Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e6e319a275 | ||
|
|
869b13cb57 | ||
|
|
e190785d4f | ||
|
|
3fd05ff833 | ||
|
|
e7f24f0be4 | ||
|
|
1bfe460e4b | ||
|
|
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
|
||||||
+41
-16
@@ -1,38 +1,43 @@
|
|||||||
# Gitea Actions
|
# Gitea Actions
|
||||||
|
|
||||||
Workflow: [workflows/ci.yaml](workflows/ci.yaml).
|
| Workflow | Когда | Что |
|
||||||
|
|----------|--------|-----|
|
||||||
| Job | PR | push в main |
|
| [workflows/ci.yaml](workflows/ci.yaml) | pull request в main/master | quality gates + commitlint |
|
||||||
|-----|-----|-------------|
|
| [workflows/cd.yaml](workflows/cd.yaml) | push в main/master | quality gates + semantic-release + docker push |
|
||||||
| changes, openapi, web, go, bird2 | quality gates | quality gates |
|
| [workflows/quality.yaml](workflows/quality.yaml) | reusable (`workflow_call`) | changes, openapi, web, go, bird2, commitlint, docker-check |
|
||||||
| commitlint | да | — |
|
|
||||||
| **release** | — | semantic-release + docker push (один run) |
|
|
||||||
|
|
||||||
Подробнее: [docs/releasing.md](../docs/releasing.md).
|
Подробнее: [docs/releasing.md](../docs/releasing.md).
|
||||||
|
|
||||||
## CI (quality gates)
|
## CI (quality gates)
|
||||||
|
|
||||||
Job **changes** вычисляет флаги по путям в diff. Полный прогон (все узлы openapi / web / go / bird2 в графе): `.gitea/workflows/*`, `scripts/*`, `.golangci.yml`, `.pre-commit-config.yaml`, корневой `package.json` / `.releaserc.json`. Отдельно: `migrations/*`, `docs/openapi.yaml` → `go` / `openapi` и т.д. (см. `ci.yaml`).
|
Job **changes** вычисляет флаги по путям в diff. Полный прогон: `.gitea/workflows/*`, `scripts/*`, `.golangci.yml`, `.pre-commit-config.yaml`, корневой `package.json` / `.releaserc.json`. Правки `.cursor/`, `.claude/`, `*.md` (кроме `docs/api.md` / `docs/access.md` / `docs/openapi.yaml`) quality jobs не запускают.
|
||||||
|
|
||||||
На **pull request** — **commitlint** (Conventional Commits).
|
На **pull request** — **commitlint**. При изменении `deploy/docker/**` — job **docker-check** (`bake --print`, bake без `--push` если есть доступ к registry).
|
||||||
|
|
||||||
Runner: `ubuntu-latest`, **bird2** из apt, Docker для job **release**.
|
Кэш зависимостей — нативный `actions/cache` (cache server act_runner), ключ `sha256sum` lockfile (не `hashFiles`). Пути **абсолютные** (`$HOME/.pnpm-store`, `go env GOMODCACHE` / `GOCACHE`): тильда `~` на Gitea часто не раскрывается и даёт вечный miss.
|
||||||
|
|
||||||
## Release (job в ci.yaml)
|
Кэшируется целиком: pnpm store + `node_modules` + corepack; Go modules + GOCACHE + `golangci-lint` в `GOBIN`. При hit: `pnpm install --offline`, `go mod download` без сети. `setup-go cache:` и `golangci-lint-action` не используем — они завязаны на `hashFiles`.
|
||||||
|
|
||||||
После успешных quality gates на **push в main** job **release**:
|
Если restore пишет `connect ECONNREFUSED` / `cache server not configured` — на runner включите cache server (см. ниже). Иначе каждый job снова качает пакеты (~минуты).
|
||||||
|
|
||||||
1. `npx semantic-release` — тег `vX.Y.Z` на **текущий commit** (без дополнительного commit в main).
|
Runner: `ubuntu-latest`, **bird2** из apt, Docker для **docker-check** (PR) и **publish** (CD).
|
||||||
|
|
||||||
|
## CD (job publish)
|
||||||
|
|
||||||
|
После успешных quality gates на **push в main** job **publish**:
|
||||||
|
|
||||||
|
1. `pnpm exec semantic-release` — тег `vX.Y.Z` на **текущий commit** (без дополнительного commit в main).
|
||||||
2. Gitea Release + `CHANGELOG.md` как attachment (не в git).
|
2. Gitea Release + `CHANGELOG.md` как attachment (не в git).
|
||||||
3. `docker buildx bake default --push` с `VERSION=X.Y.Z` — в том же job.
|
3. Зеркало base-образов в `evobgp-buildcache:base-*` (`deploy/docker/mirror-base-images.sh`; skip существующих тегов, `linux/amd64`, retry при 429).
|
||||||
|
4. `docker buildx bake default --push` с `VERSION=X.Y.Z`, `pull=false`, named builder `evobgp` (`cleanup: false`).
|
||||||
|
|
||||||
Если releasable-коммитов нет — semantic-release no-op, образы не публикуются.
|
Если releasable-коммитов нет — semantic-release no-op, образы не публикуются.
|
||||||
|
|
||||||
Повтор упавшего **release** (тег уже есть, bake нет): detect берёт `v*` на `HEAD` и всё равно пушит образы. Подробнее: [docs/releasing.md](../docs/releasing.md#перезапуск-упавшего-job-release).
|
Повтор упавшего **publish** (тег уже есть, bake нет): detect берёт `v*` на `HEAD` и всё равно пушит образы. Подробнее: [docs/releasing.md](../docs/releasing.md#перезапуск-упавшего-job-publish).
|
||||||
|
|
||||||
### Секреты
|
### Секреты
|
||||||
|
|
||||||
**`ACTIONS_PAT`**: push tags, releases, Container Registry. Fallback: **`gitea.token`**.
|
**`ACTIONS_PAT`**: push tags, releases, Container Registry. Для git tag fallback: `github.token`. Push OCI — **только PAT** (у `GITEA_TOKEN` нет права packages).
|
||||||
|
|
||||||
### Теги образов
|
### Теги образов
|
||||||
|
|
||||||
@@ -46,6 +51,8 @@ git.shx.one/<owner>/<имя>:sha-<full-sha>
|
|||||||
|
|
||||||
Имена образов: `evobgp-api`, `evobgp-all`, `evobgp-scheduler`, `evobgp-ingest`, `evobgp-render`, `evobgp-deploy`, `evobgp-node`, `evobgp-web`, `evobgp-web-all`, `evobgp-agent`, `evobgp-bird2`.
|
Имена образов: `evobgp-api`, `evobgp-all`, `evobgp-scheduler`, `evobgp-ingest`, `evobgp-render`, `evobgp-deploy`, `evobgp-node`, `evobgp-web`, `evobgp-web-all`, `evobgp-agent`, `evobgp-bird2`.
|
||||||
|
|
||||||
|
Кэш сборки: `evobgp-buildcache:{go,web,birdc}-buildcache` и `evobgp-buildcache:base-*`.
|
||||||
|
|
||||||
**Удалённый спикер** (compose `deploy/compose/docker-compose.remote-speaker.yaml`): `evobgp-bird2`, `evobgp-agent`, `evobgp-node` (fallback profile); Traefik — внешний `traefik:latest`. CI: `scripts/validate-remote-speaker-compose.sh`.
|
**Удалённый спикер** (compose `deploy/compose/docker-compose.remote-speaker.yaml`): `evobgp-bird2`, `evobgp-agent`, `evobgp-node` (fallback profile); Traefik — внешний `traefik:latest`. CI: `scripts/validate-remote-speaker-compose.sh`.
|
||||||
|
|
||||||
Пример:
|
Пример:
|
||||||
@@ -55,3 +62,21 @@ docker pull git.shx.one/myuser/evobgp-api:1.2.3
|
|||||||
```
|
```
|
||||||
|
|
||||||
См. [deploy/docker/README.md](../deploy/docker/README.md), [docs/quickstart.md](../docs/quickstart.md).
|
См. [deploy/docker/README.md](../deploy/docker/README.md), [docs/quickstart.md](../docs/quickstart.md).
|
||||||
|
|
||||||
|
## act_runner: cache server
|
||||||
|
|
||||||
|
`actions/cache` ходит в **встроенный cache server** runner (не GitHub `type=gha`). Кэш локален для этого runner.
|
||||||
|
|
||||||
|
В `config.yaml` runner:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
cache:
|
||||||
|
enabled: true
|
||||||
|
dir: "" # по умолчанию $HOME/.cache/actcache
|
||||||
|
host: "" # IP, доступный из job-контейнера (не 0.0.0.0)
|
||||||
|
port: 8088
|
||||||
|
```
|
||||||
|
|
||||||
|
Если runner в Docker, а jobs — отдельные контейнеры: пробросьте порт и задайте `host` (LAN IP хоста) или `external_server: "http://<host>:8088/"`. Иначе restore — timeout/ECONNREFUSED и пакеты качаются снова.
|
||||||
|
|
||||||
|
Не делайте `docker system prune -a` по cron: сотрётся и Docker-кэш FROM, и пользы от `cleanup: false` у buildx не будет.
|
||||||
|
|||||||
@@ -0,0 +1,148 @@
|
|||||||
|
name: CD
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main, master]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
quality:
|
||||||
|
uses: ./.gitea/workflows/quality.yaml
|
||||||
|
with:
|
||||||
|
is_pull_request: false
|
||||||
|
before_sha: ${{ github.event.before }}
|
||||||
|
head_sha: ${{ github.sha }}
|
||||||
|
allow_registry_login: false
|
||||||
|
secrets:
|
||||||
|
ACTIONS_PAT: ${{ secrets.ACTIONS_PAT }}
|
||||||
|
|
||||||
|
publish:
|
||||||
|
needs: [quality]
|
||||||
|
if: >-
|
||||||
|
always() &&
|
||||||
|
(github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master') &&
|
||||||
|
needs.quality.result == 'success'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
packages: write
|
||||||
|
releases: write
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
fetch-tags: true
|
||||||
|
token: ${{ secrets.ACTIONS_PAT || gitea.token }}
|
||||||
|
persist-credentials: true
|
||||||
|
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||||
|
with:
|
||||||
|
node-version: "22"
|
||||||
|
- name: Export cache paths
|
||||||
|
run: sh scripts/ci/export-cache-env.sh
|
||||||
|
- id: pnpm-hash
|
||||||
|
run: echo "key=$(sha256sum pnpm-lock.yaml | awk '{print $1}')" >> "$GITHUB_OUTPUT"
|
||||||
|
- id: pnpm-cache
|
||||||
|
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
${{ env.PNPM_STORE_DIR }}
|
||||||
|
${{ env.COREPACK_HOME }}
|
||||||
|
node_modules
|
||||||
|
apps/web/node_modules
|
||||||
|
packages/ui/node_modules
|
||||||
|
key: pnpm-${{ runner.os }}-${{ steps.pnpm-hash.outputs.key }}
|
||||||
|
restore-keys: |
|
||||||
|
pnpm-${{ runner.os }}-
|
||||||
|
- name: Install release tooling
|
||||||
|
env:
|
||||||
|
PNPM_CACHE_HIT: ${{ steps.pnpm-cache.outputs.cache-hit }}
|
||||||
|
run: sh scripts/ci/pnpm-ci.sh
|
||||||
|
- name: Verify releasable commit messages
|
||||||
|
run: pnpm exec node scripts/commit/verify-release-commits.mjs
|
||||||
|
- name: Semantic release
|
||||||
|
run: pnpm exec semantic-release
|
||||||
|
env:
|
||||||
|
GITEA_URL: https://git.shx.one
|
||||||
|
GITEA_TOKEN: ${{ secrets.ACTIONS_PAT || gitea.token }}
|
||||||
|
- name: Detect new release
|
||||||
|
id: rel
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
version=""
|
||||||
|
if [ -f .release-version ]; then
|
||||||
|
version="$(tr -d '[:space:]' < .release-version)"
|
||||||
|
echo "New release from semantic-release: $version"
|
||||||
|
else
|
||||||
|
git fetch --tags --force origin || true
|
||||||
|
tag="$(git tag --points-at HEAD --list 'v*.*.*' | sort -V | tail -n1 || true)"
|
||||||
|
if [ -n "${tag:-}" ]; then
|
||||||
|
version="${tag#v}"
|
||||||
|
echo "Reuse existing tag $tag on HEAD (release retry)"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
if [ -n "${version:-}" ]; then
|
||||||
|
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "released=true" >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
echo "released=false" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "No releasable commits — skipping image publish"
|
||||||
|
fi
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
if: steps.rel.outputs.released == 'true'
|
||||||
|
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
|
||||||
|
with:
|
||||||
|
name: evobgp
|
||||||
|
driver: docker-container
|
||||||
|
cleanup: false
|
||||||
|
- name: Prepare image metadata
|
||||||
|
if: steps.rel.outputs.released == 'true'
|
||||||
|
id: meta
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
echo "version=${{ steps.rel.outputs.version }}" >> "$GITHUB_OUTPUT"
|
||||||
|
owner_lc="$(echo '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]')"
|
||||||
|
echo "owner_lc=$owner_lc" >> "$GITHUB_OUTPUT"
|
||||||
|
short_sha="$(echo '${{ github.sha }}' | cut -c1-7)"
|
||||||
|
echo "short_sha=$short_sha" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "build_time=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT"
|
||||||
|
- name: Log in to Gitea Registry
|
||||||
|
if: steps.rel.outputs.released == 'true'
|
||||||
|
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
|
||||||
|
with:
|
||||||
|
registry: git.shx.one
|
||||||
|
username: ${{ gitea.actor }}
|
||||||
|
password: ${{ secrets.ACTIONS_PAT }}
|
||||||
|
- name: Mirror base images into buildcache
|
||||||
|
if: steps.rel.outputs.released == 'true'
|
||||||
|
env:
|
||||||
|
REGISTRY: git.shx.one/${{ steps.meta.outputs.owner_lc }}
|
||||||
|
MIRROR_ENV_FILE: ${{ runner.temp }}/mirror-base.env
|
||||||
|
run: sh deploy/docker/mirror-base-images.sh
|
||||||
|
- name: Build and push images (bake)
|
||||||
|
if: steps.rel.outputs.released == 'true'
|
||||||
|
env:
|
||||||
|
REGISTRY: git.shx.one/${{ steps.meta.outputs.owner_lc }}
|
||||||
|
IMAGE_TAG: latest
|
||||||
|
VERSION: ${{ steps.meta.outputs.version }}
|
||||||
|
SHORT_SHA: ${{ steps.meta.outputs.short_sha }}
|
||||||
|
SHA_FULL: ${{ github.sha }}
|
||||||
|
BUILD_TIME: ${{ steps.meta.outputs.build_time }}
|
||||||
|
CACHE_REF_GO: git.shx.one/${{ steps.meta.outputs.owner_lc }}/evobgp-buildcache:go-buildcache
|
||||||
|
CACHE_REF_WEB: git.shx.one/${{ steps.meta.outputs.owner_lc }}/evobgp-buildcache:web-buildcache
|
||||||
|
CACHE_REF_BIRDC: git.shx.one/${{ steps.meta.outputs.owner_lc }}/evobgp-buildcache:birdc-buildcache
|
||||||
|
BUILDX_BAKE_ENTITLEMENTS_FS: "0"
|
||||||
|
BUILDX_BAKE_FILE_RELATIVE_PATHS: "1"
|
||||||
|
MIRROR_ENV_FILE: ${{ runner.temp }}/mirror-base.env
|
||||||
|
working-directory: deploy/docker
|
||||||
|
run: |
|
||||||
|
set -euxo pipefail
|
||||||
|
if [ -f "${MIRROR_ENV_FILE}" ]; then
|
||||||
|
set -a
|
||||||
|
# shellcheck disable=SC1090
|
||||||
|
. "${MIRROR_ENV_FILE}"
|
||||||
|
set +a
|
||||||
|
fi
|
||||||
|
docker buildx bake --allow=fs.read="${{ github.workspace }}" \
|
||||||
|
-f docker-bake.hcl default --push
|
||||||
+16
-380
@@ -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,428 @@
|
|||||||
|
# Quality gates (reusable). Callers: ci.yaml (PR), cd.yaml (push main).
|
||||||
|
name: quality
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_call:
|
||||||
|
inputs:
|
||||||
|
is_pull_request:
|
||||||
|
type: boolean
|
||||||
|
required: true
|
||||||
|
base_sha:
|
||||||
|
type: string
|
||||||
|
required: false
|
||||||
|
default: ""
|
||||||
|
head_sha:
|
||||||
|
type: string
|
||||||
|
required: false
|
||||||
|
default: ""
|
||||||
|
before_sha:
|
||||||
|
type: string
|
||||||
|
required: false
|
||||||
|
default: ""
|
||||||
|
allow_registry_login:
|
||||||
|
type: boolean
|
||||||
|
required: false
|
||||||
|
default: false
|
||||||
|
secrets:
|
||||||
|
ACTIONS_PAT:
|
||||||
|
required: false
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
changes:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
outputs:
|
||||||
|
openapi: ${{ steps.detect.outputs.openapi }}
|
||||||
|
go: ${{ steps.detect.outputs.go }}
|
||||||
|
web: ${{ steps.detect.outputs.web }}
|
||||||
|
bird_conf: ${{ steps.detect.outputs.bird_conf }}
|
||||||
|
docker_go: ${{ steps.detect.outputs.docker_go }}
|
||||||
|
docker_web: ${{ steps.detect.outputs.docker_web }}
|
||||||
|
docker_bird: ${{ steps.detect.outputs.docker_bird }}
|
||||||
|
steps:
|
||||||
|
- if: ${{ inputs.is_pull_request }}
|
||||||
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
- if: ${{ inputs.is_pull_request == false }}
|
||||||
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
with:
|
||||||
|
fetch-depth: 2
|
||||||
|
- id: detect
|
||||||
|
name: Detect changed paths per module
|
||||||
|
env:
|
||||||
|
IS_PR: ${{ inputs.is_pull_request }}
|
||||||
|
BASE_SHA: ${{ inputs.base_sha }}
|
||||||
|
HEAD_SHA: ${{ inputs.head_sha }}
|
||||||
|
BEFORE_SHA: ${{ inputs.before_sha }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
openapi=false
|
||||||
|
go=false
|
||||||
|
web=false
|
||||||
|
bird_conf=false
|
||||||
|
docker_go=false
|
||||||
|
docker_web=false
|
||||||
|
docker_bird=false
|
||||||
|
|
||||||
|
set_all_flags_true() {
|
||||||
|
openapi=true
|
||||||
|
go=true
|
||||||
|
web=true
|
||||||
|
bird_conf=true
|
||||||
|
docker_go=true
|
||||||
|
docker_web=true
|
||||||
|
docker_bird=true
|
||||||
|
}
|
||||||
|
|
||||||
|
write_outputs() {
|
||||||
|
for v in openapi go web bird_conf docker_go docker_web docker_bird; do
|
||||||
|
eval "echo \"\$v=\$$v\"" >> "$GITHUB_OUTPUT"
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
if [ "$IS_PR" = "true" ]; then
|
||||||
|
FILES="$(git diff --name-only "$BASE_SHA" "$HEAD_SHA")"
|
||||||
|
else
|
||||||
|
after="${HEAD_SHA:-$(git rev-parse HEAD)}"
|
||||||
|
before="$BEFORE_SHA"
|
||||||
|
if [ -n "$before" ] && [ "$before" != "0000000000000000000000000000000000000000" ]; then
|
||||||
|
FILES="$(git diff --name-only "$before" "$after")"
|
||||||
|
elif git rev-parse --verify HEAD~1 >/dev/null 2>&1; then
|
||||||
|
FILES="$(git diff --name-only HEAD~1 HEAD)"
|
||||||
|
else
|
||||||
|
set_all_flags_true
|
||||||
|
write_outputs
|
||||||
|
echo "No parent commit — full pipeline (all modules)"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$(printf '%s' "$FILES" | tr -d '[:space:]')" ]; then
|
||||||
|
set_all_flags_true
|
||||||
|
write_outputs
|
||||||
|
echo "Empty diff — full pipeline fallback"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
full_pipeline=false
|
||||||
|
|
||||||
|
while IFS= read -r f || [ -n "${f:-}" ]; do
|
||||||
|
[ -z "${f:-}" ] && continue
|
||||||
|
case "$f" in
|
||||||
|
.gitea/workflows/*|.golangci.yml|.pre-commit-config.yaml|scripts/*)
|
||||||
|
full_pipeline=true
|
||||||
|
;;
|
||||||
|
docs/openapi.yaml|redocly.yaml)
|
||||||
|
openapi=true
|
||||||
|
;;
|
||||||
|
docs/api.md|docs/access.md)
|
||||||
|
openapi=true
|
||||||
|
go=true
|
||||||
|
;;
|
||||||
|
.cursor/*|.claude/*|.codegraph/*|memory-bank/*)
|
||||||
|
;;
|
||||||
|
*.md|AGENTS.md)
|
||||||
|
;;
|
||||||
|
apps/web/README.md|apps/web/components.json|packages/ui/components.json)
|
||||||
|
;;
|
||||||
|
apps/web/*|packages/ui/*|packages/shared/*)
|
||||||
|
web=true
|
||||||
|
;;
|
||||||
|
deploy/bird/*)
|
||||||
|
bird_conf=true
|
||||||
|
go=true
|
||||||
|
;;
|
||||||
|
deploy/compose/*|deploy/docker/*|.dockerignore)
|
||||||
|
docker_go=true
|
||||||
|
docker_web=true
|
||||||
|
docker_bird=true
|
||||||
|
go=true
|
||||||
|
;;
|
||||||
|
go.mod|go.sum|go.work)
|
||||||
|
go=true
|
||||||
|
;;
|
||||||
|
migrations/*)
|
||||||
|
go=true
|
||||||
|
;;
|
||||||
|
cmd/*|internal/*|*.go)
|
||||||
|
go=true
|
||||||
|
bird_conf=true
|
||||||
|
;;
|
||||||
|
docs/*)
|
||||||
|
;;
|
||||||
|
package.json|package-lock.json|pnpm-lock.yaml|pnpm-workspace.yaml|.releaserc.json)
|
||||||
|
full_pipeline=true
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done <<< "$FILES"
|
||||||
|
|
||||||
|
if $full_pipeline; then
|
||||||
|
set_all_flags_true
|
||||||
|
fi
|
||||||
|
|
||||||
|
write_outputs
|
||||||
|
|
||||||
|
echo "Changed files (first 30):"
|
||||||
|
printf '%s\n' "$FILES" | head -n 30
|
||||||
|
echo "--- flags ---"
|
||||||
|
echo "openapi=$openapi go=$go web=$web bird_conf=$bird_conf"
|
||||||
|
echo "docker_go=$docker_go docker_web=$docker_web docker_bird=$docker_bird full_pipeline=$full_pipeline"
|
||||||
|
|
||||||
|
openapi:
|
||||||
|
needs: [changes]
|
||||||
|
if: needs.changes.outputs.openapi == 'true' || needs.changes.outputs.web == 'true'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||||
|
with:
|
||||||
|
node-version: "22"
|
||||||
|
- name: Export cache paths
|
||||||
|
run: sh scripts/ci/export-cache-env.sh
|
||||||
|
- id: pnpm-hash
|
||||||
|
run: echo "key=$(sha256sum pnpm-lock.yaml | awk '{print $1}')" >> "$GITHUB_OUTPUT"
|
||||||
|
- id: pnpm-cache
|
||||||
|
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
${{ env.PNPM_STORE_DIR }}
|
||||||
|
${{ env.COREPACK_HOME }}
|
||||||
|
node_modules
|
||||||
|
apps/web/node_modules
|
||||||
|
packages/ui/node_modules
|
||||||
|
key: pnpm-${{ runner.os }}-${{ steps.pnpm-hash.outputs.key }}
|
||||||
|
restore-keys: |
|
||||||
|
pnpm-${{ runner.os }}-
|
||||||
|
- name: pnpm install, Redocly, codegen check
|
||||||
|
env:
|
||||||
|
PNPM_CACHE_HIT: ${{ steps.pnpm-cache.outputs.cache-hit }}
|
||||||
|
run: |
|
||||||
|
set -euxo pipefail
|
||||||
|
sh scripts/ci/pnpm-ci.sh
|
||||||
|
pnpm exec redocly lint docs/openapi.yaml
|
||||||
|
chmod +x scripts/check-openapi-gen.sh
|
||||||
|
sh scripts/check-openapi-gen.sh
|
||||||
|
|
||||||
|
web:
|
||||||
|
needs: [changes]
|
||||||
|
if: needs.changes.outputs.web == 'true'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||||
|
with:
|
||||||
|
node-version: "22"
|
||||||
|
- name: Export cache paths
|
||||||
|
run: sh scripts/ci/export-cache-env.sh
|
||||||
|
- id: pnpm-hash
|
||||||
|
run: echo "key=$(sha256sum pnpm-lock.yaml | awk '{print $1}')" >> "$GITHUB_OUTPUT"
|
||||||
|
- id: pnpm-cache
|
||||||
|
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
${{ env.PNPM_STORE_DIR }}
|
||||||
|
${{ env.COREPACK_HOME }}
|
||||||
|
node_modules
|
||||||
|
apps/web/node_modules
|
||||||
|
packages/ui/node_modules
|
||||||
|
key: pnpm-${{ runner.os }}-${{ steps.pnpm-hash.outputs.key }}
|
||||||
|
restore-keys: |
|
||||||
|
pnpm-${{ runner.os }}-
|
||||||
|
- name: pnpm install, typecheck, lint, test, build
|
||||||
|
env:
|
||||||
|
PNPM_CACHE_HIT: ${{ steps.pnpm-cache.outputs.cache-hit }}
|
||||||
|
run: |
|
||||||
|
set -euxo pipefail
|
||||||
|
sh scripts/ci/pnpm-ci.sh
|
||||||
|
pnpm --filter @evobgp/web run typecheck
|
||||||
|
pnpm --filter @evobgp/web run lint
|
||||||
|
pnpm --filter @evobgp/web run test
|
||||||
|
pnpm --filter @evobgp/web run build
|
||||||
|
|
||||||
|
go:
|
||||||
|
needs: [changes]
|
||||||
|
if: needs.changes.outputs.go == 'true'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
- uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0
|
||||||
|
with:
|
||||||
|
go-version: "1.24"
|
||||||
|
cache: false
|
||||||
|
- name: Export cache paths
|
||||||
|
run: sh scripts/ci/export-cache-env.sh
|
||||||
|
- id: go-hash
|
||||||
|
run: echo "key=$(sha256sum go.sum | awk '{print $1}')" >> "$GITHUB_OUTPUT"
|
||||||
|
- uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
${{ env.GOMODCACHE }}
|
||||||
|
${{ env.GOCACHE }}
|
||||||
|
${{ env.GOBIN }}
|
||||||
|
${{ env.GOLANGCI_LINT_CACHE }}
|
||||||
|
key: go-${{ runner.os }}-1.24-gl1.64.8-${{ steps.go-hash.outputs.key }}
|
||||||
|
restore-keys: |
|
||||||
|
go-${{ runner.os }}-1.24-gl1.64.8-
|
||||||
|
go-${{ runner.os }}-1.24-
|
||||||
|
- name: Download modules
|
||||||
|
env:
|
||||||
|
GOMODCACHE: ${{ env.GOMODCACHE }}
|
||||||
|
GOCACHE: ${{ env.GOCACHE }}
|
||||||
|
run: go mod download
|
||||||
|
- name: Vet
|
||||||
|
env:
|
||||||
|
GOFLAGS: -mod=readonly
|
||||||
|
run: go vet ./...
|
||||||
|
- name: Lint httpapi (ERR-01 / ARCH-01)
|
||||||
|
run: sh scripts/lint-httpapi.sh
|
||||||
|
- name: Check migration pairs (DEP-03)
|
||||||
|
run: sh scripts/check-migrations-pair.sh
|
||||||
|
- name: Validate remote speaker compose
|
||||||
|
run: sh scripts/validate-remote-speaker-compose.sh
|
||||||
|
- name: golangci-lint
|
||||||
|
env:
|
||||||
|
GOLANGCI_LINT_VERSION: v1.64.8
|
||||||
|
run: sh scripts/ci/golangci-lint.sh
|
||||||
|
- name: Test
|
||||||
|
env:
|
||||||
|
GOFLAGS: -mod=readonly
|
||||||
|
GOMODCACHE: ${{ env.GOMODCACHE }}
|
||||||
|
GOCACHE: ${{ env.GOCACHE }}
|
||||||
|
run: go test ./... -race -count=1
|
||||||
|
- name: Build all commands
|
||||||
|
env:
|
||||||
|
GOFLAGS: -mod=readonly
|
||||||
|
GOMODCACHE: ${{ env.GOMODCACHE }}
|
||||||
|
GOCACHE: ${{ env.GOCACHE }}
|
||||||
|
run: |
|
||||||
|
set -euxo pipefail
|
||||||
|
out="${RUNNER_TEMP}/evobgp-bin"
|
||||||
|
mkdir -p "$out"
|
||||||
|
for d in cmd/*/; do
|
||||||
|
name="$(basename "$d")"
|
||||||
|
go build -o "$out/$name" "./$d"
|
||||||
|
done
|
||||||
|
|
||||||
|
bird2:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [changes, go]
|
||||||
|
if: >-
|
||||||
|
always() &&
|
||||||
|
needs.changes.result == 'success' &&
|
||||||
|
needs.go.result != 'failure' &&
|
||||||
|
(needs.changes.outputs.go == 'true' ||
|
||||||
|
needs.changes.outputs.bird_conf == 'true' ||
|
||||||
|
needs.changes.outputs.docker_bird == 'true' ||
|
||||||
|
needs.changes.outputs.docker_go == 'true')
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
- name: Install bird2 (репозиторий Ubuntu runner, как в образе evobgp-bird2)
|
||||||
|
run: |
|
||||||
|
set -euxo pipefail
|
||||||
|
if command -v sudo >/dev/null 2>&1; then SUDO=sudo; else SUDO=""; fi
|
||||||
|
$SUDO apt-get update -qq
|
||||||
|
DEBIAN_FRONTEND=noninteractive $SUDO apt-get install -y -qq bird2
|
||||||
|
bird --version
|
||||||
|
- name: bird -p on all scenario bird.conf files
|
||||||
|
env:
|
||||||
|
WORKSPACE: ${{ github.workspace }}
|
||||||
|
run: |
|
||||||
|
set -euxo pipefail
|
||||||
|
WS="${WORKSPACE:-$PWD}"
|
||||||
|
cd "$WS"
|
||||||
|
if [ ! -f internal/birdfmt/testdata/scenarios/minimal/bird.conf ]; then
|
||||||
|
echo "Нет сценариев BIRD в checkout. Проверьте, что internal/birdfmt/testdata/scenarios закоммичен и push в remote."
|
||||||
|
ls -la internal/birdfmt/testdata/ 2>/dev/null || ls -la
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
for conf in internal/birdfmt/testdata/scenarios/*/bird.conf; do
|
||||||
|
echo "==> $conf"
|
||||||
|
bird -c "$WS/$conf" -p
|
||||||
|
done
|
||||||
|
|
||||||
|
commitlint:
|
||||||
|
if: inputs.is_pull_request
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||||
|
with:
|
||||||
|
node-version: "22"
|
||||||
|
- name: Export cache paths
|
||||||
|
run: sh scripts/ci/export-cache-env.sh
|
||||||
|
- id: pnpm-hash
|
||||||
|
run: echo "key=$(sha256sum pnpm-lock.yaml | awk '{print $1}')" >> "$GITHUB_OUTPUT"
|
||||||
|
- id: pnpm-cache
|
||||||
|
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
${{ env.PNPM_STORE_DIR }}
|
||||||
|
${{ env.COREPACK_HOME }}
|
||||||
|
node_modules
|
||||||
|
apps/web/node_modules
|
||||||
|
packages/ui/node_modules
|
||||||
|
key: pnpm-${{ runner.os }}-${{ steps.pnpm-hash.outputs.key }}
|
||||||
|
restore-keys: |
|
||||||
|
pnpm-${{ runner.os }}-
|
||||||
|
- name: Lint commit messages
|
||||||
|
env:
|
||||||
|
BASE_SHA: ${{ inputs.base_sha }}
|
||||||
|
HEAD_SHA: ${{ inputs.head_sha }}
|
||||||
|
PNPM_CACHE_HIT: ${{ steps.pnpm-cache.outputs.cache-hit }}
|
||||||
|
run: |
|
||||||
|
set -euxo pipefail
|
||||||
|
sh scripts/ci/pnpm-ci.sh
|
||||||
|
pnpm exec commitlint --from "$BASE_SHA" --to "$HEAD_SHA"
|
||||||
|
|
||||||
|
docker-check:
|
||||||
|
needs: [changes]
|
||||||
|
if: >-
|
||||||
|
inputs.is_pull_request &&
|
||||||
|
(needs.changes.outputs.docker_go == 'true' ||
|
||||||
|
needs.changes.outputs.docker_web == 'true' ||
|
||||||
|
needs.changes.outputs.docker_bird == 'true')
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
- uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
|
||||||
|
with:
|
||||||
|
name: evobgp
|
||||||
|
driver: docker-container
|
||||||
|
cleanup: false
|
||||||
|
- name: Log in to Gitea Registry
|
||||||
|
if: inputs.allow_registry_login
|
||||||
|
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
|
||||||
|
with:
|
||||||
|
registry: git.shx.one
|
||||||
|
username: ${{ gitea.actor }}
|
||||||
|
password: ${{ secrets.ACTIONS_PAT }}
|
||||||
|
- name: bake --print
|
||||||
|
working-directory: deploy/docker
|
||||||
|
env:
|
||||||
|
BUILDX_BAKE_ENTITLEMENTS_FS: "0"
|
||||||
|
BUILDX_BAKE_FILE_RELATIVE_PATHS: "1"
|
||||||
|
run: docker buildx bake --allow=fs.read="${{ github.workspace }}" -f docker-bake.hcl --print default
|
||||||
|
- name: bake (no push)
|
||||||
|
if: inputs.allow_registry_login
|
||||||
|
working-directory: deploy/docker
|
||||||
|
env:
|
||||||
|
BUILDX_BAKE_ENTITLEMENTS_FS: "0"
|
||||||
|
BUILDX_BAKE_FILE_RELATIVE_PATHS: "1"
|
||||||
|
CACHE_REF_GO: git.shx.one/${{ github.repository_owner }}/evobgp-buildcache:go-buildcache
|
||||||
|
CACHE_REF_WEB: git.shx.one/${{ github.repository_owner }}/evobgp-buildcache:web-buildcache
|
||||||
|
CACHE_REF_BIRDC: git.shx.one/${{ github.repository_owner }}/evobgp-buildcache:birdc-buildcache
|
||||||
|
run: |
|
||||||
|
set -euxo pipefail
|
||||||
|
owner_lc="$(echo '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]')"
|
||||||
|
export CACHE_REF_GO="git.shx.one/${owner_lc}/evobgp-buildcache:go-buildcache"
|
||||||
|
export CACHE_REF_WEB="git.shx.one/${owner_lc}/evobgp-buildcache:web-buildcache"
|
||||||
|
export CACHE_REF_BIRDC="git.shx.one/${owner_lc}/evobgp-buildcache:birdc-buildcache"
|
||||||
|
docker buildx bake --allow=fs.read="${{ github.workspace }}" -f docker-bake.hcl default
|
||||||
@@ -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,8 +165,9 @@ 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" search={{ create: true }} />}>
|
||||||
<PlusIcon />
|
<PlusIcon />
|
||||||
Создать
|
Создать
|
||||||
</Button>
|
</Button>
|
||||||
@@ -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',
|
||||||
@@ -16,14 +16,15 @@ const ACTIONS: QuickActionItem[] = [
|
|||||||
id: 'new-module',
|
id: 'new-module',
|
||||||
title: 'Создать модуль',
|
title: 'Создать модуль',
|
||||||
description: 'Новый модуль маршрутизации и источники префиксов.',
|
description: 'Новый модуль маршрутизации и источники префиксов.',
|
||||||
to: '/modules/new',
|
to: '/modules',
|
||||||
|
search: { create: true },
|
||||||
icon: <Plus aria-hidden />,
|
icon: <Plus aria-hidden />,
|
||||||
iconClassName: 'text-primary',
|
iconClassName: 'text-primary',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'communities',
|
id: 'communities',
|
||||||
title: 'BGP-сообщества',
|
title: 'BGP community',
|
||||||
description: 'Справочник BGP-сообществ для политик экспорта.',
|
description: 'Справочник BGP community для политик экспорта.',
|
||||||
to: '/directories',
|
to: '/directories',
|
||||||
icon: <Tags aria-hidden />,
|
icon: <Tags aria-hidden />,
|
||||||
iconClassName: 'text-info',
|
iconClassName: 'text-info',
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { ColumnDef } from '@tanstack/react-table'
|
|||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
|
|
||||||
import { DataGridPrimaryCell } from '@/components/data-grid-cell'
|
import { DataGridPrimaryCell } from '@/components/data-grid-cell'
|
||||||
import { DataGridSection } from '@/components/data-grid-shell'
|
import { DataGridShell } from '@/components/data-grid-shell'
|
||||||
import { StatusBadge } from '@/components/status-badge'
|
import { StatusBadge } from '@/components/status-badge'
|
||||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||||
import { DATA_GRID_DENSE_LAYOUT } from '@/lib/data-grid-defaults'
|
import { DATA_GRID_DENSE_LAYOUT } from '@/lib/data-grid-defaults'
|
||||||
@@ -29,7 +29,6 @@ export function DashboardRecentJobsGrid({
|
|||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<DataGridPrimaryCell
|
<DataGridPrimaryCell
|
||||||
title={jobKindRu(row.original.kind)}
|
title={jobKindRu(row.original.kind)}
|
||||||
accent="mono"
|
|
||||||
subtitle={
|
subtitle={
|
||||||
row.original.meta?.module_id
|
row.original.meta?.module_id
|
||||||
? (nameById.get(String(row.original.meta.module_id)) ?? undefined)
|
? (nameById.get(String(row.original.meta.module_id)) ?? undefined)
|
||||||
@@ -49,7 +48,7 @@ export function DashboardRecentJobsGrid({
|
|||||||
[nameById],
|
[nameById],
|
||||||
)
|
)
|
||||||
|
|
||||||
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
|
const { table, filteredCount } = useClientDataGrid({
|
||||||
data,
|
data,
|
||||||
columns,
|
columns,
|
||||||
getSearchText: (row) => {
|
getSearchText: (row) => {
|
||||||
@@ -63,16 +62,13 @@ export function DashboardRecentJobsGrid({
|
|||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DataGridSection
|
<DataGridShell
|
||||||
table={table}
|
table={table}
|
||||||
recordCount={filteredCount}
|
recordCount={filteredCount}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
emptyMessage="Нет задач"
|
emptyMessage="Нет задач"
|
||||||
showPagination={false}
|
showPagination={false}
|
||||||
tableLayout={DATA_GRID_DENSE_LAYOUT}
|
tableLayout={DATA_GRID_DENSE_LAYOUT}
|
||||||
searchValue={globalFilter}
|
|
||||||
onSearchChange={setGlobalFilter}
|
|
||||||
searchPlaceholder="Поиск задач…"
|
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { ColumnDef } from '@tanstack/react-table'
|
|||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
|
|
||||||
import { DataGridMutedCell, DataGridPrimaryCell } from '@/components/data-grid-cell'
|
import { DataGridMutedCell, DataGridPrimaryCell } from '@/components/data-grid-cell'
|
||||||
import { DataGridSection } from '@/components/data-grid-shell'
|
import { DataGridShell } from '@/components/data-grid-shell'
|
||||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||||
import { DATA_GRID_DENSE_LAYOUT } from '@/lib/data-grid-defaults'
|
import { DATA_GRID_DENSE_LAYOUT } from '@/lib/data-grid-defaults'
|
||||||
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
|
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
|
||||||
@@ -42,7 +42,7 @@ export function DashboardRecentRevisionsGrid({
|
|||||||
[],
|
[],
|
||||||
)
|
)
|
||||||
|
|
||||||
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
|
const { table, filteredCount } = useClientDataGrid({
|
||||||
data,
|
data,
|
||||||
columns,
|
columns,
|
||||||
getSearchText: (row) => row.id,
|
getSearchText: (row) => row.id,
|
||||||
@@ -51,16 +51,13 @@ export function DashboardRecentRevisionsGrid({
|
|||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DataGridSection
|
<DataGridShell
|
||||||
table={table}
|
table={table}
|
||||||
recordCount={filteredCount}
|
recordCount={filteredCount}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
emptyMessage="Нет ревизий"
|
emptyMessage="Нет ревизий"
|
||||||
showPagination={false}
|
showPagination={false}
|
||||||
tableLayout={DATA_GRID_DENSE_LAYOUT}
|
tableLayout={DATA_GRID_DENSE_LAYOUT}
|
||||||
searchValue={globalFilter}
|
|
||||||
onSearchChange={setGlobalFilter}
|
|
||||||
searchPlaceholder="Поиск ревизий…"
|
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 />,
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ export function LookupAddStep({
|
|||||||
<AlertTitle>Нет подходящего модуля</AlertTitle>
|
<AlertTitle>Нет подходящего модуля</AlertTitle>
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
Создайте модуль типа {wantedType}, затем повторите добавление.{' '}
|
Создайте модуль типа {wantedType}, затем повторите добавление.{' '}
|
||||||
<Button variant="link" size="sm" className="h-auto p-0" render={<Link to="/modules/new" />}>
|
<Button variant="link" size="sm" className="h-auto p-0" render={<Link to="/modules" search={{ create: true }} />}>
|
||||||
Перейти к модулям
|
Перейти к модулям
|
||||||
</Button>
|
</Button>
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ import { normalizeCdnSourceKind } from '@/lib/modules/helpers'
|
|||||||
import type { BgpCommunity, CdnPreviewResponse, CdnSource, CdnSourceCreate } from '@/types/api'
|
import type { BgpCommunity, CdnPreviewResponse, CdnSource, CdnSourceCreate } from '@/types/api'
|
||||||
|
|
||||||
const CDN_KIND_ITEMS = [
|
const CDN_KIND_ITEMS = [
|
||||||
{ value: 'plaintext', label: 'plaintext' },
|
{ value: 'plaintext', label: 'Текст' },
|
||||||
{ value: 'json', label: 'json' },
|
{ value: 'json', label: 'JSON' },
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
interface ModuleCdnSourceDialogProps {
|
interface ModuleCdnSourceDialogProps {
|
||||||
|
|||||||
@@ -0,0 +1,289 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
|
import { Button } from '@evobgp/ui/components/button'
|
||||||
|
import { Checkbox } from '@evobgp/ui/components/checkbox'
|
||||||
|
import { Input } from '@evobgp/ui/components/input'
|
||||||
|
import { Label } from '@evobgp/ui/components/label'
|
||||||
|
|
||||||
|
import { FormDrawer } from '@/components/form-drawer'
|
||||||
|
import { LoadingButton } from '@/components/loading-button'
|
||||||
|
import { CommunitySelect } from '@/components/modules/community-select'
|
||||||
|
import { SelectField } from '@/components/select-field'
|
||||||
|
import { dohProfileShortLabel } from '@/lib/modules/helpers'
|
||||||
|
import { dohPolicyRu, moduleTypeRu } from '@/lib/ui-labels'
|
||||||
|
import { useCreateModuleMutation } from '@/queries/modules'
|
||||||
|
import type {
|
||||||
|
BgpCommunity,
|
||||||
|
DohProfile,
|
||||||
|
DohResolverPolicy,
|
||||||
|
ModuleCreate,
|
||||||
|
ModuleRow,
|
||||||
|
ModuleType,
|
||||||
|
} from '@/types/api'
|
||||||
|
|
||||||
|
interface ModuleCreateDialogProps {
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
communities: BgpCommunity[]
|
||||||
|
dohProfiles: DohProfile[]
|
||||||
|
onCreated?: (mod: ModuleRow) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @see https://reui.io/preview/base/form-7 */
|
||||||
|
/** @see https://reui.io/preview/base/sheet-8 */
|
||||||
|
|
||||||
|
const MODULE_TYPE_ITEMS: { value: ModuleType; label: string }[] = [
|
||||||
|
{ value: 'IP_RANGES', label: moduleTypeRu('IP_RANGES') },
|
||||||
|
{ value: 'AS_PREFIXES', label: moduleTypeRu('AS_PREFIXES') },
|
||||||
|
{ value: 'CDN_CIDRS', label: moduleTypeRu('CDN_CIDRS') },
|
||||||
|
{ value: 'DOMAINS', label: moduleTypeRu('DOMAINS') },
|
||||||
|
]
|
||||||
|
|
||||||
|
const DOH_POLICY_ITEMS: { value: DohResolverPolicy; label: string }[] = [
|
||||||
|
{ value: 'primary_only', label: dohPolicyRu('primary_only') },
|
||||||
|
{ value: 'failover', label: dohPolicyRu('failover') },
|
||||||
|
{ value: 'union', label: dohPolicyRu('union') },
|
||||||
|
]
|
||||||
|
|
||||||
|
export function ModuleCreateDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
communities,
|
||||||
|
dohProfiles,
|
||||||
|
onCreated,
|
||||||
|
}: ModuleCreateDialogProps) {
|
||||||
|
const createMutation = useCreateModuleMutation()
|
||||||
|
|
||||||
|
const [type, setType] = useState<ModuleType>('IP_RANGES')
|
||||||
|
const [name, setName] = useState('')
|
||||||
|
const [enabled, setEnabled] = useState(true)
|
||||||
|
const [priority, setPriority] = useState('0')
|
||||||
|
const [refreshIntervalSec, setRefreshIntervalSec] = useState('')
|
||||||
|
const [cronExpr, setCronExpr] = useState('')
|
||||||
|
const [defaultCommunityId, setDefaultCommunityId] = useState<string | null>(null)
|
||||||
|
const [dohResolverPolicy, setDohResolverPolicy] = useState<DohResolverPolicy>('primary_only')
|
||||||
|
const [dohProfileIds, setDohProfileIds] = useState<string[]>([])
|
||||||
|
|
||||||
|
const isDomains = type === 'DOMAINS'
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
setType('IP_RANGES')
|
||||||
|
setName('')
|
||||||
|
setEnabled(true)
|
||||||
|
setPriority('0')
|
||||||
|
setRefreshIntervalSec('')
|
||||||
|
setCronExpr('')
|
||||||
|
setDefaultCommunityId(null)
|
||||||
|
setDohResolverPolicy('primary_only')
|
||||||
|
setDohProfileIds([])
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
function toggleDohProfile(id: string, checked: boolean) {
|
||||||
|
setDohProfileIds((prev) => {
|
||||||
|
if (checked) {
|
||||||
|
if (prev.includes(id)) return prev
|
||||||
|
return [...prev, id]
|
||||||
|
}
|
||||||
|
return prev.filter((x) => x !== id)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
const trimmedName = name.trim()
|
||||||
|
if (!trimmedName) {
|
||||||
|
toast.error('Укажите название модуля')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const priorityNum = Number(priority)
|
||||||
|
if (!Number.isFinite(priorityNum) || !Number.isInteger(priorityNum)) {
|
||||||
|
toast.error('Приоритет должен быть целым числом')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let refresh: number | undefined
|
||||||
|
if (refreshIntervalSec.trim() !== '') {
|
||||||
|
const n = Number(refreshIntervalSec)
|
||||||
|
if (!Number.isFinite(n) || !Number.isInteger(n) || n < 0) {
|
||||||
|
toast.error('Интервал обновления должен быть целым числом ≥ 0')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
refresh = n
|
||||||
|
}
|
||||||
|
|
||||||
|
const body: ModuleCreate = {
|
||||||
|
type,
|
||||||
|
name: trimmedName,
|
||||||
|
enabled,
|
||||||
|
priority: priorityNum,
|
||||||
|
}
|
||||||
|
if (refresh !== undefined) {
|
||||||
|
body.refresh_interval_sec = refresh
|
||||||
|
}
|
||||||
|
const cron = cronExpr.trim()
|
||||||
|
if (cron) {
|
||||||
|
body.cron_expr = cron
|
||||||
|
}
|
||||||
|
if (defaultCommunityId) {
|
||||||
|
body.default_community_id = defaultCommunityId
|
||||||
|
}
|
||||||
|
if (isDomains) {
|
||||||
|
body.doh_resolver_policy = dohResolverPolicy
|
||||||
|
body.doh_profile_ids = dohProfileIds
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const created = await createMutation.mutateAsync(body)
|
||||||
|
onOpenChange(false)
|
||||||
|
onCreated?.(created)
|
||||||
|
} catch {
|
||||||
|
// toast in mutation
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormDrawer
|
||||||
|
open={open}
|
||||||
|
onOpenChange={onOpenChange}
|
||||||
|
title="Новый модуль"
|
||||||
|
description="Тип задаётся один раз. Записи добавляются на карточке модуля."
|
||||||
|
className="sm:max-w-md"
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="outline" type="button" onClick={() => onOpenChange(false)}>
|
||||||
|
Отмена
|
||||||
|
</Button>
|
||||||
|
<LoadingButton type="button" loading={createMutation.isPending} onClick={() => void save()}>
|
||||||
|
Создать
|
||||||
|
</LoadingButton>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectField
|
||||||
|
id="mod-create-type"
|
||||||
|
label="Тип"
|
||||||
|
items={MODULE_TYPE_ITEMS}
|
||||||
|
value={type}
|
||||||
|
onValueChange={(v) => {
|
||||||
|
if (v) setType(v)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="mod-create-name">Название</Label>
|
||||||
|
<Input
|
||||||
|
id="mod-create-name"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder="Имя модуля"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-row items-center justify-between gap-4 rounded-lg border border-border bg-muted/30 p-3">
|
||||||
|
<div className="grid min-w-0 flex-1 gap-1 pr-2">
|
||||||
|
<Label htmlFor="mod-create-enabled">Включён</Label>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Выключенный модуль не участвует в обновлении и применении.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Checkbox
|
||||||
|
id="mod-create-enabled"
|
||||||
|
checked={enabled}
|
||||||
|
onCheckedChange={(v) => setEnabled(v === true)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="mod-create-priority">Приоритет</Label>
|
||||||
|
<Input
|
||||||
|
id="mod-create-priority"
|
||||||
|
type="number"
|
||||||
|
value={priority}
|
||||||
|
onChange={(e) => setPriority(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="mod-create-interval">Интервал обновления (сек)</Label>
|
||||||
|
<Input
|
||||||
|
id="mod-create-interval"
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
placeholder="пусто = по умолчанию"
|
||||||
|
value={refreshIntervalSec}
|
||||||
|
onChange={(e) => setRefreshIntervalSec(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="mod-create-cron">Cron (опционально)</Label>
|
||||||
|
<Input
|
||||||
|
id="mod-create-cron"
|
||||||
|
placeholder="0 * * * *"
|
||||||
|
value={cronExpr}
|
||||||
|
onChange={(e) => setCronExpr(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CommunitySelect
|
||||||
|
id="mod-create-community"
|
||||||
|
label="Community по умолчанию"
|
||||||
|
value={defaultCommunityId}
|
||||||
|
onValueChange={setDefaultCommunityId}
|
||||||
|
communities={communities}
|
||||||
|
nullable
|
||||||
|
/>
|
||||||
|
|
||||||
|
{isDomains ? (
|
||||||
|
<>
|
||||||
|
<SelectField
|
||||||
|
id="mod-create-doh-policy"
|
||||||
|
label="Политика DoH"
|
||||||
|
items={DOH_POLICY_ITEMS}
|
||||||
|
value={dohResolverPolicy}
|
||||||
|
onValueChange={(v) => {
|
||||||
|
if (v) setDohResolverPolicy(v)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label>DoH профили</Label>
|
||||||
|
{dohProfiles.length === 0 ? (
|
||||||
|
<p className="text-muted-foreground text-sm">Нет профилей в справочнике</p>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-2 rounded-lg border border-border p-3">
|
||||||
|
{dohProfiles.map((p) => {
|
||||||
|
const checked = dohProfileIds.includes(p.id)
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
key={p.id}
|
||||||
|
htmlFor={`mod-create-doh-${p.id}`}
|
||||||
|
className="flex cursor-pointer items-start gap-3"
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
id={`mod-create-doh-${p.id}`}
|
||||||
|
checked={checked}
|
||||||
|
onCheckedChange={(v) => toggleDohProfile(p.id, v === true)}
|
||||||
|
className="mt-0.5"
|
||||||
|
/>
|
||||||
|
<span className="flex min-w-0 flex-col gap-0.5">
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{dohProfileShortLabel(p.id, dohProfiles)}
|
||||||
|
</span>
|
||||||
|
<span className="text-muted-foreground truncate text-xs" title={p.url}>
|
||||||
|
{p.url}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</FormDrawer>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -160,7 +160,7 @@ export function ModuleEditDialog({
|
|||||||
<div className="grid min-w-0 flex-1 gap-1 pr-2">
|
<div className="grid min-w-0 flex-1 gap-1 pr-2">
|
||||||
<Label htmlFor="mod-enabled">Включён</Label>
|
<Label htmlFor="mod-enabled">Включён</Label>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Выключенный модуль не участвует в refresh и apply.
|
Выключенный модуль не участвует в обновлении и применении.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { DataGridSection } from '@/components/data-grid-shell'
|
|||||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||||
import { formatDateTime } from '@/lib/modules/display'
|
import { formatDateTime } from '@/lib/modules/display'
|
||||||
import { communityLabel } from '@/lib/modules/helpers'
|
import { communityLabel } from '@/lib/modules/helpers'
|
||||||
|
import { cdnSourceKindRu } from '@/lib/ui-labels'
|
||||||
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
|
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
|
||||||
import type {
|
import type {
|
||||||
AsEntry,
|
AsEntry,
|
||||||
@@ -145,7 +146,7 @@ export function ModuleEntriesGrid({
|
|||||||
accessorKey: 'source_kind',
|
accessorKey: 'source_kind',
|
||||||
header: 'Тип',
|
header: 'Тип',
|
||||||
cell: ({ row }: { row: { original: CdnSource } }) => (
|
cell: ({ row }: { row: { original: CdnSource } }) => (
|
||||||
<CategoryBadge>{row.original.source_kind}</CategoryBadge>
|
<CategoryBadge>{cdnSourceKindRu(row.original.source_kind)}</CategoryBadge>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ import type { PeerDiscoveryRow, SpeakerRow } from '@/types/api'
|
|||||||
function speakerLabel(s: SpeakerRow): string {
|
function speakerLabel(s: SpeakerRow): string {
|
||||||
if (s.role === 'master') {
|
if (s.role === 'master') {
|
||||||
const host = s.agent_domain ?? s.endpoint
|
const host = s.agent_domain ?? s.endpoint
|
||||||
return host ? `CP · ${host}` : 'CP (master)'
|
return host ? `Плоскость · ${host}` : 'Плоскость управления'
|
||||||
}
|
}
|
||||||
return s.agent_domain ?? s.endpoint ?? `${s.id.slice(0, 8)}…`
|
return s.agent_domain ?? s.endpoint ?? `${s.id.slice(0, 8)}…`
|
||||||
}
|
}
|
||||||
@@ -51,7 +51,7 @@ const filterFields: FilterFieldConfig[] = [
|
|||||||
icon: <SearchIcon className="size-3.5" aria-hidden />,
|
icon: <SearchIcon className="size-3.5" aria-hidden />,
|
||||||
type: 'text',
|
type: 'text',
|
||||||
className: 'w-48',
|
className: 'w-48',
|
||||||
placeholder: 'IP или Neighbor ID…',
|
placeholder: 'IP или ID соседа…',
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -94,7 +94,7 @@ export function NetworkDiscoveredPeersCard({
|
|||||||
id: 'neighbor_id',
|
id: 'neighbor_id',
|
||||||
accessorFn: (row) => row.neighbor_id || row.neighbor,
|
accessorFn: (row) => row.neighbor_id || row.neighbor,
|
||||||
header: ({ column }) => (
|
header: ({ column }) => (
|
||||||
<DataGridColumnHeader column={column} title="Neighbor ID" />
|
<DataGridColumnHeader column={column} title="ID соседа" />
|
||||||
),
|
),
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<DataGridPrimaryCell
|
<DataGridPrimaryCell
|
||||||
@@ -103,7 +103,7 @@ export function NetworkDiscoveredPeersCard({
|
|||||||
accent="mono"
|
accent="mono"
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
meta: { headerTitle: 'Neighbor ID' },
|
meta: { headerTitle: 'ID соседа' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'remote_asn',
|
accessorKey: 'remote_asn',
|
||||||
@@ -212,7 +212,7 @@ export function NetworkDiscoveredPeersCard({
|
|||||||
title="Одобрить пира"
|
title="Одобрить пира"
|
||||||
description={
|
description={
|
||||||
approveTarget
|
approveTarget
|
||||||
? `Neighbor ID ${approveTarget.neighbor_id || '—'} · ${approveTarget.neighbor} AS${approveTarget.remote_asn ?? '?'}`
|
? `ID соседа ${approveTarget.neighbor_id || '—'} · ${approveTarget.neighbor} AS${approveTarget.remote_asn ?? '?'}`
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
className="sm:max-w-sm"
|
className="sm:max-w-sm"
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ export function NetworkKpi({
|
|||||||
}
|
}
|
||||||
size="sm"
|
size="sm"
|
||||||
>
|
>
|
||||||
{loading ? '…' : 'онлайн'}
|
{loading ? '…' : 'в сети'}
|
||||||
</Badge>
|
</Badge>
|
||||||
),
|
),
|
||||||
to: '/network',
|
to: '/network',
|
||||||
|
|||||||
@@ -39,11 +39,11 @@ const PEER_TABS = [
|
|||||||
|
|
||||||
const SESSION_STATE_OPTIONS = [
|
const SESSION_STATE_OPTIONS = [
|
||||||
{ value: 'Established', label: 'Установлена' },
|
{ value: 'Established', label: 'Установлена' },
|
||||||
{ value: 'Idle', label: 'Idle' },
|
{ value: 'Idle', label: 'Простой' },
|
||||||
{ value: 'Active', label: 'Active' },
|
{ value: 'Active', label: 'Поиск' },
|
||||||
{ value: 'Connect', label: 'Connect' },
|
{ value: 'Connect', label: 'Соединение' },
|
||||||
{ value: 'OpenSent', label: 'OpenSent' },
|
{ value: 'OpenSent', label: 'Open отправлен' },
|
||||||
{ value: 'OpenConfirm', label: 'OpenConfirm' },
|
{ value: 'OpenConfirm', label: 'Open подтверждён' },
|
||||||
]
|
]
|
||||||
|
|
||||||
function createDefaultPeerFilters(): Filter[] {
|
function createDefaultPeerFilters(): Filter[] {
|
||||||
|
|||||||
@@ -31,14 +31,14 @@ function speakerDeleteLabel(s: SpeakerRow): string {
|
|||||||
|
|
||||||
const SPEAKER_TABS = [
|
const SPEAKER_TABS = [
|
||||||
{ id: 'all', label: 'Все' },
|
{ id: 'all', label: 'Все' },
|
||||||
{ id: 'online', label: 'Online' },
|
{ id: 'online', label: 'В сети' },
|
||||||
{ id: 'offline', label: 'Offline' },
|
{ id: 'offline', label: 'Не в сети' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const ROLE_OPTIONS = [
|
const ROLE_OPTIONS = [
|
||||||
{ value: 'primary', label: 'primary' },
|
{ value: 'primary', label: 'Основной' },
|
||||||
{ value: 'secondary', label: 'secondary' },
|
{ value: 'secondary', label: 'Резервный' },
|
||||||
{ value: 'speaker', label: 'speaker' },
|
{ value: 'speaker', label: 'Спикер' },
|
||||||
]
|
]
|
||||||
|
|
||||||
function createDefaultSpeakerFilters(): Filter[] {
|
function createDefaultSpeakerFilters(): Filter[] {
|
||||||
@@ -52,7 +52,7 @@ const speakerFilterFields: FilterFieldConfig[] = [
|
|||||||
icon: <SearchIcon className="size-3.5" aria-hidden />,
|
icon: <SearchIcon className="size-3.5" aria-hidden />,
|
||||||
type: 'text',
|
type: 'text',
|
||||||
className: 'w-52',
|
className: 'w-52',
|
||||||
placeholder: 'endpoint…',
|
placeholder: 'Адрес агента…',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'role',
|
key: 'role',
|
||||||
@@ -128,7 +128,7 @@ export function NetworkSpeakersCard({
|
|||||||
<>
|
<>
|
||||||
<ResourcePage
|
<ResourcePage
|
||||||
title="Спикеры"
|
title="Спикеры"
|
||||||
description="BIRD-агенты на нодах tenant"
|
description="BIRD-агенты на нодах арендатора"
|
||||||
tabs={SPEAKER_TABS}
|
tabs={SPEAKER_TABS}
|
||||||
tabFilter={speakerTabFilter}
|
tabFilter={speakerTabFilter}
|
||||||
filterFields={speakerFilterFields}
|
filterFields={speakerFilterFields}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { DataGridPrimaryCell } from '@/components/data-grid-cell'
|
|||||||
import { StatusBadge } from '@/components/status-badge'
|
import { StatusBadge } from '@/components/status-badge'
|
||||||
import { Badge } from '@/components/reui/badge'
|
import { Badge } from '@/components/reui/badge'
|
||||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||||
import { speakerOnlineLabel } from '@/lib/ui-labels'
|
import { speakerOnlineLabel, speakerRoleRu } from '@/lib/ui-labels'
|
||||||
import type { SpeakerRow } from '@/types/api'
|
import type { SpeakerRow } from '@/types/api'
|
||||||
|
|
||||||
export const speakerColumns: ColumnDef<SpeakerRow, unknown>[] = [
|
export const speakerColumns: ColumnDef<SpeakerRow, unknown>[] = [
|
||||||
@@ -20,7 +20,7 @@ export const speakerColumns: ColumnDef<SpeakerRow, unknown>[] = [
|
|||||||
{
|
{
|
||||||
accessorKey: 'role',
|
accessorKey: 'role',
|
||||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Роль" />,
|
header: ({ column }) => <DataGridColumnHeader column={column} title="Роль" />,
|
||||||
cell: ({ row }) => <CategoryBadge>{row.original.role}</CategoryBadge>,
|
cell: ({ row }) => <CategoryBadge>{speakerRoleRu(row.original.role)}</CategoryBadge>,
|
||||||
meta: { headerTitle: 'Роль' },
|
meta: { headerTitle: 'Роль' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ interface PeerFormDialogProps {
|
|||||||
function speakerLabel(s: SpeakerRow): string {
|
function speakerLabel(s: SpeakerRow): string {
|
||||||
if (s.role === 'master') {
|
if (s.role === 'master') {
|
||||||
const host = s.agent_domain ?? s.endpoint
|
const host = s.agent_domain ?? s.endpoint
|
||||||
return host ? `CP · ${host}` : 'CP (master)'
|
return host ? `Плоскость · ${host}` : 'Плоскость управления'
|
||||||
}
|
}
|
||||||
return s.agent_domain ?? s.endpoint ?? `${s.id.slice(0, 8)}…`
|
return s.agent_domain ?? s.endpoint ?? `${s.id.slice(0, 8)}…`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ export function SpeakerFormDialog({ open, onOpenChange }: SpeakerFormDialogProps
|
|||||||
const ep =
|
const ep =
|
||||||
endpoint.trim() || (agentDomain.trim() ? `https://${agentDomain.trim()}` : '')
|
endpoint.trim() || (agentDomain.trim() ? `https://${agentDomain.trim()}` : '')
|
||||||
if (!ep) {
|
if (!ep) {
|
||||||
toast.error('Укажите endpoint или agent domain')
|
toast.error('Укажите конечную точку или домен агента')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const body: BgpSpeakerCreate = {
|
const body: BgpSpeakerCreate = {
|
||||||
@@ -95,7 +95,7 @@ export function SpeakerFormDialog({ open, onOpenChange }: SpeakerFormDialogProps
|
|||||||
open={open}
|
open={open}
|
||||||
onOpenChange={onOpenChange}
|
onOpenChange={onOpenChange}
|
||||||
title="Новый спикер"
|
title="Новый спикер"
|
||||||
description="BIRD-агент на ноде реплики или control plane"
|
description="BIRD-агент на ноде реплики или плоскости управления"
|
||||||
className="sm:max-w-md"
|
className="sm:max-w-md"
|
||||||
footer={
|
footer={
|
||||||
<>
|
<>
|
||||||
@@ -122,7 +122,7 @@ export function SpeakerFormDialog({ open, onOpenChange }: SpeakerFormDialogProps
|
|||||||
label="Роль"
|
label="Роль"
|
||||||
items={[
|
items={[
|
||||||
{ value: 'replica', label: 'Реплика' },
|
{ value: 'replica', label: 'Реплика' },
|
||||||
{ value: 'master', label: 'Мастер (CP)' },
|
{ value: 'master', label: 'Мастер (плоскость)' },
|
||||||
]}
|
]}
|
||||||
value={role}
|
value={role}
|
||||||
onValueChange={(v) => setRole(v ?? 'replica')}
|
onValueChange={(v) => setRole(v ?? 'replica')}
|
||||||
@@ -146,7 +146,7 @@ export function SpeakerFormDialog({ open, onOpenChange }: SpeakerFormDialogProps
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Label htmlFor="speaker-bgp-source">BGP source IPv4</Label>
|
<Label htmlFor="speaker-bgp-source">Исходный IPv4 BGP</Label>
|
||||||
<Input
|
<Input
|
||||||
id="speaker-bgp-source"
|
id="speaker-bgp-source"
|
||||||
placeholder="203.0.113.10"
|
placeholder="203.0.113.10"
|
||||||
|
|||||||
@@ -43,7 +43,6 @@ export function OperationsJobsGrid({
|
|||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<DataGridPrimaryCell
|
<DataGridPrimaryCell
|
||||||
title={jobKindRu(row.original.kind)}
|
title={jobKindRu(row.original.kind)}
|
||||||
accent="mono"
|
|
||||||
subtitle={
|
subtitle={
|
||||||
row.original.meta?.module_id
|
row.original.meta?.module_id
|
||||||
? (nameById.get(String(row.original.meta.module_id)) ??
|
? (nameById.get(String(row.original.meta.module_id)) ??
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ export function OperationsRevisionsGrid({
|
|||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
title={`Откатиться к ревизии ${row.original.id.slice(0, 8)}…?`}
|
title={`Откатиться к ревизии ${row.original.id.slice(0, 8)}…?`}
|
||||||
description="Будет создана новая ревизия на основе выбранной. Требуется роль operator."
|
description="Будет создана новая ревизия на основе выбранной. Требуется роль оператора."
|
||||||
confirmLabel="Откатить"
|
confirmLabel="Откатить"
|
||||||
destructive
|
destructive
|
||||||
onConfirm={() => rollbackMutation.mutate(row.original.id)}
|
onConfirm={() => rollbackMutation.mutate(row.original.id)}
|
||||||
|
|||||||
@@ -8,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 ? (
|
||||||
|
|||||||
@@ -1,21 +1,28 @@
|
|||||||
import { Link } from '@tanstack/react-router'
|
|
||||||
import { Boxes, Plus } from 'lucide-react'
|
import { Boxes, Plus } from 'lucide-react'
|
||||||
|
|
||||||
import { IllustratedEmptyState } from '@/components/patterns/illustrated-empty-state'
|
import { IllustratedEmptyState } from '@/components/patterns/illustrated-empty-state'
|
||||||
import { Button } from '@evobgp/ui/components/button'
|
import { Button } from '@evobgp/ui/components/button'
|
||||||
|
|
||||||
/** empty-state-3 pattern for first module. */
|
/** empty-state-3 pattern for first module. */
|
||||||
export function ProjectsEmptyState() {
|
export function ProjectsEmptyState({
|
||||||
|
canCreate = true,
|
||||||
|
onCreate,
|
||||||
|
}: {
|
||||||
|
canCreate?: boolean
|
||||||
|
onCreate?: () => void
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<IllustratedEmptyState
|
<IllustratedEmptyState
|
||||||
icon={Boxes}
|
icon={Boxes}
|
||||||
title="Создайте первый модуль"
|
title="Создайте первый модуль"
|
||||||
description="Модули задают источники префиксов: AS, CDN, домены и IP-диапазоны."
|
description="Модули задают источники префиксов: AS, CDN, домены и IP-диапазоны."
|
||||||
action={
|
action={
|
||||||
<Button size="sm" render={<Link to="/modules/new" />}>
|
canCreate && onCreate ? (
|
||||||
<Plus />
|
<Button size="sm" onClick={onCreate}>
|
||||||
Новый модуль
|
<Plus />
|
||||||
</Button>
|
Новый модуль
|
||||||
|
</Button>
|
||||||
|
) : undefined
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -29,10 +29,10 @@ export function FrameDataGrid({
|
|||||||
}) {
|
}) {
|
||||||
const hasHeader = Boolean(title || description || actions)
|
const hasHeader = Boolean(title || description || actions)
|
||||||
return (
|
return (
|
||||||
<Frame dense spacing="sm" className={cn('w-full gap-0 p-0', className)}>
|
<Frame dense spacing="sm" className={cn('w-full min-w-0 gap-0 p-0', className)}>
|
||||||
<FramePanel className="flex flex-col gap-0 p-0 shadow-xs">
|
<FramePanel className="flex flex-col gap-0 p-0 shadow-xs">
|
||||||
{hasHeader ? (
|
{hasHeader ? (
|
||||||
<FrameHeader className="flex-row items-start justify-between gap-3 border-b">
|
<FrameHeader className="flex-col items-stretch gap-3 border-b sm:flex-row sm:items-start sm:justify-between">
|
||||||
<div className="flex min-w-0 flex-1 flex-col gap-px">
|
<div className="flex min-w-0 flex-1 flex-col gap-px">
|
||||||
{title ? <FrameTitle>{title}</FrameTitle> : null}
|
{title ? <FrameTitle>{title}</FrameTitle> : null}
|
||||||
{description ? (
|
{description ? (
|
||||||
|
|||||||
@@ -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: "Некорректное значение",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@evobgp/ui/components/select'
|
} from '@evobgp/ui/components/select'
|
||||||
import { cn } from '@evobgp/ui/lib/utils'
|
import { cn } from '@evobgp/ui/lib/utils'
|
||||||
import { jobKindRu } from '@/lib/ui-labels'
|
import { isRefreshJobKind, jobKindRu } from '@/lib/ui-labels'
|
||||||
import type { JobRow } from '@/types/api'
|
import type { JobRow } from '@/types/api'
|
||||||
|
|
||||||
import { ScheduleCalendarView } from './schedule-calendar-view'
|
import { ScheduleCalendarView } from './schedule-calendar-view'
|
||||||
@@ -34,7 +34,7 @@ function jobTimestamp(job: JobRow): string | undefined {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function matchesFilter(job: JobRow, filter: JobFilter): boolean {
|
function matchesFilter(job: JobRow, filter: JobFilter): boolean {
|
||||||
if (filter === 'refresh') return job.kind === 'module_refresh'
|
if (filter === 'refresh') return isRefreshJobKind(job.kind)
|
||||||
if (filter === 'failed')
|
if (filter === 'failed')
|
||||||
return ['failed', 'error', 'cancelled'].includes(job.status.toLowerCase())
|
return ['failed', 'error', 'cancelled'].includes(job.status.toLowerCase())
|
||||||
return true
|
return true
|
||||||
@@ -107,7 +107,7 @@ export function ScheduleAgendaPanel({
|
|||||||
return (
|
return (
|
||||||
<PanelCard
|
<PanelCard
|
||||||
title="Календарь задач"
|
title="Календарь задач"
|
||||||
description="Задачи refresh и apply по дням"
|
description="Задачи обновления и применения по дням"
|
||||||
contentClassName={cn(panelCardContentFlushClassName, 'p-0')}
|
contentClassName={cn(panelCardContentFlushClassName, 'p-0')}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col lg:flex-row">
|
<div className="flex flex-col lg:flex-row">
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { FrameDataGrid } from '@/components/reui-kit'
|
|||||||
import { QueryState } from '@/components/query-state'
|
import { QueryState } from '@/components/query-state'
|
||||||
import { TableSkeleton } from '@/components/skeletons'
|
import { TableSkeleton } from '@/components/skeletons'
|
||||||
import { Tabs, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs'
|
import { Tabs, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs'
|
||||||
|
import { isRefreshJobKind } from '@/lib/ui-labels'
|
||||||
import type { JobRow } from '@/types/api'
|
import type { JobRow } from '@/types/api'
|
||||||
|
|
||||||
import { ScheduleJobsGrid } from './schedule-jobs-grid'
|
import { ScheduleJobsGrid } from './schedule-jobs-grid'
|
||||||
@@ -11,7 +12,7 @@ import { ScheduleJobsGrid } from './schedule-jobs-grid'
|
|||||||
type JobTab = 'all' | 'refresh' | 'failed'
|
type JobTab = 'all' | 'refresh' | 'failed'
|
||||||
|
|
||||||
function filterJobs(items: JobRow[], tab: JobTab): JobRow[] {
|
function filterJobs(items: JobRow[], tab: JobTab): JobRow[] {
|
||||||
if (tab === 'refresh') return items.filter((j) => j.kind === 'module_refresh')
|
if (tab === 'refresh') return items.filter((j) => isRefreshJobKind(j.kind))
|
||||||
if (tab === 'failed')
|
if (tab === 'failed')
|
||||||
return items.filter((j) => ['failed', 'error', 'cancelled'].includes(j.status.toLowerCase()))
|
return items.filter((j) => ['failed', 'error', 'cancelled'].includes(j.status.toLowerCase()))
|
||||||
return items
|
return items
|
||||||
@@ -20,7 +21,7 @@ function filterJobs(items: JobRow[], tab: JobTab): JobRow[] {
|
|||||||
function tabCounts(items: JobRow[]) {
|
function tabCounts(items: JobRow[]) {
|
||||||
return {
|
return {
|
||||||
all: items.length,
|
all: items.length,
|
||||||
refresh: items.filter((j) => j.kind === 'module_refresh').length,
|
refresh: items.filter((j) => isRefreshJobKind(j.kind)).length,
|
||||||
failed: items.filter((j) => ['failed', 'error', 'cancelled'].includes(j.status.toLowerCase()))
|
failed: items.filter((j) => ['failed', 'error', 'cancelled'].includes(j.status.toLowerCase()))
|
||||||
.length,
|
.length,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export function ScheduleJobsGrid({
|
|||||||
{
|
{
|
||||||
accessorKey: 'kind',
|
accessorKey: 'kind',
|
||||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Вид" />,
|
header: ({ column }) => <DataGridColumnHeader column={column} title="Вид" />,
|
||||||
cell: ({ row }) => <DataGridPrimaryCell title={jobKindRu(row.original.kind)} accent="mono" />,
|
cell: ({ row }) => <DataGridPrimaryCell title={jobKindRu(row.original.kind)} />,
|
||||||
meta: { headerTitle: 'Вид' },
|
meta: { headerTitle: 'Вид' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export function ScheduleModulesGrid({
|
|||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<span className="font-mono text-xs text-muted-foreground">
|
<span className="font-mono text-xs text-muted-foreground">
|
||||||
{row.original.cron_expr ??
|
{row.original.cron_expr ??
|
||||||
(row.original.refresh_interval_sec ? `${row.original.refresh_interval_sec}s` : '—')}
|
(row.original.refresh_interval_sec ? `${row.original.refresh_interval_sec} с` : '—')}
|
||||||
</span>
|
</span>
|
||||||
),
|
),
|
||||||
meta: { headerTitle: 'Расписание' },
|
meta: { headerTitle: 'Расписание' },
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ export function AppearanceSettingsTab() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="flex flex-col gap-6">
|
||||||
<SettingsCard
|
<SettingsCard
|
||||||
title="Тема интерфейса"
|
title="Тема интерфейса"
|
||||||
description="Быстрый переключатель также доступен в боковой панели"
|
description="Быстрый переключатель также доступен в боковой панели"
|
||||||
@@ -61,6 +61,7 @@ export function AppearanceSettingsTab() {
|
|||||||
мгновенно
|
мгновенно
|
||||||
</Badge>
|
</Badge>
|
||||||
}
|
}
|
||||||
|
stacked
|
||||||
last
|
last
|
||||||
>
|
>
|
||||||
<ToggleGroup
|
<ToggleGroup
|
||||||
@@ -72,7 +73,7 @@ export function AppearanceSettingsTab() {
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
aria-label="Тема интерфейса"
|
aria-label="Тема интерфейса"
|
||||||
className="w-full sm:w-auto"
|
className="w-full min-w-0"
|
||||||
>
|
>
|
||||||
{THEME_OPTIONS.map((option) => {
|
{THEME_OPTIONS.map((option) => {
|
||||||
const Icon = option.icon
|
const Icon = option.icon
|
||||||
@@ -89,16 +90,16 @@ export function AppearanceSettingsTab() {
|
|||||||
</SettingsCard>
|
</SettingsCard>
|
||||||
|
|
||||||
<SettingsCard
|
<SettingsCard
|
||||||
title="Дашборд"
|
title="Обзор"
|
||||||
description="Блоки на экране «Обзор»"
|
description="Блоки на экране «Обзор»"
|
||||||
>
|
>
|
||||||
<SettingsFieldGroup
|
<SettingsFieldGroup
|
||||||
legend="Быстрые действия"
|
legend="Быстрые действия"
|
||||||
description="Показывать KPI-like плитки быстрых переходов под метриками."
|
description="Показывать плитки быстрых переходов под метриками."
|
||||||
>
|
>
|
||||||
<SettingRow
|
<SettingRow
|
||||||
title="Быстрые действия"
|
title="Быстрые действия"
|
||||||
description="Блок с частыми переходами (модули, сеть, деплой) на дашборде."
|
description="Блок с частыми переходами (модули, сеть, деплой) на экране «Обзор»."
|
||||||
last
|
last
|
||||||
>
|
>
|
||||||
<Switch
|
<Switch
|
||||||
|
|||||||
@@ -0,0 +1,341 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { Save } from 'lucide-react'
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
|
import { Input } from '@evobgp/ui/components/input'
|
||||||
|
import { FrameDataGrid } from '@/components/reui-kit'
|
||||||
|
import { SelectMenu } from '@/components/select-field'
|
||||||
|
import { SettingsCard } from '@/components/settings/settings-card'
|
||||||
|
import { SettingsFieldGroup } from '@/components/settings/settings-field-group'
|
||||||
|
import { SettingsKvGrid } from '@/components/settings/settings-kv-grid'
|
||||||
|
import { SettingsSettingField } from '@/components/settings/settings-setting-field'
|
||||||
|
import { QueryState } from '@/components/query-state'
|
||||||
|
import { LoadingButton } from '@/components/loading-button'
|
||||||
|
|
||||||
|
import {
|
||||||
|
BIRD_SETTING_KEYS,
|
||||||
|
REVISION_SETTING_KEYS,
|
||||||
|
RUNTIME_LOGS_SETTING_KEYS,
|
||||||
|
buildPayload,
|
||||||
|
partitionSettings,
|
||||||
|
settingsKeys,
|
||||||
|
settingsQueryOptions,
|
||||||
|
type BirdSettingKey,
|
||||||
|
} from '@/queries/settings'
|
||||||
|
import { apiMutate } from '@/lib/api-client'
|
||||||
|
|
||||||
|
const RUNTIME_LOGS_ENABLED_ITEMS = [
|
||||||
|
{ value: 'true', label: 'Вкл' },
|
||||||
|
{ value: 'false', label: 'Выкл' },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
const RUNTIME_LOGS_MODE_ITEMS = [
|
||||||
|
{ value: 'truncate', label: 'Обнулить файл' },
|
||||||
|
{ value: 'delete', label: 'Удалить файл' },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
const BIRD_LABELS: Record<BirdSettingKey, string> = {
|
||||||
|
bird_router_id: 'Router ID',
|
||||||
|
bird_local_ipv4: 'Локальный IPv4',
|
||||||
|
bird_local_ipv6: 'Локальный IPv6',
|
||||||
|
bird_local_asn: 'Локальный ASN',
|
||||||
|
bird_bgp_source_ipv4: 'Исходный IPv4 BGP',
|
||||||
|
bird_bgp_source_ipv6: 'Исходный IPv6 BGP',
|
||||||
|
peer_discovery_enabled: 'Автообнаружение пиров',
|
||||||
|
peer_discovery_ranges_v4: 'CIDR автообнаружения IPv4',
|
||||||
|
peer_discovery_ranges_v6: 'CIDR автообнаружения IPv6',
|
||||||
|
peer_discovery_require_external: 'Только внешние ASN',
|
||||||
|
}
|
||||||
|
|
||||||
|
const BIRD_BOOL_ITEMS = [
|
||||||
|
{ value: 'true', label: 'Вкл' },
|
||||||
|
{ value: 'false', label: 'Выкл' },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
const BIRD_HINTS: Partial<Record<BirdSettingKey, string>> = {
|
||||||
|
peer_discovery_enabled:
|
||||||
|
'Динамический диапазон соседей в BIRD (карантин import/export none). Нужны CIDR.',
|
||||||
|
peer_discovery_ranges_v4: 'Через запятую или пробел, напр. 198.51.100.0/24 203.0.113.0/24',
|
||||||
|
peer_discovery_ranges_v6: 'Опционально, напр. 2001:db8::/32',
|
||||||
|
peer_discovery_require_external: 'Диапазон соседей с внешним (любым чужим) ASN',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BirdSettingsTab() {
|
||||||
|
const settingsQ = useQuery(settingsQueryOptions())
|
||||||
|
const qc = useQueryClient()
|
||||||
|
|
||||||
|
const partitioned = settingsQ.data ? partitionSettings(settingsQ.data) : null
|
||||||
|
|
||||||
|
const [birdForm, setBirdForm] = useState<Record<string, string>>({})
|
||||||
|
const [revisionForm, setRevisionForm] = useState<Record<string, string>>({})
|
||||||
|
const [runtimeLogsForm, setRuntimeLogsForm] = useState<Record<string, string>>({})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (partitioned) {
|
||||||
|
setBirdForm({ ...partitioned.bird })
|
||||||
|
setRevisionForm({ ...partitioned.revision })
|
||||||
|
setRuntimeLogsForm({ ...partitioned.runtimeLogs })
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [settingsQ.data])
|
||||||
|
|
||||||
|
const patchMutation = useMutation({
|
||||||
|
mutationFn: (payload: Record<string, string | number | boolean>) =>
|
||||||
|
apiMutate('/v1/settings', 'PATCH', payload),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Параметры сохранены')
|
||||||
|
void qc.invalidateQueries({ queryKey: settingsKeys.all })
|
||||||
|
},
|
||||||
|
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось сохранить'),
|
||||||
|
})
|
||||||
|
|
||||||
|
function saveBird() {
|
||||||
|
patchMutation.mutate(buildPayload(BIRD_SETTING_KEYS, birdForm))
|
||||||
|
}
|
||||||
|
function saveRevision() {
|
||||||
|
patchMutation.mutate(buildPayload(REVISION_SETTING_KEYS, revisionForm))
|
||||||
|
}
|
||||||
|
function saveRuntimeLogs() {
|
||||||
|
patchMutation.mutate(buildPayload(RUNTIME_LOGS_SETTING_KEYS, runtimeLogsForm))
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<SettingsCard
|
||||||
|
title="Плоскость управления BIRD"
|
||||||
|
description="Глобальные параметры BIRD для обновления модулей и применения конфигурации. Сохранение доступно оператору."
|
||||||
|
footer={
|
||||||
|
<LoadingButton onClick={saveBird} loading={patchMutation.isPending}>
|
||||||
|
<Save />
|
||||||
|
Сохранить
|
||||||
|
</LoadingButton>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<QueryState
|
||||||
|
data={partitioned}
|
||||||
|
isLoading={settingsQ.isLoading}
|
||||||
|
isError={settingsQ.isError}
|
||||||
|
error={settingsQ.error}
|
||||||
|
skeleton={<div className="h-64" />}
|
||||||
|
onRetry={() => settingsQ.refetch()}
|
||||||
|
>
|
||||||
|
{() => (
|
||||||
|
<SettingsFieldGroup
|
||||||
|
legend="BIRD"
|
||||||
|
description="Параметры демона и автообнаружения пиров."
|
||||||
|
>
|
||||||
|
{BIRD_SETTING_KEYS.map((key, index) => (
|
||||||
|
<SettingsSettingField
|
||||||
|
key={key}
|
||||||
|
title={BIRD_LABELS[key]}
|
||||||
|
description={BIRD_HINTS[key]}
|
||||||
|
labelFor={key}
|
||||||
|
badge={{ label: 'BIRD', variant: 'info-light' }}
|
||||||
|
stacked
|
||||||
|
last={index === BIRD_SETTING_KEYS.length - 1}
|
||||||
|
>
|
||||||
|
{key === 'peer_discovery_enabled' ||
|
||||||
|
key === 'peer_discovery_require_external' ? (
|
||||||
|
<SelectMenu
|
||||||
|
id={key}
|
||||||
|
items={[...BIRD_BOOL_ITEMS]}
|
||||||
|
value={birdForm[key] || 'false'}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
setBirdForm((s) => ({ ...s, [key]: v || 'false' }))
|
||||||
|
}
|
||||||
|
placeholder="Выкл"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Input
|
||||||
|
id={key}
|
||||||
|
className="w-full min-w-0"
|
||||||
|
value={birdForm[key] ?? ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
setBirdForm((s) => ({ ...s, [key]: e.target.value }))
|
||||||
|
}
|
||||||
|
placeholder={BIRD_LABELS[key]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</SettingsSettingField>
|
||||||
|
))}
|
||||||
|
</SettingsFieldGroup>
|
||||||
|
)}
|
||||||
|
</QueryState>
|
||||||
|
</SettingsCard>
|
||||||
|
|
||||||
|
<SettingsCard
|
||||||
|
title="Ревизии"
|
||||||
|
description="Время хранения ревизий в БД"
|
||||||
|
footer={
|
||||||
|
<LoadingButton onClick={saveRevision} loading={patchMutation.isPending}>
|
||||||
|
<Save />
|
||||||
|
Сохранить
|
||||||
|
</LoadingButton>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<QueryState
|
||||||
|
data={partitioned}
|
||||||
|
isLoading={settingsQ.isLoading}
|
||||||
|
isError={settingsQ.isError}
|
||||||
|
error={settingsQ.error}
|
||||||
|
skeleton={<div className="h-32" />}
|
||||||
|
onRetry={() => settingsQ.refetch()}
|
||||||
|
>
|
||||||
|
{() => (
|
||||||
|
<SettingsFieldGroup
|
||||||
|
legend="Хранение"
|
||||||
|
description="Срок хранения ревизий в минутах."
|
||||||
|
>
|
||||||
|
<SettingsSettingField
|
||||||
|
title="Срок хранения"
|
||||||
|
description="Сколько минут хранить ревизии конфигурации."
|
||||||
|
labelFor="revision_retention_minutes"
|
||||||
|
stacked
|
||||||
|
last
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
id="revision_retention_minutes"
|
||||||
|
type="number"
|
||||||
|
className="w-full min-w-0"
|
||||||
|
value={revisionForm.revision_retention_minutes ?? ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
setRevisionForm((s) => ({
|
||||||
|
...s,
|
||||||
|
revision_retention_minutes: e.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</SettingsSettingField>
|
||||||
|
</SettingsFieldGroup>
|
||||||
|
)}
|
||||||
|
</QueryState>
|
||||||
|
</SettingsCard>
|
||||||
|
|
||||||
|
<SettingsCard
|
||||||
|
title="Файловые логи"
|
||||||
|
description="Автоматическая очистка логов"
|
||||||
|
footer={
|
||||||
|
<LoadingButton onClick={saveRuntimeLogs} loading={patchMutation.isPending}>
|
||||||
|
<Save />
|
||||||
|
Сохранить
|
||||||
|
</LoadingButton>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<QueryState
|
||||||
|
data={partitioned}
|
||||||
|
isLoading={settingsQ.isLoading}
|
||||||
|
isError={settingsQ.isError}
|
||||||
|
error={settingsQ.error}
|
||||||
|
skeleton={<div className="h-48" />}
|
||||||
|
onRetry={() => settingsQ.refetch()}
|
||||||
|
>
|
||||||
|
{() => (
|
||||||
|
<SettingsFieldGroup
|
||||||
|
legend="Авто-очистка"
|
||||||
|
description="Расписание и лимиты файловых логов runtime."
|
||||||
|
>
|
||||||
|
<SettingsSettingField
|
||||||
|
title="Авто-очистка"
|
||||||
|
description="Включить периодическую очистку файловых логов."
|
||||||
|
stacked
|
||||||
|
>
|
||||||
|
<SelectMenu
|
||||||
|
items={[...RUNTIME_LOGS_ENABLED_ITEMS]}
|
||||||
|
value={runtimeLogsForm.runtime_logs_auto_enabled ?? 'false'}
|
||||||
|
placeholder="Выберите"
|
||||||
|
onValueChange={(v) =>
|
||||||
|
v &&
|
||||||
|
setRuntimeLogsForm((s) => ({
|
||||||
|
...s,
|
||||||
|
runtime_logs_auto_enabled: v,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</SettingsSettingField>
|
||||||
|
<SettingsSettingField
|
||||||
|
title="Макс. размер файла"
|
||||||
|
description="Порог в мегабайтах, после которого срабатывает очистка."
|
||||||
|
labelFor="runtime_logs_max_file_mb"
|
||||||
|
stacked
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
id="runtime_logs_max_file_mb"
|
||||||
|
type="number"
|
||||||
|
className="w-full min-w-0"
|
||||||
|
value={runtimeLogsForm.runtime_logs_max_file_mb ?? ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
setRuntimeLogsForm((s) => ({
|
||||||
|
...s,
|
||||||
|
runtime_logs_max_file_mb: e.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</SettingsSettingField>
|
||||||
|
<SettingsSettingField
|
||||||
|
title="Расписание"
|
||||||
|
description="Cron-выражение для авто-очистки."
|
||||||
|
labelFor="runtime_logs_auto_schedule"
|
||||||
|
stacked
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
id="runtime_logs_auto_schedule"
|
||||||
|
className="w-full min-w-0"
|
||||||
|
value={runtimeLogsForm.runtime_logs_auto_schedule ?? ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
setRuntimeLogsForm((s) => ({
|
||||||
|
...s,
|
||||||
|
runtime_logs_auto_schedule: e.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</SettingsSettingField>
|
||||||
|
<SettingsSettingField
|
||||||
|
title="Режим очистки"
|
||||||
|
description="Обнулить файл или удалить его."
|
||||||
|
stacked
|
||||||
|
last
|
||||||
|
>
|
||||||
|
<SelectMenu
|
||||||
|
items={[...RUNTIME_LOGS_MODE_ITEMS]}
|
||||||
|
value={runtimeLogsForm.runtime_logs_auto_mode ?? ''}
|
||||||
|
placeholder="Выберите"
|
||||||
|
onValueChange={(v) =>
|
||||||
|
v &&
|
||||||
|
setRuntimeLogsForm((s) => ({
|
||||||
|
...s,
|
||||||
|
runtime_logs_auto_mode: v,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</SettingsSettingField>
|
||||||
|
</SettingsFieldGroup>
|
||||||
|
)}
|
||||||
|
</QueryState>
|
||||||
|
</SettingsCard>
|
||||||
|
|
||||||
|
<FrameDataGrid
|
||||||
|
title="Дополнительные параметры"
|
||||||
|
description="Параметры вне стандартных групп (только чтение — изменяются через API)"
|
||||||
|
>
|
||||||
|
<QueryState
|
||||||
|
data={partitioned?.additional ?? []}
|
||||||
|
isLoading={settingsQ.isLoading}
|
||||||
|
isError={settingsQ.isError}
|
||||||
|
error={settingsQ.error}
|
||||||
|
empty={(partitioned?.additional ?? []).length === 0}
|
||||||
|
emptyTitle="Нет дополнительных параметров"
|
||||||
|
skeleton={<div className="h-32" />}
|
||||||
|
onRetry={() => settingsQ.refetch()}
|
||||||
|
>
|
||||||
|
{(items) => (
|
||||||
|
<SettingsKvGrid
|
||||||
|
items={items}
|
||||||
|
isLoading={settingsQ.isFetching && !settingsQ.isLoading}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</QueryState>
|
||||||
|
</FrameDataGrid>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Link, useNavigate } from '@tanstack/react-router'
|
import { Link, useNavigate } from '@tanstack/react-router'
|
||||||
import { useQueryClient } from '@tanstack/react-query'
|
import { useQueryClient } from '@tanstack/react-query'
|
||||||
import { Save } from 'lucide-react'
|
import { KeyRound, Save } from 'lucide-react'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
@@ -8,6 +8,7 @@ import { SettingRow } from '@/components/settings/setting-row'
|
|||||||
import { SettingsCard } from '@/components/settings/settings-card'
|
import { SettingsCard } from '@/components/settings/settings-card'
|
||||||
import { SettingsFieldGroup } from '@/components/settings/settings-field-group'
|
import { SettingsFieldGroup } from '@/components/settings/settings-field-group'
|
||||||
import { Badge } from '@/components/reui/badge'
|
import { Badge } from '@/components/reui/badge'
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from '@/components/reui/alert'
|
||||||
import { LoadingButton } from '@/components/loading-button'
|
import { LoadingButton } from '@/components/loading-button'
|
||||||
import {
|
import {
|
||||||
DEV_API_TOKEN,
|
DEV_API_TOKEN,
|
||||||
@@ -16,7 +17,6 @@ import {
|
|||||||
TOKEN_STORAGE_KEY,
|
TOKEN_STORAGE_KEY,
|
||||||
} from '@/lib/api-client'
|
} from '@/lib/api-client'
|
||||||
import { authKeys } from '@/queries/auth'
|
import { authKeys } from '@/queries/auth'
|
||||||
import { Alert, AlertDescription } from '@evobgp/ui/components/alert'
|
|
||||||
import { Button } from '@evobgp/ui/components/button'
|
import { Button } from '@evobgp/ui/components/button'
|
||||||
import { Input } from '@evobgp/ui/components/input'
|
import { Input } from '@evobgp/ui/components/input'
|
||||||
|
|
||||||
@@ -50,21 +50,22 @@ export function ConnectionSettingsTab({ tokenRequired }: { tokenRequired: boolea
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="flex flex-col gap-6">
|
||||||
{tokenRequired ? (
|
{tokenRequired ? (
|
||||||
<Alert>
|
<Alert variant="warning">
|
||||||
|
<KeyRound aria-hidden />
|
||||||
|
<AlertTitle>Нужен API-токен</AlertTitle>
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
Для доступа к разделу нужен API-токен. Сохраните токен ниже или нажмите «Использовать
|
Сохраните токен ниже или нажмите «Использовать dev».
|
||||||
dev».
|
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<SettingsCard
|
<SettingsCard
|
||||||
title="Подключение к API"
|
title="Подключение к API"
|
||||||
description="Токен хранится только в этом браузере (localStorage)"
|
description="Токен хранится только в этом браузере"
|
||||||
footer={
|
footer={
|
||||||
<div className="flex w-full flex-wrap justify-end gap-2">
|
<div className="flex w-full min-w-0 flex-wrap justify-end gap-2">
|
||||||
<Button type="button" variant="outline" onClick={useDevToken}>
|
<Button type="button" variant="outline" onClick={useDevToken}>
|
||||||
Использовать dev
|
Использовать dev
|
||||||
</Button>
|
</Button>
|
||||||
@@ -83,8 +84,7 @@ export function ConnectionSettingsTab({ tokenRequired }: { tokenRequired: boolea
|
|||||||
title="Токен для запросов"
|
title="Токен для запросов"
|
||||||
description={
|
description={
|
||||||
<>
|
<>
|
||||||
Ключ для заголовка <code className="text-xs">Authorization</code>. Управление
|
Ключ для заголовка Authorization. Управление ключами арендатора — в разделе{' '}
|
||||||
ключами tenant — в разделе{' '}
|
|
||||||
<Link to="/access" className="text-primary underline-offset-4 hover:underline">
|
<Link to="/access" className="text-primary underline-offset-4 hover:underline">
|
||||||
Права доступа
|
Права доступа
|
||||||
</Link>
|
</Link>
|
||||||
@@ -93,16 +93,18 @@ export function ConnectionSettingsTab({ tokenRequired }: { tokenRequired: boolea
|
|||||||
}
|
}
|
||||||
titleAddon={
|
titleAddon={
|
||||||
<Badge variant="outline" size="sm">
|
<Badge variant="outline" size="sm">
|
||||||
localStorage
|
браузер
|
||||||
</Badge>
|
</Badge>
|
||||||
}
|
}
|
||||||
labelFor="settings-token"
|
labelFor="settings-token"
|
||||||
|
stacked
|
||||||
last
|
last
|
||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
id="settings-token"
|
id="settings-token"
|
||||||
type="password"
|
type="password"
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
|
className="w-full min-w-0"
|
||||||
value={token}
|
value={token}
|
||||||
onChange={(e) => setTokenValue(e.target.value)}
|
onChange={(e) => setTokenValue(e.target.value)}
|
||||||
placeholder="dev или API-ключ"
|
placeholder="dev или API-ключ"
|
||||||
|
|||||||
@@ -23,42 +23,45 @@ import {
|
|||||||
const ADMIN_SECTIONS = [
|
const ADMIN_SECTIONS = [
|
||||||
{
|
{
|
||||||
id: 'tenant-settings',
|
id: 'tenant-settings',
|
||||||
to: '/tenant-settings' as const,
|
to: '/settings' as const,
|
||||||
|
search: { tab: 'bird' as const },
|
||||||
title: 'Параметры арендатора',
|
title: 'Параметры арендатора',
|
||||||
description: 'BIRD, ревизии, файловые логи и дополнительные ключи /v1/settings.',
|
description: 'BIRD, ревизии, файловые логи и дополнительные ключи /v1/settings.',
|
||||||
icon: <SlidersHorizontalIcon aria-hidden="true" />,
|
icon: <SlidersHorizontalIcon aria-hidden="true" />,
|
||||||
badge: { label: 'operator', variant: 'warning-light' as const },
|
badge: { label: 'оператор', variant: 'warning-light' as const },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'access',
|
id: 'access',
|
||||||
to: '/access' as const,
|
to: '/access' as const,
|
||||||
|
search: undefined,
|
||||||
title: 'Права доступа',
|
title: 'Права доступа',
|
||||||
description: 'API-ключи tenant, роли и управление доступом.',
|
description: 'API-ключи арендатора, роли и управление доступом.',
|
||||||
icon: <KeyRoundIcon aria-hidden="true" />,
|
icon: <KeyRoundIcon aria-hidden="true" />,
|
||||||
badge: { label: 'operator', variant: 'warning-light' as const },
|
badge: { label: 'оператор', variant: 'warning-light' as const },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'monitoring',
|
id: 'monitoring',
|
||||||
to: '/monitoring' as const,
|
to: '/monitoring' as const,
|
||||||
|
search: undefined,
|
||||||
title: 'Мониторинг',
|
title: 'Мониторинг',
|
||||||
description: 'Метрики, состояние jobs и observability control plane.',
|
description: 'Метрики, состояние задач и наблюдаемость плоскости управления.',
|
||||||
icon: <ActivityIcon aria-hidden="true" />,
|
icon: <ActivityIcon aria-hidden="true" />,
|
||||||
badge: { label: 'viewer+', variant: 'info-light' as const },
|
badge: { label: 'просмотр+', variant: 'info-light' as const },
|
||||||
},
|
},
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
export function SectionsSettingsTab() {
|
export function SectionsSettingsTab() {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="flex flex-col gap-6">
|
||||||
<SettingsCard
|
<SettingsCard
|
||||||
title="Разделы control plane"
|
title="Разделы плоскости управления"
|
||||||
description="Параметры tenant и операции — отдельно от настроек браузера"
|
description="Параметры арендатора и операции — отдельно от настроек браузера"
|
||||||
>
|
>
|
||||||
<ItemGroup className="gap-0">
|
<ItemGroup className="gap-0">
|
||||||
{ADMIN_SECTIONS.map((section, index) => (
|
{ADMIN_SECTIONS.map((section, index) => (
|
||||||
<Fragment key={section.id}>
|
<Fragment key={section.id}>
|
||||||
{index > 0 ? <ItemSeparator className="my-0" /> : null}
|
{index > 0 ? <ItemSeparator className="my-0" /> : null}
|
||||||
<Item className="min-h-0 items-center gap-4 px-5 py-3.5">
|
<Item className="min-h-0 min-w-0 flex-wrap items-center gap-4 px-5 py-3.5">
|
||||||
<ItemMedia className="self-center!">
|
<ItemMedia className="self-center!">
|
||||||
<Item className="bg-muted/60 border-background flex size-8 shrink-0 items-center justify-center border-2 p-0 shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] [&_svg]:size-4 [&_svg]:opacity-60">
|
<Item className="bg-muted/60 border-background flex size-8 shrink-0 items-center justify-center border-2 p-0 shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] [&_svg]:size-4 [&_svg]:opacity-60">
|
||||||
{section.icon}
|
{section.icon}
|
||||||
@@ -66,7 +69,7 @@ export function SectionsSettingsTab() {
|
|||||||
</ItemMedia>
|
</ItemMedia>
|
||||||
|
|
||||||
<ItemContent className="min-w-0 justify-center gap-0.5 self-center">
|
<ItemContent className="min-w-0 justify-center gap-0.5 self-center">
|
||||||
<ItemTitle className="gap-2 leading-5">
|
<ItemTitle className="flex min-w-0 flex-wrap items-center gap-2 leading-5">
|
||||||
{section.title}
|
{section.title}
|
||||||
<Badge variant={section.badge.variant} size="xs">
|
<Badge variant={section.badge.variant} size="xs">
|
||||||
{section.badge.label}
|
{section.badge.label}
|
||||||
@@ -76,7 +79,17 @@ export function SectionsSettingsTab() {
|
|||||||
</ItemContent>
|
</ItemContent>
|
||||||
|
|
||||||
<ItemActions className="shrink-0 justify-end self-center">
|
<ItemActions className="shrink-0 justify-end self-center">
|
||||||
<Button variant="outline" size="sm" render={<Link to={section.to} />}>
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
render={
|
||||||
|
section.to === '/settings' ? (
|
||||||
|
<Link to="/settings" search={{ tab: 'bird' }} />
|
||||||
|
) : (
|
||||||
|
<Link to={section.to} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
Открыть
|
Открыть
|
||||||
</Button>
|
</Button>
|
||||||
</ItemActions>
|
</ItemActions>
|
||||||
|
|||||||
@@ -35,8 +35,8 @@ export function SessionSettingsTab() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="flex flex-col gap-6">
|
||||||
<SettingsCard title="Текущая сессия" description="Проверка токена через GET /v1/auth/session">
|
<SettingsCard title="Текущая сессия" description="Проверка токена через API сессии">
|
||||||
<QueryState
|
<QueryState
|
||||||
data={sessionQ.data}
|
data={sessionQ.data}
|
||||||
isLoading={sessionQ.isLoading && hasStoredToken}
|
isLoading={sessionQ.isLoading && hasStoredToken}
|
||||||
@@ -58,15 +58,17 @@ export function SessionSettingsTab() {
|
|||||||
</ItemMedia>
|
</ItemMedia>
|
||||||
|
|
||||||
<ItemContent className="min-w-0 justify-center gap-0.5 self-center">
|
<ItemContent className="min-w-0 justify-center gap-0.5 self-center">
|
||||||
<ItemTitle className="gap-2 leading-5">
|
<ItemTitle className="flex min-w-0 flex-wrap items-center gap-2 leading-5">
|
||||||
Этот браузер
|
Этот браузер
|
||||||
<Badge variant="success-light" size="xs">
|
<Badge variant="success-light" size="xs">
|
||||||
Активна
|
Активна
|
||||||
</Badge>
|
</Badge>
|
||||||
</ItemTitle>
|
</ItemTitle>
|
||||||
<ItemDescription className="leading-5">
|
<ItemDescription className="leading-5">
|
||||||
Tenant:{' '}
|
Арендатор:{' '}
|
||||||
<code className="text-foreground font-mono text-xs">{session.tenant_id}</code>
|
<code className="text-foreground font-mono text-xs break-all">
|
||||||
|
{session.tenant_id}
|
||||||
|
</code>
|
||||||
</ItemDescription>
|
</ItemDescription>
|
||||||
</ItemContent>
|
</ItemContent>
|
||||||
</Item>
|
</Item>
|
||||||
@@ -82,7 +84,7 @@ export function SessionSettingsTab() {
|
|||||||
>
|
>
|
||||||
<SettingRow
|
<SettingRow
|
||||||
title="Роль"
|
title="Роль"
|
||||||
description="Определяет доступ к операциям control plane и CRUD."
|
description="Определяет доступ к операциям плоскости управления и изменению данных."
|
||||||
titleAddon={
|
titleAddon={
|
||||||
sessionQ.data ? (
|
sessionQ.data ? (
|
||||||
<Badge variant="info-light" size="sm">
|
<Badge variant="info-light" size="sm">
|
||||||
@@ -94,12 +96,13 @@ export function SessionSettingsTab() {
|
|||||||
</Badge>
|
</Badge>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
stacked
|
||||||
last
|
last
|
||||||
>
|
>
|
||||||
{sessionQ.data ? (
|
{sessionQ.data ? (
|
||||||
<p className="text-muted-foreground text-sm">
|
<p className="text-muted-foreground text-sm break-words">
|
||||||
Ключ с ролью <strong>{sessionQ.data.role}</strong> в tenant{' '}
|
Ключ с ролью <strong>{ROLE_LABELS[sessionQ.data.role] ?? sessionQ.data.role}</strong> у арендатора{' '}
|
||||||
<code className="font-mono text-xs">{sessionQ.data.tenant_id}</code>.
|
<code className="font-mono text-xs break-all">{sessionQ.data.tenant_id}</code>.
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-muted-foreground text-sm">
|
<p className="text-muted-foreground text-sm">
|
||||||
|
|||||||
@@ -10,31 +10,59 @@ import {
|
|||||||
FieldTitle,
|
FieldTitle,
|
||||||
} from '@evobgp/ui/components/field'
|
} from '@evobgp/ui/components/field'
|
||||||
|
|
||||||
interface SettingRowProps {
|
export interface SettingRowProps {
|
||||||
title: string
|
title: string
|
||||||
description?: ReactNode
|
description?: ReactNode
|
||||||
children: ReactNode
|
children: ReactNode
|
||||||
last?: boolean
|
last?: boolean
|
||||||
|
/** Opt-in FieldSeparator after the row (default off — ReUI settings use Frame+gap). */
|
||||||
|
separated?: boolean
|
||||||
|
compact?: boolean
|
||||||
|
stacked?: boolean
|
||||||
labelFor?: string
|
labelFor?: string
|
||||||
contentClassName?: string
|
contentClassName?: string
|
||||||
|
className?: string
|
||||||
titleAddon?: ReactNode
|
titleAddon?: ReactNode
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Settings row — settings-3 DNA. Preview: https://reui.io/preview/base/settings-3 */
|
/**
|
||||||
|
* Settings row — settings-3 / settings-16 DNA.
|
||||||
|
* Control is flex-1 + min-w-0 (never a fixed w-78) so label and input do not overlap.
|
||||||
|
* @see https://reui.io/preview/base/settings-3
|
||||||
|
* @see https://reui.io/preview/base/settings-16
|
||||||
|
*/
|
||||||
export function SettingRow({
|
export function SettingRow({
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
children,
|
children,
|
||||||
last,
|
last,
|
||||||
|
separated = false,
|
||||||
|
compact,
|
||||||
|
stacked,
|
||||||
labelFor,
|
labelFor,
|
||||||
contentClassName,
|
contentClassName,
|
||||||
|
className,
|
||||||
titleAddon,
|
titleAddon,
|
||||||
}: SettingRowProps) {
|
}: SettingRowProps) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Field orientation="responsive" className="gap-4 px-5 py-3">
|
<Field
|
||||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5 @md/field-group:max-w-sm">
|
orientation={stacked ? 'vertical' : 'responsive'}
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
className={cn(
|
||||||
|
'min-w-0 gap-4 px-5 py-3.5',
|
||||||
|
!stacked &&
|
||||||
|
'@md/field-group:items-start @md/field-group:*:min-w-0 @md/field-group:*:max-w-full',
|
||||||
|
!last && !separated && 'border-border/40 border-b',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex min-w-0 flex-1 flex-col gap-0.5',
|
||||||
|
!stacked && '@md/field-group:max-w-sm',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||||
{labelFor ? (
|
{labelFor ? (
|
||||||
<FieldLabel htmlFor={labelFor}>{title}</FieldLabel>
|
<FieldLabel htmlFor={labelFor}>{title}</FieldLabel>
|
||||||
) : (
|
) : (
|
||||||
@@ -44,20 +72,33 @@ export function SettingRow({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{description ? (
|
{description ? (
|
||||||
<FieldDescription>{description}</FieldDescription>
|
<FieldDescription className="text-sm break-words">{description}</FieldDescription>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<FieldContent
|
<FieldContent
|
||||||
className={cn('min-w-0 @md/field-group:w-78', contentClassName)}
|
className={cn(
|
||||||
|
'w-full min-w-0 @md/field-group:flex-1',
|
||||||
|
stacked
|
||||||
|
? 'max-w-none'
|
||||||
|
: compact
|
||||||
|
? '@md/field-group:max-w-[17rem] @md/field-group:shrink-0'
|
||||||
|
: '@md/field-group:max-w-[min(100%,34rem)]',
|
||||||
|
contentClassName,
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex w-full justify-start @md/field-group:justify-end">
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex w-full min-w-0 justify-start',
|
||||||
|
stacked ? 'justify-start' : '@md/field-group:justify-end',
|
||||||
|
)}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</FieldContent>
|
</FieldContent>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
{!last ? <FieldSeparator /> : null}
|
{separated && !last ? <FieldSeparator /> : null}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export function SettingsCard({
|
|||||||
footerClassName,
|
footerClassName,
|
||||||
}: SettingsCardProps) {
|
}: SettingsCardProps) {
|
||||||
return (
|
return (
|
||||||
<Frame dense spacing="sm" className={cn('w-full gap-0 p-0', className)}>
|
<Frame dense spacing="sm" className={cn('w-full min-w-0 gap-0 p-0', className)}>
|
||||||
<FramePanel className="flex flex-col gap-0 p-0 shadow-xs">
|
<FramePanel className="flex flex-col gap-0 p-0 shadow-xs">
|
||||||
<FrameHeader className="gap-0 border-b px-5 py-3">
|
<FrameHeader className="gap-0 border-b px-5 py-3">
|
||||||
<FrameTitle>{title}</FrameTitle>
|
<FrameTitle>{title}</FrameTitle>
|
||||||
|
|||||||
@@ -1,45 +1,138 @@
|
|||||||
import { BadgeTabs, TabsContent } from '@/components/badge-tabs'
|
import { useIsMobile } from '@/hooks/use-mobile'
|
||||||
|
import { cn } from '@evobgp/ui/lib/utils'
|
||||||
|
import {
|
||||||
|
Tabs,
|
||||||
|
TabsContent,
|
||||||
|
TabsList,
|
||||||
|
TabsTrigger,
|
||||||
|
} from '@evobgp/ui/components/tabs'
|
||||||
|
|
||||||
import { AppearanceSettingsTab } from './appearance-settings-tab'
|
import { AppearanceSettingsTab } from './appearance-settings-tab'
|
||||||
|
import { BirdSettingsTab } from './bird-settings-tab'
|
||||||
import { ConnectionSettingsTab } from './connection-settings-tab'
|
import { ConnectionSettingsTab } from './connection-settings-tab'
|
||||||
import { SectionsSettingsTab } from './sections-settings-tab'
|
|
||||||
import { SessionSettingsTab } from './session-settings-tab'
|
import { SessionSettingsTab } from './session-settings-tab'
|
||||||
import {
|
import {
|
||||||
SETTINGS_TAB_ITEMS,
|
SETTINGS_TAB_ITEMS,
|
||||||
type SettingsTab,
|
type SettingsSection,
|
||||||
|
type SettingsTabItem,
|
||||||
} from './settings-tabs-data'
|
} from './settings-tabs-data'
|
||||||
|
|
||||||
|
function UiSettingsPanel({ tokenRequired }: { tokenRequired: boolean }) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<ConnectionSettingsTab tokenRequired={tokenRequired} />
|
||||||
|
<SessionSettingsTab />
|
||||||
|
<AppearanceSettingsTab />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SettingsNavigation({
|
||||||
|
isMobile,
|
||||||
|
activeValue,
|
||||||
|
items,
|
||||||
|
}: {
|
||||||
|
isMobile: boolean
|
||||||
|
activeValue: string
|
||||||
|
items: SettingsTabItem[]
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className={cn('min-w-0', isMobile ? 'w-full' : 'w-40 shrink-0')}>
|
||||||
|
{isMobile ? (
|
||||||
|
<div className="-mx-1 overflow-x-auto px-1 pb-1">
|
||||||
|
<TabsList className="h-auto w-max min-w-max justify-start gap-1 bg-transparent p-0">
|
||||||
|
{items.map((tab) => (
|
||||||
|
<TabsTrigger
|
||||||
|
key={tab.value}
|
||||||
|
value={tab.value}
|
||||||
|
className={cn(
|
||||||
|
'w-full justify-start gap-3 px-3 py-1.5 shadow-none',
|
||||||
|
activeValue === tab.value ? 'bg-muted!' : 'bg-transparent',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{tab.icon}
|
||||||
|
<span className="truncate">{tab.label}</span>
|
||||||
|
</TabsTrigger>
|
||||||
|
))}
|
||||||
|
</TabsList>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<TabsList className="h-auto w-full flex-col items-stretch gap-1 bg-transparent p-0">
|
||||||
|
{items.map((tab) => (
|
||||||
|
<TabsTrigger
|
||||||
|
key={tab.value}
|
||||||
|
value={tab.value}
|
||||||
|
className={cn(
|
||||||
|
'w-full justify-start gap-3 px-3 py-1.5 shadow-none',
|
||||||
|
activeValue === tab.value ? 'bg-muted!' : 'bg-transparent',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{tab.icon}
|
||||||
|
<span className="truncate">{tab.label}</span>
|
||||||
|
</TabsTrigger>
|
||||||
|
))}
|
||||||
|
</TabsList>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unified settings — settings-7 AccountSettings 1:1 (header + vertical Tabs).
|
||||||
|
* Surface of cards stays Frame (`SettingsCard`), not shadcn Card.
|
||||||
|
* @see https://reui.io/preview/base/settings-7
|
||||||
|
* @see https://reui.io/blocks
|
||||||
|
* @see https://reui.io/docs/components/base/frame
|
||||||
|
*/
|
||||||
export function SettingsPageShell({
|
export function SettingsPageShell({
|
||||||
activeTab,
|
activeTab,
|
||||||
onTabChange,
|
onTabChange,
|
||||||
tokenRequired,
|
tokenRequired,
|
||||||
|
showBird,
|
||||||
}: {
|
}: {
|
||||||
activeTab: SettingsTab
|
activeTab: SettingsSection
|
||||||
onTabChange: (tab: SettingsTab) => void
|
onTabChange: (tab: SettingsSection) => void
|
||||||
tokenRequired: boolean
|
tokenRequired: boolean
|
||||||
|
showBird: boolean
|
||||||
}) {
|
}) {
|
||||||
|
const isMobile = useIsMobile()
|
||||||
|
const items = showBird
|
||||||
|
? SETTINGS_TAB_ITEMS
|
||||||
|
: SETTINGS_TAB_ITEMS.filter((tab) => tab.value === 'ui')
|
||||||
|
const resolvedTab = activeTab === 'bird' && showBird ? 'bird' : 'ui'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BadgeTabs
|
<div className="mx-auto flex w-full max-w-4xl flex-col gap-8">
|
||||||
value={activeTab}
|
<header className="px-1">
|
||||||
onValueChange={(value) => onTabChange(value as SettingsTab)}
|
<h1 className="text-xl font-semibold tracking-tight">Настройки</h1>
|
||||||
items={SETTINGS_TAB_ITEMS.map((tab) => ({
|
<p className="text-muted-foreground max-w-2xl text-sm leading-relaxed">
|
||||||
value: tab.value,
|
Интерфейс UI и глобальные параметры BIRD в одном разделе.
|
||||||
label: tab.label,
|
</p>
|
||||||
icon: tab.icon,
|
</header>
|
||||||
}))}
|
|
||||||
>
|
<Tabs
|
||||||
<TabsContent value="connection" className="mt-0">
|
value={resolvedTab}
|
||||||
<ConnectionSettingsTab tokenRequired={tokenRequired} />
|
onValueChange={(value) => onTabChange(value as SettingsSection)}
|
||||||
</TabsContent>
|
orientation={isMobile ? 'horizontal' : 'vertical'}
|
||||||
<TabsContent value="session" className="mt-0">
|
className="w-full gap-4 lg:gap-8"
|
||||||
<SessionSettingsTab />
|
>
|
||||||
</TabsContent>
|
<SettingsNavigation
|
||||||
<TabsContent value="appearance" className="mt-0">
|
isMobile={isMobile}
|
||||||
<AppearanceSettingsTab />
|
activeValue={resolvedTab}
|
||||||
</TabsContent>
|
items={items}
|
||||||
<TabsContent value="sections" className="mt-0">
|
/>
|
||||||
<SectionsSettingsTab />
|
|
||||||
</TabsContent>
|
<div className="min-w-0 flex-1">
|
||||||
</BadgeTabs>
|
<TabsContent value="ui" className="mt-0">
|
||||||
|
<UiSettingsPanel tokenRequired={tokenRequired} />
|
||||||
|
</TabsContent>
|
||||||
|
{showBird ? (
|
||||||
|
<TabsContent value="bird" className="mt-0">
|
||||||
|
<BirdSettingsTab />
|
||||||
|
</TabsContent>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,9 @@
|
|||||||
import { type ComponentProps, type ReactNode } from 'react'
|
import { type ComponentProps, type ReactNode } from 'react'
|
||||||
|
|
||||||
import { Badge } from '@/components/reui/badge'
|
import { Badge } from '@/components/reui/badge'
|
||||||
import { cn } from '@evobgp/ui/lib/utils'
|
import { SettingRow } from '@/components/settings/setting-row'
|
||||||
import {
|
|
||||||
Field,
|
|
||||||
FieldContent,
|
|
||||||
FieldDescription,
|
|
||||||
FieldLabel,
|
|
||||||
FieldSeparator,
|
|
||||||
FieldTitle,
|
|
||||||
} from '@evobgp/ui/components/field'
|
|
||||||
|
|
||||||
/** settings-3 row pattern for tenant/UI settings. */
|
/** Tenant settings row — same SettingRow DNA as UI settings. */
|
||||||
export function SettingsSettingField({
|
export function SettingsSettingField({
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
@@ -20,38 +12,34 @@ export function SettingsSettingField({
|
|||||||
last,
|
last,
|
||||||
labelFor,
|
labelFor,
|
||||||
contentClassName,
|
contentClassName,
|
||||||
|
stacked,
|
||||||
}: {
|
}: {
|
||||||
title: string
|
title: string
|
||||||
description: ReactNode
|
description?: ReactNode
|
||||||
badge?: { label: string; variant: ComponentProps<typeof Badge>['variant'] }
|
badge?: { label: string; variant: ComponentProps<typeof Badge>['variant'] }
|
||||||
children: ReactNode
|
children: ReactNode
|
||||||
last?: boolean
|
last?: boolean
|
||||||
labelFor?: string
|
labelFor?: string
|
||||||
contentClassName?: string
|
contentClassName?: string
|
||||||
|
stacked?: boolean
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<>
|
<SettingRow
|
||||||
<Field orientation="responsive" className="gap-4 px-4 py-4">
|
title={title}
|
||||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5 @md/field-group:max-w-sm">
|
description={description}
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
last={last}
|
||||||
{labelFor ? (
|
labelFor={labelFor}
|
||||||
<FieldLabel htmlFor={labelFor}>{title}</FieldLabel>
|
contentClassName={contentClassName}
|
||||||
) : (
|
stacked={stacked}
|
||||||
<FieldTitle>{title}</FieldTitle>
|
titleAddon={
|
||||||
)}
|
badge ? (
|
||||||
{badge ? (
|
<Badge variant={badge.variant} size="sm">
|
||||||
<Badge variant={badge.variant} size="sm">
|
{badge.label}
|
||||||
{badge.label}
|
</Badge>
|
||||||
</Badge>
|
) : null
|
||||||
) : null}
|
}
|
||||||
</div>
|
>
|
||||||
<FieldDescription className="text-xs">{description}</FieldDescription>
|
{children}
|
||||||
</div>
|
</SettingRow>
|
||||||
<FieldContent className={cn('min-w-0 @md/field-group:w-78', contentClassName)}>
|
|
||||||
{children}
|
|
||||||
</FieldContent>
|
|
||||||
</Field>
|
|
||||||
{!last ? <FieldSeparator /> : null}
|
|
||||||
</>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,49 +1,32 @@
|
|||||||
import {
|
import { ServerCogIcon, SettingsIcon } from 'lucide-react'
|
||||||
KeyRoundIcon,
|
|
||||||
MonitorSmartphoneIcon,
|
|
||||||
PaletteIcon,
|
|
||||||
SlidersHorizontalIcon,
|
|
||||||
} from 'lucide-react'
|
|
||||||
import { type ReactNode } from 'react'
|
import { type ReactNode } from 'react'
|
||||||
|
|
||||||
export type SettingsTab = 'connection' | 'session' | 'appearance' | 'sections'
|
export type SettingsSection = 'ui' | 'bird'
|
||||||
|
|
||||||
export type SettingsTabItem = {
|
export type SettingsTabItem = {
|
||||||
value: SettingsTab
|
value: SettingsSection
|
||||||
label: string
|
label: string
|
||||||
icon: ReactNode
|
icon: ReactNode
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Side rail items — settings-7 AccountSettings DNA. */
|
||||||
export const SETTINGS_TAB_ITEMS: SettingsTabItem[] = [
|
export const SETTINGS_TAB_ITEMS: SettingsTabItem[] = [
|
||||||
{
|
{
|
||||||
value: 'connection',
|
value: 'ui',
|
||||||
label: 'Подключение',
|
label: 'Настройки UI',
|
||||||
icon: <KeyRoundIcon aria-hidden="true" />,
|
icon: <SettingsIcon aria-hidden="true" />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: 'session',
|
value: 'bird',
|
||||||
label: 'Сессия',
|
label: 'Настройки BIRD',
|
||||||
icon: <MonitorSmartphoneIcon aria-hidden="true" />,
|
icon: <ServerCogIcon aria-hidden="true" />,
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 'appearance',
|
|
||||||
label: 'Оформление',
|
|
||||||
icon: <PaletteIcon aria-hidden="true" />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 'sections',
|
|
||||||
label: 'Разделы',
|
|
||||||
icon: <SlidersHorizontalIcon aria-hidden="true" />,
|
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
export function parseSettingsTab(value: unknown): SettingsTab {
|
const LEGACY_BIRD = new Set(['bird', 'revision', 'runtime-logs', 'additional'])
|
||||||
if (
|
|
||||||
value === 'session' ||
|
/** Maps current and legacy `?tab=` values onto the unified settings rail. */
|
||||||
value === 'appearance' ||
|
export function parseSettingsSection(value: unknown): SettingsSection {
|
||||||
value === 'sections'
|
if (typeof value === 'string' && LEGACY_BIRD.has(value)) return 'bird'
|
||||||
) {
|
return 'ui'
|
||||||
return value
|
|
||||||
}
|
|
||||||
return 'connection'
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,9 +30,28 @@ export function moduleTypeRu(type: string): string {
|
|||||||
|
|
||||||
const JOB_KIND_RU: Record<string, string> = {
|
const JOB_KIND_RU: Record<string, string> = {
|
||||||
module_refresh: 'Обновление модуля',
|
module_refresh: 'Обновление модуля',
|
||||||
|
tenant_refresh: 'Обновление тенанта',
|
||||||
|
peer_reconcile: 'Согласование пиров',
|
||||||
|
deploy_apply: 'Применение на спикеры',
|
||||||
apply: 'Применение конфигурации',
|
apply: 'Применение конфигурации',
|
||||||
|
revision_rollback: 'Откат ревизии',
|
||||||
rollback: 'Откат ревизии',
|
rollback: 'Откат ревизии',
|
||||||
bird_reload: 'Перезагрузка BIRD',
|
bird_reload: 'Перезагрузка BIRD',
|
||||||
|
postgres_metrics_refresh: 'Метрики PostgreSQL',
|
||||||
|
postgres_slow_query_aggregate: 'Медленные запросы PostgreSQL',
|
||||||
|
postgres_table_bloat_estimate: 'Bloat таблиц PostgreSQL',
|
||||||
|
postgres_index_usage_analyze: 'Использование индексов PostgreSQL',
|
||||||
|
postgres_autovacuum_lag_detect: 'Отставание autovacuum',
|
||||||
|
postgres_vacuum: 'VACUUM PostgreSQL',
|
||||||
|
postgres_vacuum_analyze: 'VACUUM ANALYZE PostgreSQL',
|
||||||
|
postgres_analyze: 'ANALYZE PostgreSQL',
|
||||||
|
postgres_reindex: 'REINDEX PostgreSQL',
|
||||||
|
postgres_cleanup: 'Очистка PostgreSQL',
|
||||||
|
maintenance_policy_run: 'Политика обслуживания',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isRefreshJobKind(kind: string): boolean {
|
||||||
|
return kind === 'module_refresh' || kind === 'tenant_refresh'
|
||||||
}
|
}
|
||||||
|
|
||||||
export function jobKindRu(kind: string): string {
|
export function jobKindRu(kind: string): string {
|
||||||
@@ -61,18 +80,45 @@ const JOB_STATUS_RU: Record<string, string> = {
|
|||||||
paused: 'Приостановлен',
|
paused: 'Приостановлен',
|
||||||
disabled: 'Выключен',
|
disabled: 'Выключен',
|
||||||
archived: 'В архиве',
|
archived: 'В архиве',
|
||||||
block: 'block',
|
block: 'Блокировать',
|
||||||
accept: 'accept',
|
accept: 'Принимать',
|
||||||
}
|
}
|
||||||
|
|
||||||
export function jobStatusRu(status: string): string {
|
export function jobStatusRu(status: string): string {
|
||||||
return JOB_STATUS_RU[status.toLowerCase()] ?? status
|
return JOB_STATUS_RU[status.toLowerCase()] ?? status
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const BGP_SESSION_STATE_RU: Record<string, string> = {
|
||||||
|
Idle: 'Простой',
|
||||||
|
Connect: 'Соединение',
|
||||||
|
Active: 'Поиск',
|
||||||
|
OpenSent: 'Open отправлен',
|
||||||
|
OpenConfirm: 'Open подтверждён',
|
||||||
|
Established: 'Установлена',
|
||||||
|
}
|
||||||
|
|
||||||
export function bgpSessionStateRu(state: string | null | undefined): string {
|
export function bgpSessionStateRu(state: string | null | undefined): string {
|
||||||
if (!state) return '—'
|
if (!state) return '—'
|
||||||
if (state === 'Established') return 'Установлена'
|
return BGP_SESSION_STATE_RU[state] ?? state
|
||||||
return state
|
}
|
||||||
|
|
||||||
|
export function speakerRoleRu(role: string | null | undefined): string {
|
||||||
|
switch (role) {
|
||||||
|
case 'master':
|
||||||
|
return 'Основной'
|
||||||
|
case 'primary':
|
||||||
|
return 'Основной'
|
||||||
|
case 'secondary':
|
||||||
|
return 'Резервный'
|
||||||
|
case 'speaker':
|
||||||
|
return 'Спикер'
|
||||||
|
default:
|
||||||
|
return role?.trim() || '—'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cdnSourceKindRu(kind: string): string {
|
||||||
|
return kind === 'json' ? 'JSON' : 'Текст'
|
||||||
}
|
}
|
||||||
|
|
||||||
export function speakerOnlineLabel(agentOk: boolean | undefined): string {
|
export function speakerOnlineLabel(agentOk: boolean | undefined): string {
|
||||||
|
|||||||
@@ -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'),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { toast } from 'sonner'
|
|||||||
import { apiJSON, apiMutate } from '@/lib/api-client'
|
import { apiJSON, apiMutate } from '@/lib/api-client'
|
||||||
import { overviewKeys } from '@/queries/overview'
|
import { overviewKeys } from '@/queries/overview'
|
||||||
import type {
|
import type {
|
||||||
|
ModuleCreate,
|
||||||
ModulePatch,
|
ModulePatch,
|
||||||
ModuleRow,
|
ModuleRow,
|
||||||
ModulesResponse,
|
ModulesResponse,
|
||||||
@@ -64,6 +65,18 @@ export function moduleEntriesQueryOptions(id: string, type: ModuleRow['type']) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useCreateModuleMutation() {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (body: ModuleCreate) => apiMutate<ModuleRow>('/v1/modules', 'POST', body),
|
||||||
|
onSuccess: (data) => {
|
||||||
|
toast.success('Модуль создан')
|
||||||
|
invalidateModules(qc, data.id)
|
||||||
|
},
|
||||||
|
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось создать модуль'),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export function useUpdateModuleMutation() {
|
export function useUpdateModuleMutation() {
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
return useMutation({
|
return useMutation({
|
||||||
|
|||||||
@@ -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()}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Link, createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { Plus, RefreshCw } from 'lucide-react'
|
import { Plus, RefreshCw } from 'lucide-react'
|
||||||
|
|
||||||
@@ -6,18 +6,45 @@ import { Button } from '@evobgp/ui/components/button'
|
|||||||
|
|
||||||
import { FrameDataGrid } from '@/components/reui-kit'
|
import { FrameDataGrid } from '@/components/reui-kit'
|
||||||
import { ProjectsEmptyState } from '@/components/patterns/projects-empty-state'
|
import { ProjectsEmptyState } from '@/components/patterns/projects-empty-state'
|
||||||
|
import { ModuleCreateDialog } from '@/components/modules/module-create-dialog'
|
||||||
import { ModulesListGrid } from '@/components/modules/modules-list-grid'
|
import { ModulesListGrid } from '@/components/modules/modules-list-grid'
|
||||||
import { PageHeader } from '@/components/page-header'
|
import { PageHeader } from '@/components/page-header'
|
||||||
import { QueryState } from '@/components/query-state'
|
import { QueryState } from '@/components/query-state'
|
||||||
import { TableSkeleton } from '@/components/skeletons'
|
import { TableSkeleton } from '@/components/skeletons'
|
||||||
|
import { sessionCanWriteModules } from '@/lib/auth'
|
||||||
|
import { authSessionQueryOptions } from '@/queries/auth'
|
||||||
|
import {
|
||||||
|
directoriesCommunitiesQueryOptions,
|
||||||
|
directoriesDohQueryOptions,
|
||||||
|
} from '@/queries/directories'
|
||||||
import { modulesListQueryOptions } from '@/queries/modules'
|
import { modulesListQueryOptions } from '@/queries/modules'
|
||||||
|
|
||||||
|
function parseCreateFlag(value: unknown): boolean {
|
||||||
|
return value === true || value === '1' || value === 'true'
|
||||||
|
}
|
||||||
|
|
||||||
export const Route = createFileRoute('/_auth/modules/')({
|
export const Route = createFileRoute('/_auth/modules/')({
|
||||||
component: ModulesListComponent,
|
component: ModulesListComponent,
|
||||||
|
validateSearch: (search: Record<string, unknown>): { create?: boolean } => {
|
||||||
|
if (parseCreateFlag(search.create)) return { create: true }
|
||||||
|
return {}
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
function ModulesListComponent() {
|
function ModulesListComponent() {
|
||||||
|
const { create } = Route.useSearch()
|
||||||
|
const navigate = Route.useNavigate()
|
||||||
const query = useQuery(modulesListQueryOptions())
|
const query = useQuery(modulesListQueryOptions())
|
||||||
|
const sessionQ = useQuery(authSessionQueryOptions())
|
||||||
|
const canWrite = sessionCanWriteModules(sessionQ.data)
|
||||||
|
const communitiesQ = useQuery(directoriesCommunitiesQueryOptions())
|
||||||
|
const dohQ = useQuery(directoriesDohQueryOptions())
|
||||||
|
|
||||||
|
const createOpen = canWrite && create === true
|
||||||
|
|
||||||
|
function setCreateOpen(open: boolean) {
|
||||||
|
void navigate({ search: open ? { create: true } : {}, replace: true })
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
@@ -42,10 +69,12 @@ function ModulesListComponent() {
|
|||||||
<FrameDataGrid
|
<FrameDataGrid
|
||||||
title="Все модули"
|
title="Все модули"
|
||||||
actions={
|
actions={
|
||||||
<Button size="sm" render={<Link to="/modules/new" />}>
|
canWrite ? (
|
||||||
<Plus />
|
<Button size="sm" onClick={() => setCreateOpen(true)}>
|
||||||
Создать
|
<Plus />
|
||||||
</Button>
|
Создать
|
||||||
|
</Button>
|
||||||
|
) : undefined
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<QueryState
|
<QueryState
|
||||||
@@ -54,7 +83,12 @@ function ModulesListComponent() {
|
|||||||
isError={query.isError}
|
isError={query.isError}
|
||||||
error={query.error}
|
error={query.error}
|
||||||
empty={query.data?.items?.length === 0}
|
empty={query.data?.items?.length === 0}
|
||||||
emptyContent={<ProjectsEmptyState />}
|
emptyContent={
|
||||||
|
<ProjectsEmptyState
|
||||||
|
canCreate={canWrite}
|
||||||
|
onCreate={() => setCreateOpen(true)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
skeleton={<TableSkeleton rows={6} cols={5} />}
|
skeleton={<TableSkeleton rows={6} cols={5} />}
|
||||||
onRetry={() => query.refetch()}
|
onRetry={() => query.refetch()}
|
||||||
>
|
>
|
||||||
@@ -66,6 +100,16 @@ function ModulesListComponent() {
|
|||||||
)}
|
)}
|
||||||
</QueryState>
|
</QueryState>
|
||||||
</FrameDataGrid>
|
</FrameDataGrid>
|
||||||
|
|
||||||
|
<ModuleCreateDialog
|
||||||
|
open={createOpen}
|
||||||
|
onOpenChange={setCreateOpen}
|
||||||
|
communities={communitiesQ.data?.items ?? []}
|
||||||
|
dohProfiles={dohQ.data?.items ?? []}
|
||||||
|
onCreated={(mod) => {
|
||||||
|
void navigate({ to: '/modules/$moduleId', params: { moduleId: mod.id } })
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,42 +1,7 @@
|
|||||||
import { createFileRoute, Link } from '@tanstack/react-router'
|
import { createFileRoute, redirect } from '@tanstack/react-router'
|
||||||
import { Button } from '@evobgp/ui/components/button'
|
|
||||||
import { PageHeader } from '@/components/page-header'
|
|
||||||
import {
|
|
||||||
Frame,
|
|
||||||
FrameDescription,
|
|
||||||
FrameHeader,
|
|
||||||
FramePanel,
|
|
||||||
FrameTitle,
|
|
||||||
} from '@/components/reui/frame'
|
|
||||||
|
|
||||||
export const Route = createFileRoute('/_auth/modules/new')({
|
export const Route = createFileRoute('/_auth/modules/new')({
|
||||||
component: NewModuleComponent,
|
beforeLoad: () => {
|
||||||
|
throw redirect({ to: '/modules', search: { create: true } })
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
function NewModuleComponent() {
|
|
||||||
return (
|
|
||||||
<div className="mx-auto flex max-w-3xl flex-col gap-6">
|
|
||||||
<PageHeader
|
|
||||||
title="Новый модуль"
|
|
||||||
description="Создание модуля — через API или будущая форма"
|
|
||||||
/>
|
|
||||||
<Frame dense spacing="sm">
|
|
||||||
<FrameHeader>
|
|
||||||
<FrameTitle>Создание через API</FrameTitle>
|
|
||||||
<FrameDescription>
|
|
||||||
Форма в UI появится позже. Сейчас модуль можно создать запросом ниже.
|
|
||||||
</FrameDescription>
|
|
||||||
</FrameHeader>
|
|
||||||
<FramePanel className="flex flex-col gap-3 text-sm text-muted-foreground">
|
|
||||||
<pre className="overflow-x-auto rounded-md border bg-muted/40 p-3 font-mono text-xs">
|
|
||||||
{`POST /v1/modules
|
|
||||||
{ "type": "DOMAINS", "name": "Мой список" }`}
|
|
||||||
</pre>
|
|
||||||
<Button variant="outline" size="sm" className="self-start" render={<Link to="/modules" />}>
|
|
||||||
Назад к списку
|
|
||||||
</Button>
|
|
||||||
</FramePanel>
|
|
||||||
</Frame>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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 | `gcr.io/distroless/static-debian12:nonroot` | static Go (`CGO_ENABLED=0`), без shell |
|
||||||
|
| api, all, deploy, node | `debian:bookworm-slim` + `bird` + `birdc` | `bird -p` (parse-check) и `birdc`; демон не запускается |
|
||||||
|
| agent | тот же Ubuntu+bird2, что bird2 | общие слои с `evobgp-bird2` |
|
||||||
|
| bird2 | Ubuntu Noble + пакет bird2 | |
|
||||||
|
| web, web-all | `nginx:1.27-alpine` | `worker_processes 1` |
|
||||||
|
|
||||||
Остальные bake-target’ы только `cache-from` (чтение). Параллельный `cache-to` в один ref ломает manifest в registry (`content descriptor … not found`).
|
Сборка Go: `golang:1.24-alpine`. Context режется корневым [.dockerignore](../../.dockerignore).
|
||||||
|
|
||||||
Локально BuildKit также кэширует `/go/pkg/mod` и `~/.cache/go-build` через `RUN --mount=type=cache`.
|
### Кэш registry
|
||||||
|
|
||||||
Если CI падает на «not found» после смены схемы кэша — один раз удалите теги `evobgp-buildcache:go-buildcache` и `:web-buildcache` в registry и пересоберите.
|
- `git.shx.one/<owner>/evobgp-buildcache:go-buildcache` — **запись** только из `go-build-all`
|
||||||
|
- `git.shx.one/<owner>/evobgp-buildcache:web-buildcache` — **запись** только из `web-build`
|
||||||
|
- `git.shx.one/<owner>/evobgp-buildcache:birdc-buildcache` — **запись** только из `go-birdc`
|
||||||
|
- `git.shx.one/<owner>/evobgp-buildcache:base-*` — зеркало FROM с **Docker Hub** (`docker.io/library/…`; distroless — `gcr.io`). Только `linux/amd64`; skip если тег уже в Gitea. Неуспешный copy не валит CD — bake берёт Docker Hub FROM для этой базы.
|
||||||
|
|
||||||
|
`pull = false` в bake: не перекачивать FROM, если слой уже в builder. Не запускайте `docker system prune -a` на runner.
|
||||||
|
|
||||||
|
Параллельный `cache-to` в один ref ломает manifest (`content descriptor … not found`).
|
||||||
|
|
||||||
|
Если CI падает на «not found» после смены схемы кэша — один раз удалите теги `evobgp-buildcache:*` в registry и пересоберите.
|
||||||
|
|
||||||
## Локальная сборка
|
## Локальная сборка
|
||||||
|
|
||||||
@@ -37,20 +46,24 @@ export IMAGE_TAG=latest
|
|||||||
export SHORT_SHA=$(git rev-parse --short HEAD)
|
export SHORT_SHA=$(git rev-parse --short HEAD)
|
||||||
export VERSION=dev
|
export VERSION=dev
|
||||||
export BUILD_TIME=
|
export BUILD_TIME=
|
||||||
sh write-bake-override.sh
|
docker buildx bake --allow=fs.read=../.. -f docker-bake.hcl go-images
|
||||||
docker buildx bake --allow=fs.read=../.. -f docker-bake.hcl -f docker-bake.override.hcl go-images
|
docker buildx bake --allow=fs.read=../.. -f docker-bake.hcl web-images
|
||||||
docker buildx bake --allow=fs.read=../.. -f docker-bake.hcl -f docker-bake.override.hcl web-images
|
|
||||||
```
|
```
|
||||||
|
|
||||||
В `docker-bake.hcl`: `context = "../.."` (корень репо), `dockerfile = "deploy/docker/…"` (путь от корня репо). **CI:** `write-bake-override.sh` + `--allow=fs.read=$GITHUB_WORKSPACE` в `.gitea/workflows/ci.yaml`.
|
Проверка манифеста без сборки:
|
||||||
|
|
||||||
Один образ (legacy):
|
```bash
|
||||||
|
docker buildx bake --allow=fs.read=../.. -f docker-bake.hcl --print default
|
||||||
|
```
|
||||||
|
|
||||||
|
В `docker-bake.hcl`: `context = "../.."` резолвится **от cwd** (каталог `deploy/docker/`). `dockerfile` — путь от этого context (корень репо). Из корня репо не вызывать bake с `-f deploy/docker/docker-bake.hcl`: получится `lstat ../../deploy`. Можно задать `BUILDX_BAKE_FILE_RELATIVE_PATHS=1`.
|
||||||
|
|
||||||
|
Один образ (legacy, target `build-all`):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker build -f deploy/docker/gobinary/Dockerfile \
|
docker build -f deploy/docker/gobinary/Dockerfile \
|
||||||
--target build-all \
|
--target build-all \
|
||||||
--build-arg BIN=evobgp-api \
|
-t evobgp-build-all:local .
|
||||||
-t evobgp-api:local .
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Runtime-образы в bake ожидают stage `build-all` (через bake contexts), не отдельный `--build-arg BIN` на старый target `build`.
|
Runtime-образы в bake ожидают stage `build-all` (через bake contexts).
|
||||||
|
|||||||
@@ -1,10 +1,17 @@
|
|||||||
|
# syntax=docker/dockerfile:1.7
|
||||||
# BIRD из репозитория Ubuntu (Noble 24.04 LTS) — актуальнее пакета Debian bookworm.
|
# BIRD из репозитория Ubuntu (Noble 24.04 LTS) — актуальнее пакета Debian bookworm.
|
||||||
# Сборка из корня репозитория: docker build -f deploy/docker/bird2/Dockerfile .
|
# Stage bird2-base общий с evobgp-agent (одинаковые слои на speaker-VPS).
|
||||||
# Зеркало ECR Public вместо прямого pull с Docker Hub.
|
ARG BASE_UBUNTU=docker.io/library/ubuntu:noble
|
||||||
FROM public.ecr.aws/docker/library/ubuntu:noble
|
|
||||||
RUN apt-get update \
|
FROM ${BASE_UBUNTU} AS bird2-base
|
||||||
&& apt-get install -y --no-install-recommends bird2 \
|
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
--mount=type=cache,target=/var/lib/apt,sharing=locked \
|
||||||
|
rm -f /etc/apt/apt.conf.d/docker-clean \
|
||||||
|
&& apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends bird2 ca-certificates \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
FROM bird2-base AS bird2
|
||||||
COPY deploy/bird/bird.conf /etc/bird/bird.conf
|
COPY deploy/bird/bird.conf /etc/bird/bird.conf
|
||||||
RUN mkdir -p /etc/bird/bird.d
|
RUN mkdir -p /etc/bird/bird.d
|
||||||
EXPOSE 179
|
EXPOSE 179
|
||||||
|
|||||||
+110
-27
@@ -1,7 +1,7 @@
|
|||||||
# Единая сборка образов EvoBGP (buildx bake: -f deploy/docker/docker-bake.hcl).
|
# Единая сборка образов EvoBGP (buildx bake: -f deploy/docker/docker-bake.hcl).
|
||||||
# context = "../.." — корень репозитория (относительно этого файла).
|
# context = "../.." — корень репо. Bake резолвит context от cwd (не от HCL),
|
||||||
# dockerfile — путь от корня репозитория (относительно context).
|
# поэтому вызов из deploy/docker/ либо BUILDX_BAKE_FILE_RELATIVE_PATHS=1.
|
||||||
# Переменные: REGISTRY, IMAGE_TAG, CACHE_REF_GO, CACHE_REF_WEB
|
# Переменные Bake читаются из окружения (см. docs.docker.com/build/bake/variables/).
|
||||||
|
|
||||||
variable "REGISTRY" {
|
variable "REGISTRY" {
|
||||||
default = "git.shx.one/evobgp"
|
default = "git.shx.one/evobgp"
|
||||||
@@ -35,14 +35,40 @@ variable "CACHE_REF_WEB" {
|
|||||||
default = ""
|
default = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
variable "CACHE_REF_BIRDC" {
|
||||||
|
default = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "BASE_GOLANG" {
|
||||||
|
default = "docker.io/library/golang:1.24-alpine"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "BASE_DEBIAN" {
|
||||||
|
default = "docker.io/library/debian:bookworm-slim"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "BASE_DISTROLESS" {
|
||||||
|
default = "gcr.io/distroless/static-debian12:nonroot"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "BASE_NODE" {
|
||||||
|
default = "docker.io/library/node:22-alpine"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "BASE_NGINX" {
|
||||||
|
default = "docker.io/library/nginx:1.27-alpine"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "BASE_UBUNTU" {
|
||||||
|
default = "docker.io/library/ubuntu:noble"
|
||||||
|
}
|
||||||
|
|
||||||
function "go-cache-from" {
|
function "go-cache-from" {
|
||||||
params = []
|
params = []
|
||||||
result = notequal("", CACHE_REF_GO) ? ["type=registry,ref=${CACHE_REF_GO}"] : []
|
result = notequal("", CACHE_REF_GO) ? ["type=registry,ref=${CACHE_REF_GO}"] : []
|
||||||
}
|
}
|
||||||
|
|
||||||
# Экспорт кэша — только go-build-all / web-build (один writer на ref).
|
# Экспорт кэша — один writer на ref (параллельный cache-to в один ref ломает manifest).
|
||||||
# Несколько target с cache-to в один ref и mode=max дают гонку в registry
|
|
||||||
# (content descriptor not found при параллельном bake).
|
|
||||||
function "go-cache-to-export" {
|
function "go-cache-to-export" {
|
||||||
params = []
|
params = []
|
||||||
result = notequal("", CACHE_REF_GO) ? ["type=registry,ref=${CACHE_REF_GO},mode=max"] : []
|
result = notequal("", CACHE_REF_GO) ? ["type=registry,ref=${CACHE_REF_GO},mode=max"] : []
|
||||||
@@ -58,6 +84,16 @@ function "web-cache-to-export" {
|
|||||||
result = notequal("", CACHE_REF_WEB) ? ["type=registry,ref=${CACHE_REF_WEB},mode=max"] : []
|
result = notequal("", CACHE_REF_WEB) ? ["type=registry,ref=${CACHE_REF_WEB},mode=max"] : []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function "birdc-cache-from" {
|
||||||
|
params = []
|
||||||
|
result = notequal("", CACHE_REF_BIRDC) ? ["type=registry,ref=${CACHE_REF_BIRDC}"] : []
|
||||||
|
}
|
||||||
|
|
||||||
|
function "birdc-cache-to-export" {
|
||||||
|
params = []
|
||||||
|
result = notequal("", CACHE_REF_BIRDC) ? ["type=registry,ref=${CACHE_REF_BIRDC},mode=max"] : []
|
||||||
|
}
|
||||||
|
|
||||||
group "default" {
|
group "default" {
|
||||||
targets = ["go-images", "web-images", "evobgp-bird2"]
|
targets = ["go-images", "web-images", "evobgp-bird2"]
|
||||||
}
|
}
|
||||||
@@ -75,26 +111,38 @@ group "go-images" {
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
# web-deps / web-build — только зависимости (contexts), без tags; при --push в группе не указывать.
|
|
||||||
group "web-images" {
|
group "web-images" {
|
||||||
targets = ["evobgp-web", "evobgp-web-all"]
|
targets = ["evobgp-web", "evobgp-web-all"]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
target "_common" {
|
||||||
|
platforms = ["linux/amd64"]
|
||||||
|
pull = false
|
||||||
|
}
|
||||||
|
|
||||||
|
target "_go-bases" {
|
||||||
|
args = {
|
||||||
|
BASE_GOLANG = BASE_GOLANG
|
||||||
|
BASE_DEBIAN = BASE_DEBIAN
|
||||||
|
BASE_DISTROLESS = BASE_DISTROLESS
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
# --- Go: go mod download → все cmd/* → birdc (один раз) → runtime-образы ---
|
# --- Go: go mod download → все cmd/* → birdc (один раз) → runtime-образы ---
|
||||||
|
|
||||||
target "go-deps" {
|
target "go-deps" {
|
||||||
|
inherits = ["_common", "_go-bases"]
|
||||||
context = "../.."
|
context = "../.."
|
||||||
dockerfile = "deploy/docker/gobinary/Dockerfile"
|
dockerfile = "deploy/docker/gobinary/Dockerfile"
|
||||||
target = "deps"
|
target = "deps"
|
||||||
platforms = ["linux/amd64"]
|
|
||||||
cache-from = go-cache-from()
|
cache-from = go-cache-from()
|
||||||
}
|
}
|
||||||
|
|
||||||
target "go-build-all" {
|
target "go-build-all" {
|
||||||
|
inherits = ["_common", "_go-bases"]
|
||||||
context = "../.."
|
context = "../.."
|
||||||
dockerfile = "deploy/docker/gobinary/Dockerfile"
|
dockerfile = "deploy/docker/gobinary/Dockerfile"
|
||||||
target = "build-all"
|
target = "build-all"
|
||||||
platforms = ["linux/amd64"]
|
|
||||||
args = {
|
args = {
|
||||||
VERSION = VERSION
|
VERSION = VERSION
|
||||||
GIT_SHA = notequal("", SHA_FULL) ? SHA_FULL : SHORT_SHA
|
GIT_SHA = notequal("", SHA_FULL) ? SHA_FULL : SHORT_SHA
|
||||||
@@ -108,18 +156,19 @@ target "go-build-all" {
|
|||||||
}
|
}
|
||||||
|
|
||||||
target "go-birdc" {
|
target "go-birdc" {
|
||||||
|
inherits = ["_common", "_go-bases"]
|
||||||
context = "../.."
|
context = "../.."
|
||||||
dockerfile = "deploy/docker/gobinary/Dockerfile"
|
dockerfile = "deploy/docker/gobinary/Dockerfile"
|
||||||
target = "birdc"
|
target = "birdc"
|
||||||
platforms = ["linux/amd64"]
|
cache-from = birdc-cache-from()
|
||||||
cache-from = go-cache-from()
|
cache-to = birdc-cache-to-export()
|
||||||
}
|
}
|
||||||
|
|
||||||
target "_go-runtime" {
|
target "_go-runtime" {
|
||||||
|
inherits = ["_common", "_go-bases"]
|
||||||
context = "../.."
|
context = "../.."
|
||||||
dockerfile = "deploy/docker/gobinary/Dockerfile"
|
dockerfile = "deploy/docker/gobinary/Dockerfile"
|
||||||
target = "runtime"
|
target = "runtime"
|
||||||
platforms = ["linux/amd64"]
|
|
||||||
contexts = {
|
contexts = {
|
||||||
build-all = "target:go-build-all"
|
build-all = "target:go-build-all"
|
||||||
}
|
}
|
||||||
@@ -127,15 +176,15 @@ target "_go-runtime" {
|
|||||||
}
|
}
|
||||||
|
|
||||||
target "_go-runtime-birdc" {
|
target "_go-runtime-birdc" {
|
||||||
|
inherits = ["_common", "_go-bases"]
|
||||||
context = "../.."
|
context = "../.."
|
||||||
dockerfile = "deploy/docker/gobinary/Dockerfile"
|
dockerfile = "deploy/docker/gobinary/Dockerfile"
|
||||||
target = "runtime-birdc"
|
target = "runtime-birdc"
|
||||||
platforms = ["linux/amd64"]
|
|
||||||
contexts = {
|
contexts = {
|
||||||
build-all = "target:go-build-all"
|
build-all = "target:go-build-all"
|
||||||
birdc = "target:go-birdc"
|
birdc = "target:go-birdc"
|
||||||
}
|
}
|
||||||
cache-from = go-cache-from()
|
cache-from = concat(go-cache-from(), birdc-cache-from())
|
||||||
}
|
}
|
||||||
|
|
||||||
function "image-tags" {
|
function "image-tags" {
|
||||||
@@ -187,43 +236,62 @@ target "evobgp-render" {
|
|||||||
}
|
}
|
||||||
|
|
||||||
target "evobgp-deploy" {
|
target "evobgp-deploy" {
|
||||||
inherits = ["_go-runtime"]
|
inherits = ["_go-runtime-birdc"]
|
||||||
args = { BIN = "evobgp-deploy" }
|
args = { BIN = "evobgp-deploy" }
|
||||||
tags = image-tags("evobgp-deploy")
|
tags = image-tags("evobgp-deploy")
|
||||||
}
|
}
|
||||||
|
|
||||||
target "evobgp-node" {
|
target "evobgp-node" {
|
||||||
inherits = ["_go-runtime"]
|
inherits = ["_go-runtime-birdc"]
|
||||||
args = { BIN = "evobgp-node" }
|
args = { BIN = "evobgp-node" }
|
||||||
tags = image-tags("evobgp-node")
|
tags = image-tags("evobgp-node")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
target "evobgp-bird2-base" {
|
||||||
|
inherits = ["_common"]
|
||||||
|
context = "../.."
|
||||||
|
dockerfile = "deploy/docker/bird2/Dockerfile"
|
||||||
|
target = "bird2-base"
|
||||||
|
args = {
|
||||||
|
BASE_UBUNTU = BASE_UBUNTU
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
target "evobgp-agent" {
|
target "evobgp-agent" {
|
||||||
|
inherits = ["_common"]
|
||||||
context = "../.."
|
context = "../.."
|
||||||
dockerfile = "deploy/docker/evobgp-agent/Dockerfile"
|
dockerfile = "deploy/docker/evobgp-agent/Dockerfile"
|
||||||
platforms = ["linux/amd64"]
|
|
||||||
contexts = {
|
contexts = {
|
||||||
build-all = "target:go-build-all"
|
build-all = "target:go-build-all"
|
||||||
|
bird2-base = "target:evobgp-bird2-base"
|
||||||
}
|
}
|
||||||
cache-from = go-cache-from()
|
cache-from = go-cache-from()
|
||||||
tags = image-tags("evobgp-agent")
|
tags = image-tags("evobgp-agent")
|
||||||
}
|
}
|
||||||
|
|
||||||
# --- Web: npm ci (кэш) → build → nginx ---
|
# --- Web ---
|
||||||
|
|
||||||
target "web-deps" {
|
target "web-deps" {
|
||||||
|
inherits = ["_common"]
|
||||||
context = "../.."
|
context = "../.."
|
||||||
dockerfile = "deploy/docker/evobgp-web/Dockerfile"
|
dockerfile = "deploy/docker/evobgp-web/Dockerfile"
|
||||||
target = "deps"
|
target = "deps"
|
||||||
platforms = ["linux/amd64"]
|
args = {
|
||||||
|
BASE_NODE = BASE_NODE
|
||||||
|
BASE_NGINX = BASE_NGINX
|
||||||
|
}
|
||||||
cache-from = web-cache-from()
|
cache-from = web-cache-from()
|
||||||
}
|
}
|
||||||
|
|
||||||
target "web-build" {
|
target "web-build" {
|
||||||
|
inherits = ["_common"]
|
||||||
context = "../.."
|
context = "../.."
|
||||||
dockerfile = "deploy/docker/evobgp-web/Dockerfile"
|
dockerfile = "deploy/docker/evobgp-web/Dockerfile"
|
||||||
target = "build"
|
target = "build"
|
||||||
platforms = ["linux/amd64"]
|
args = {
|
||||||
|
BASE_NODE = BASE_NODE
|
||||||
|
BASE_NGINX = BASE_NGINX
|
||||||
|
}
|
||||||
contexts = {
|
contexts = {
|
||||||
deps = "target:web-deps"
|
deps = "target:web-deps"
|
||||||
}
|
}
|
||||||
@@ -232,34 +300,49 @@ target "web-build" {
|
|||||||
}
|
}
|
||||||
|
|
||||||
target "evobgp-web" {
|
target "evobgp-web" {
|
||||||
|
inherits = ["_common"]
|
||||||
context = "../.."
|
context = "../.."
|
||||||
dockerfile = "deploy/docker/evobgp-web/Dockerfile"
|
dockerfile = "deploy/docker/evobgp-web/Dockerfile"
|
||||||
target = "web"
|
target = "web"
|
||||||
platforms = ["linux/amd64"]
|
|
||||||
contexts = {
|
contexts = {
|
||||||
web-artifacts = "target:web-build"
|
web-artifacts = "target:web-build"
|
||||||
}
|
}
|
||||||
args = { EVOBGP_UPSTREAM = "evobgp-api" }
|
args = {
|
||||||
|
EVOBGP_UPSTREAM = "evobgp-api"
|
||||||
|
BASE_NODE = BASE_NODE
|
||||||
|
BASE_NGINX = BASE_NGINX
|
||||||
|
}
|
||||||
cache-from = web-cache-from()
|
cache-from = web-cache-from()
|
||||||
tags = image-tags("evobgp-web")
|
tags = image-tags("evobgp-web")
|
||||||
}
|
}
|
||||||
|
|
||||||
target "evobgp-web-all" {
|
target "evobgp-web-all" {
|
||||||
|
inherits = ["_common"]
|
||||||
context = "../.."
|
context = "../.."
|
||||||
dockerfile = "deploy/docker/evobgp-web/Dockerfile"
|
dockerfile = "deploy/docker/evobgp-web/Dockerfile"
|
||||||
target = "web"
|
target = "web"
|
||||||
platforms = ["linux/amd64"]
|
|
||||||
contexts = {
|
contexts = {
|
||||||
web-artifacts = "target:web-build"
|
web-artifacts = "target:web-build"
|
||||||
}
|
}
|
||||||
args = { EVOBGP_UPSTREAM = "evobgp-all" }
|
args = {
|
||||||
|
EVOBGP_UPSTREAM = "evobgp-all"
|
||||||
|
BASE_NODE = BASE_NODE
|
||||||
|
BASE_NGINX = BASE_NGINX
|
||||||
|
}
|
||||||
cache-from = web-cache-from()
|
cache-from = web-cache-from()
|
||||||
tags = image-tags("evobgp-web-all")
|
tags = image-tags("evobgp-web-all")
|
||||||
}
|
}
|
||||||
|
|
||||||
target "evobgp-bird2" {
|
target "evobgp-bird2" {
|
||||||
|
inherits = ["_common"]
|
||||||
context = "../.."
|
context = "../.."
|
||||||
dockerfile = "deploy/docker/bird2/Dockerfile"
|
dockerfile = "deploy/docker/bird2/Dockerfile"
|
||||||
platforms = ["linux/amd64"]
|
target = "bird2"
|
||||||
tags = image-tags("evobgp-bird2")
|
args = {
|
||||||
|
BASE_UBUNTU = BASE_UBUNTU
|
||||||
|
}
|
||||||
|
contexts = {
|
||||||
|
bird2-base = "target:evobgp-bird2-base"
|
||||||
|
}
|
||||||
|
tags = image-tags("evobgp-bird2")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user