Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
880d77810a | ||
|
|
8ebce28e34 | ||
|
|
dd7d43c2c2 | ||
|
|
a8c5e9701f | ||
|
|
be3d73f374 | ||
|
|
5fca165c69 | ||
|
|
293115e0e1 | ||
|
|
6d2051f813 | ||
|
|
b51a9ae3b3 | ||
|
|
4d4cd2301f | ||
|
|
d687881eaa | ||
|
|
87e756f34f | ||
|
|
27864fad58 | ||
|
|
dc5e777d07 | ||
|
|
23910c3393 | ||
|
|
0e90bbee4e | ||
|
|
55bc87cbc4 | ||
|
|
e68f034966 | ||
|
|
e06ee880b2 | ||
|
|
33fe8fdd18 | ||
|
|
6fa693156d | ||
|
|
5858d0889e | ||
|
|
bfe20c1fe0 | ||
|
|
c66cc9317d | ||
|
|
9ea4a68ccf | ||
|
|
8204105fd6 |
@@ -0,0 +1,37 @@
|
||||
# Сгенерировать commit message (EvoBGP)
|
||||
|
||||
Сгенерируй сообщение коммита для **текущих staged-изменений**. Не выполняй `git commit`, если пользователь явно не просил закоммитить.
|
||||
|
||||
## Обязательный workflow (MUST)
|
||||
|
||||
1. Прочитай скилл [`.cursor/skills/commit-message/SKILL.md`](../skills/commit-message/SKILL.md).
|
||||
2. Следуй правилу [`.cursor/rules/conventional-commits.mdc`](../rules/conventional-commits.mdc).
|
||||
3. **Первым вызовом Shell** из корня репозитория:
|
||||
|
||||
```powershell
|
||||
powershell -NoProfile -File scripts/commit/staged-context.ps1
|
||||
```
|
||||
|
||||
4. Строй текст **только** по JSON stdout (`groups`, `stat`, `diff_excerpt`). Exit `1` → index пуст, сообщи пользователю.
|
||||
5. **Запрещено** обходить скрипт через один `git diff --cached`.
|
||||
|
||||
## Формат вывода
|
||||
|
||||
Для каждого коммита (при auto-split — по одному блоку):
|
||||
|
||||
```
|
||||
<type>(<scope>): <summary in English>
|
||||
|
||||
<тело на русском>
|
||||
```
|
||||
|
||||
Плюс пояснение (RU): semver impact (`minor`|`patch`|`none`|`major`), почему выбран type, был ли split.
|
||||
|
||||
## Semver (кратко)
|
||||
|
||||
- Новая пользовательская возможность → `feat` (minor)
|
||||
- Починка ожидаемого поведения / баг → `fix` (patch)
|
||||
- Follow-up баги после недавнего `feat` в том же scope → **`fix`**, не `feat`
|
||||
- Только перестройка без нового поведения → `refactor` (patch, без новых функций)
|
||||
|
||||
Заголовок — EN, императив, ≤72 символов. Тело — RU.
|
||||
@@ -11,7 +11,13 @@ alwaysApply: false
|
||||
|
||||
## Триггеры (применить правило + скилл)
|
||||
|
||||
Любой запрос на коммит или сообщение коммита: `commit`, `коммит`, `закоммить`, `git commit`, `commit message`, `conventional commit`, `staged`, «сгенерируй коммит» — в т.ч. если это указано в плане или [AGENTS.md](../../AGENTS.md).
|
||||
Любой запрос на коммит или сообщение коммита: `commit`, `коммит`, `закоммить`, `git commit`, `commit message`, `conventional commit`, `staged`, «сгенерируй коммит», **`/commit-message`** — в т.ч. если это указано в плане или [AGENTS.md](../../AGENTS.md).
|
||||
|
||||
### Не путать с кнопкой ✨ в Source Control
|
||||
|
||||
Команда **`cursor.generateGitCommitMessage`** (sparkle в поле commit message) **не** читает Rules, Skills и `staged-context.ps1` — только staged diff и история коммитов ([ограничение Cursor](https://forum.cursor.com/t/how-to-set-prompt-for-generate-commit-message/148606)).
|
||||
|
||||
**Замена для EvoBGP:** Agent → `/commit-message` или команда [`.cursor/commands/commit-message.md`](../commands/commit-message.md).
|
||||
|
||||
## Обязательный запуск скрипта (MUST)
|
||||
|
||||
@@ -41,15 +47,74 @@ powershell -NoProfile -File scripts/commit/staged-context.ps1
|
||||
|
||||
| type | Когда | Версия |
|
||||
|------|--------|--------|
|
||||
| `feat` | новая функциональность | minor |
|
||||
| `fix` | исправление бага | patch |
|
||||
| `feat` | **новая** пользовательская возможность (раньше нельзя было) | minor |
|
||||
| `fix` | восстановление **ожидаемого** поведения; баг, регрессия, падение UI | patch |
|
||||
| `perf` | ускорение без смены API | patch |
|
||||
| `refactor` | реструктуризация без смены поведения | — |
|
||||
| `refactor` | реструктуризация **без** новой возможности и **без** исправления бага | patch |
|
||||
| `docs` | только документация | — |
|
||||
| `test` | тесты | — |
|
||||
| `ci` | CI/CD (`.gitea/`, workflows) | — |
|
||||
| `ci` | CI/CD (`.gitea/`, workflows); правки, из‑за которых нужны новые образы | patch |
|
||||
| `chore` | обслуживание, deps, `.cursor/` | — |
|
||||
|
||||
### Выбор type: semver, а не «красивые слова»
|
||||
|
||||
**Главный вопрос:** что изменится для пользователя после релиза?
|
||||
|
||||
1. Появилось **новое** действие / экран / API / настройка, которых не было → `feat`
|
||||
2. То, что **должно было работать**, не работало (кнопки, диалоги, сохранение, 500) → `fix`
|
||||
3. Только перестройка кода или UI на другой паттерн, поведение для пользователя то же → `refactor` (patch, без новых функций)
|
||||
4. Ускорение без изменения контракта → `perf`
|
||||
|
||||
**Не путать с формулировкой diff:**
|
||||
|
||||
| В diff / задаче часто пишут | Неверный type | Верный type, если… |
|
||||
|-----------------------------|---------------|---------------------|
|
||||
| enhance, improve, polish UI | `feat` | …только чиним сломанное после прошлого PR → `fix` |
|
||||
| refactor pages, unify tables | `feat` | …новой возможности нет, лишь перенос на AppDataTable → `refactor` |
|
||||
| follow-up после feat(web) | `feat` | …исправляем баги того же экрана → `fix` |
|
||||
|
||||
**Follow-up rule:** коммит сразу после `feat` в той же области, который **не добавляет** новую возможность, а устраняет дефект (effect loop, не открывается dialog, confirm не срабатывает) — **`fix`**, не `feat`.
|
||||
|
||||
**Split при смешанном diff:** новая страница/flow → `feat`; отдельным коммитом правки багов → `fix`. Не объединять в один `feat`.
|
||||
|
||||
**Breaking changes** — только `feat!` / `fix!` / `BREAKING CHANGE:` когда пользователь **обязан** менять конфиг, API или привычный workflow.
|
||||
|
||||
### Обязательно в пояснении агенту
|
||||
|
||||
При каждом предложении коммита указать:
|
||||
|
||||
- **Semver impact:** `minor` | `patch` | `none` | `major`
|
||||
- **Почему не другой type** (одно предложение), если diff большой или формулировка двусмысленная
|
||||
|
||||
Пример неправильно / правильно:
|
||||
|
||||
```
|
||||
# Плохо — patch-фикс, minor-bump
|
||||
feat(web): enhance module entry dialogs and selection handling
|
||||
|
||||
# Хорошо
|
||||
fix(web): stop effect loop breaking module action buttons
|
||||
|
||||
Исправлен effect_update_depth_exceeded и bind:open у Dialog; кнопки редактирования/удаления снова работают.
|
||||
```
|
||||
|
||||
```
|
||||
# Плохо — рефакторинг без новой фичи
|
||||
feat(web): migrate modules list to AppDataTable
|
||||
|
||||
# Хорошо — если не было нового user-facing
|
||||
refactor(web): migrate modules list to AppDataTable
|
||||
|
||||
Единый паттерн таблиц; поведение списка модулей без изменений. Semver: patch.
|
||||
```
|
||||
|
||||
```
|
||||
# Хорошо feat — действительно новое
|
||||
feat(web): add module create dialog on /modules
|
||||
|
||||
Диалог создания модуля с POST /v1/modules; раньше создание было только через API.
|
||||
```
|
||||
|
||||
## Breaking changes
|
||||
|
||||
- Заголовок: `feat!` / `fix!` **или** в теле строка `BREAKING CHANGE:` (на английском ключевое слово) + описание impact **на русском**.
|
||||
@@ -96,7 +161,7 @@ powershell -NoProfile -File scripts/commit/staged-context.ps1
|
||||
<тело RU>
|
||||
```
|
||||
|
||||
**2. Пояснение (RU):** почему выбран type; риск/impact; был ли split.
|
||||
**2. Пояснение (RU):** semver impact (`minor`|`patch`|`none`|`major`); почему выбран type; риск/impact; был ли split.
|
||||
|
||||
## Примеры
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ alwaysApply: true
|
||||
|
||||
**ARCH-01** | MUST | Новая persistence-логика — метод `store.Backend` + реализации в `repository` и `store.Memory`; SQL не в `httpapi`.
|
||||
*Rationale:* единая абстракция данных.
|
||||
*Проверка:* grep SQL в `internal/httpapi` — отсутствие; review.
|
||||
*Проверка:* CI `scripts/lint-httpapi.sh`; grep SQL в `internal/httpapi` — отсутствие.
|
||||
|
||||
**ARCH-02** | MUST | HTTP-маршруты только в `internal/httpapi`; регистрация через `http.ServeMux` с паттернами `METHOD /v1/...`.
|
||||
*Rationale:* один слой REST.
|
||||
@@ -75,7 +75,7 @@ alwaysApply: true
|
||||
|
||||
**STYLE-05** | MUST | HTTP-ошибки — `writeProblem` / `writeJSON` (`application/problem+json` для 4xx/5xx).
|
||||
*Rationale:* RFC 9457, OpenAPI.
|
||||
*Проверка:* `problem.go`.
|
||||
*Проверка:* `problem.go`; CI `scripts/lint-httpapi.sh` (5xx и 4xx store/cdn/csv).
|
||||
|
||||
**STYLE-06** | MUST | JSON полей HTTP DTO согласованы с `docs/openapi.yaml`.
|
||||
*Rationale:* контракт API.
|
||||
@@ -123,7 +123,7 @@ alwaysApply: true
|
||||
**TEST-03** | MUST | Новые BIRD-сценарии в `internal/birdfmt/testdata/scenarios/*/bird.conf` + `bird -p`.
|
||||
*Проверка:* CI job `bird2`.
|
||||
|
||||
**TEST-04** | MUST | Изменения `web/` — локально `npm run check` и `npm run lint` (CI web пока не в scope).
|
||||
**TEST-04** | MUST | Изменения `web/` — локально `npm run check` и `npm run lint`; CI job `web` в `.gitea/workflows/ci.yaml`.
|
||||
*Проверка:* локальные команды.
|
||||
|
||||
**TEST-05** | MUST | Изменения OpenAPI — `npx @redocly/cli lint docs/openapi.yaml`.
|
||||
@@ -234,7 +234,9 @@ npx @redocly/cli lint docs/openapi.yaml
|
||||
# birdfmt: go test ./internal/birdfmt/... -count=1
|
||||
```
|
||||
|
||||
**Рекомендуется (частично внедрено):** CI job `web` (Gitea); `scripts/lint-httpapi.sh` в job `go`; `.golangci.yml` (локально); pre-commit gofmt/prettier.
|
||||
**CI (Gitea):** job `web` (check + lint); job `go`: `go vet`, `scripts/lint-httpapi.sh` (ARCH-01, ERR-01), `scripts/check-migrations-pair.sh` (DEP-03), `golangci-lint`, `go test -race`, build `cmd/*`.
|
||||
|
||||
**Рекомендуется локально:** `.golangci.yml`; `.pre-commit-config.yaml` (gofmt + prettier web).
|
||||
|
||||
**Только code review:** слои SQL; роли; idempotency; OpenAPI bodies; secrets в compose.
|
||||
|
||||
|
||||
@@ -2,10 +2,14 @@
|
||||
name: commit-message
|
||||
description: >-
|
||||
ОБЯЗАТЕЛЬНО при commit, коммит, закоммить, commit message, conventional commit,
|
||||
staged, semantic-release, «сгенерируй коммит», git commit: ПЕРВЫМ делом Shell —
|
||||
scripts/commit/staged-context.ps1; затем Conventional Commit (заголовок EN, тело RU).
|
||||
staged, semantic-release, «сгенерируй коммит», git commit, /commit-message: ПЕРВЫМ
|
||||
делом Shell — scripts/commit/staged-context.ps1; затем Conventional Commit (заголовок EN, тело RU).
|
||||
---
|
||||
|
||||
> **Кнопка ✨ Generate commit message в Source Control** не использует этот скилл и Rules.
|
||||
> Эквивалент: Agent Chat → **`/commit-message`** или «сгенерируй коммит по staged».
|
||||
> См. [docs/README.md](../../docs/README.md#сообщения-коммитов-cursor).
|
||||
|
||||
# Commit message (EvoBGP)
|
||||
|
||||
## Когда применять (сразу читать этот скилл)
|
||||
@@ -90,9 +94,24 @@ git commit -m "$( @'
|
||||
|
||||
По `groups[].diff_excerpt`, `stat`, `files`:
|
||||
|
||||
- **type** — по смыслу diff (`feat` / `fix` / …), не по умолчанию `chore`.
|
||||
### Шаг A — semver (до выбора type)
|
||||
|
||||
| Вопрос | Если «да» → |
|
||||
|--------|-------------|
|
||||
| Пользователь получает **новую** возможность? | `feat` (minor) |
|
||||
| Восстанавливается **ожидаемое** поведение / устранён баг? | `fix` (patch) |
|
||||
| Только скорость, контракт тот же? | `perf` (patch) |
|
||||
| Только структура кода/UI, поведение то же? | `refactor` (patch) |
|
||||
|
||||
**Follow-up:** правки сразу после `feat` в том же scope без новой возможности → **`fix`**, не `feat` (слова *enhance/improve/refactor* в задаче не делают commit `feat`).
|
||||
|
||||
**Запрещено** по умолчанию ставить `feat` для «большого diff» в `web/` — type по **semver impact**, не по объёму.
|
||||
|
||||
### Шаг B — type, scope, текст
|
||||
|
||||
- **type** — результат шага A, не «chore по умолчанию» и не `feat` из-за слова enhance.
|
||||
- **scope** — из JSON группы или доминирующий при merge.
|
||||
- **summary** — конкретный, английский, императив.
|
||||
- **summary** — конкретный, английский, императив; для `fix` — что **починено** (`fix broken …`, `prevent … loop`).
|
||||
- **body** — русский: что, зачем, edge cases, breaking impact.
|
||||
|
||||
## Вывод пользователю
|
||||
@@ -109,7 +128,7 @@ git commit -m "$( @'
|
||||
|
||||
### 2. Пояснение (RU)
|
||||
|
||||
- Почему выбран type/scope.
|
||||
- **Semver impact:** `minor` | `patch` | `none` | `major` — и почему не другой type.
|
||||
- Риски и impact.
|
||||
- Split: сколько коммитов и почему.
|
||||
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@ Workflow: [workflows/ci.yaml](workflows/ci.yaml).
|
||||
|
||||
## CI (quality gates)
|
||||
|
||||
Job **changes** вычисляет флаги по путям в diff. Изменение `.gitea/workflows/*` поднимает полный прогон.
|
||||
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`).
|
||||
|
||||
На **pull request** — **commitlint** (Conventional Commits).
|
||||
|
||||
|
||||
+84
-37
@@ -8,10 +8,9 @@ on:
|
||||
|
||||
jobs:
|
||||
# ---------------------------------------------------------------------------
|
||||
# Гранулярная детекция изменений по модулям.
|
||||
# Каждый флаг соответствует группе файлов; downstream-джобы запускаются
|
||||
# только когда их группа затронута. Изменение CI-конфигурации (.gitea/workflows/*)
|
||||
# поднимает все флаги, чтобы гарантировать полный прогон.
|
||||
# Детекция изменений по модулям (флаги → downstream-джобы в графе CI).
|
||||
# Полный прогон (все флаги true): .gitea/workflows/*, scripts/*, .golangci.yml,
|
||||
# .pre-commit-config.yaml — чтобы при правках CI/CD пересобирались все узлы.
|
||||
# ---------------------------------------------------------------------------
|
||||
changes:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -40,6 +39,23 @@ jobs:
|
||||
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 }}"
|
||||
@@ -52,61 +68,84 @@ jobs:
|
||||
elif git rev-parse --verify HEAD~1 >/dev/null 2>&1; then
|
||||
FILES="$(git diff --name-only HEAD~1 HEAD)"
|
||||
else
|
||||
openapi=true; go=true; web=true; bird_conf=true
|
||||
docker_go=true; docker_web=true; docker_bird=true
|
||||
for v in openapi go web bird_conf docker_go docker_web docker_bird; do
|
||||
echo "$v=true" >> "$GITHUB_OUTPUT"
|
||||
done
|
||||
echo "No parent commit — full pipeline"
|
||||
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
|
||||
go=true; web=true
|
||||
for v in openapi go web bird_conf docker_go docker_web docker_bird; do
|
||||
eval "echo \"\$v=\$$v\"" >> "$GITHUB_OUTPUT"
|
||||
done
|
||||
echo "Empty diff — safe fallback: go=true web=true"
|
||||
set_all_flags_true
|
||||
write_outputs
|
||||
echo "Empty diff — full pipeline fallback"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
ci_changed=false
|
||||
full_pipeline=false
|
||||
|
||||
while IFS= read -r f || [ -n "${f:-}" ]; do
|
||||
[ -z "${f:-}" ] && continue
|
||||
case "$f" in
|
||||
.gitea/workflows/*) ci_changed=true ;;
|
||||
docs/openapi.yaml|redocly.yaml) openapi=true ;;
|
||||
web/README.md) ;; # doc-only
|
||||
web/*) web=true ;;
|
||||
deploy/bird/*) bird_conf=true ;;
|
||||
deploy/docker/bird/*) docker_bird=true; docker_go=true ;;
|
||||
deploy/docker/gobinary/*) docker_go=true ;;
|
||||
deploy/docker/docker-bake.hcl) docker_go=true; docker_web=true ;;
|
||||
deploy/docker/evobgp-agent/*) docker_go=true ;;
|
||||
deploy/docker/evobgp-web/*) docker_web=true ;;
|
||||
deploy/docker/bird2/*) docker_bird=true ;;
|
||||
go.mod|go.sum|go.work) go=true ;;
|
||||
*.go) go=true ;;
|
||||
cmd/*|internal/*) go=true ;;
|
||||
# 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
|
||||
;;
|
||||
web/README.md|web/components.json)
|
||||
;;
|
||||
web/*)
|
||||
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|.releaserc.json)
|
||||
full_pipeline=true
|
||||
;;
|
||||
*)
|
||||
go=true
|
||||
;;
|
||||
esac
|
||||
done <<< "$FILES"
|
||||
|
||||
if $ci_changed; then
|
||||
go=true; web=true; bird_conf=true
|
||||
docker_go=true; docker_web=true; docker_bird=true
|
||||
if $full_pipeline; then
|
||||
set_all_flags_true
|
||||
fi
|
||||
|
||||
for v in openapi go web bird_conf docker_go docker_web docker_bird; do
|
||||
eval "echo \"\$v=\$$v\"" >> "$GITHUB_OUTPUT"
|
||||
done
|
||||
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 ci=$ci_changed"
|
||||
echo "docker_go=$docker_go docker_web=$docker_web docker_bird=$docker_bird full_pipeline=$full_pipeline"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
openapi:
|
||||
@@ -157,6 +196,14 @@ jobs:
|
||||
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
|
||||
# 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
|
||||
|
||||
@@ -2,6 +2,7 @@ run:
|
||||
timeout: 5m
|
||||
|
||||
linters:
|
||||
disable-all: true
|
||||
enable:
|
||||
- gofmt
|
||||
- govet
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
# Local hooks (optional): install with `pre-commit install`
|
||||
repos:
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v5.0.0
|
||||
hooks:
|
||||
- id: trailing-whitespace
|
||||
- id: end-of-file-fixer
|
||||
- repo: https://github.com/dnephin/pre-commit-golang
|
||||
rev: v0.5.1
|
||||
hooks:
|
||||
- id: go-fmt
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: prettier-web
|
||||
name: prettier (web)
|
||||
entry: bash -c 'cd web && npx prettier --check .'
|
||||
language: system
|
||||
files: ^web/
|
||||
pass_filenames: false
|
||||
@@ -10,6 +10,8 @@
|
||||
{ "type": "feat", "release": "minor" },
|
||||
{ "type": "fix", "release": "patch" },
|
||||
{ "type": "perf", "release": "patch" },
|
||||
{ "type": "ci", "release": "patch" },
|
||||
{ "type": "refactor", "release": "patch" },
|
||||
{ "breaking": true, "release": "major" }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -38,13 +38,15 @@
|
||||
|
||||
## Коммиты (Conventional Commits)
|
||||
|
||||
Если пользователь просит **коммит**, **commit message**, **закоммить**, **git commit** или это следует из плана — **сразу**:
|
||||
Если пользователь просит **коммит**, **commit message**, **закоммить**, **git commit**, **`/commit-message`** или это следует из плана — **сразу**:
|
||||
|
||||
1. Shell: `powershell -NoProfile -File scripts/commit/staged-context.ps1` (первый вызов, до текста коммита).
|
||||
2. Скилл [.cursor/skills/commit-message/SKILL.md](.cursor/skills/commit-message/SKILL.md) и правило [.cursor/rules/conventional-commits.mdc](.cursor/rules/conventional-commits.mdc).
|
||||
|
||||
Без вывода скрипта (exit 0) **не** придумывать сообщение коммита. Заголовок — EN, тело — RU; несвязанные области — auto-split (скилл).
|
||||
|
||||
**Кнопка ✨ Generate commit message в Source Control** skill/rule **не** использует. Для сообщений по правилам EvoBGP — Agent Chat → **`/commit-message`** (см. [.cursor/commands/commit-message.md](.cursor/commands/commit-message.md)).
|
||||
|
||||
## Команды и среда
|
||||
|
||||
- Консоль пользователя: **PowerShell**; пути в стиле `deploy\compose`.
|
||||
|
||||
@@ -55,8 +55,12 @@ func main() {
|
||||
startBirdMetricsPoller(ctx)
|
||||
|
||||
httpSrv := &http.Server{
|
||||
Addr: cfg.HTTPAddr,
|
||||
Handler: srv.Handler(),
|
||||
Addr: cfg.HTTPAddr,
|
||||
Handler: srv.Handler(),
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
ReadTimeout: 60 * time.Second,
|
||||
WriteTimeout: 120 * time.Second,
|
||||
IdleTimeout: 120 * time.Second,
|
||||
}
|
||||
go func() {
|
||||
svc := platform.ServiceName("evobgp-all")
|
||||
@@ -91,6 +95,7 @@ func startBirdMetricsPoller(ctx context.Context) {
|
||||
return birdfmt.ShowProtocols(ctx, socket, birdcBin)
|
||||
},
|
||||
birdfmt.CountEstablishedBGPSessions,
|
||||
birdfmt.ParseBGPProtocolStates,
|
||||
)
|
||||
log.Printf("birdc protocols poller enabled (socket=%s interval=%s)", sock, interval)
|
||||
}
|
||||
|
||||
@@ -42,8 +42,12 @@ func main() {
|
||||
startBirdMetricsPoller(ctx)
|
||||
|
||||
httpSrv := &http.Server{
|
||||
Addr: cfg.HTTPAddr,
|
||||
Handler: srv.Handler(),
|
||||
Addr: cfg.HTTPAddr,
|
||||
Handler: srv.Handler(),
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
ReadTimeout: 60 * time.Second,
|
||||
WriteTimeout: 120 * time.Second,
|
||||
IdleTimeout: 120 * time.Second,
|
||||
}
|
||||
go func() {
|
||||
svc := platform.ServiceName("evobgp-api")
|
||||
@@ -84,6 +88,7 @@ func startBirdMetricsPoller(ctx context.Context) {
|
||||
return birdfmt.ShowProtocols(ctx, socket, birdcBin)
|
||||
},
|
||||
birdfmt.CountEstablishedBGPSessions,
|
||||
birdfmt.ParseBGPProtocolStates,
|
||||
)
|
||||
log.Printf("birdc protocols poller enabled (socket=%s interval=%s)", sock, interval)
|
||||
}
|
||||
|
||||
@@ -96,7 +96,7 @@ services:
|
||||
<<: *env-ref
|
||||
EVOBGP_HTTP_ADDR: ":8080"
|
||||
EVOBGP_SEED_DEMO: "1"
|
||||
# Local reference only: allows Bearer dev for scheduler HTTP client (EVOBGP_SCHEDULER_BEARER).
|
||||
# DEV ONLY — не для production. Bearer dev + слабые demo-секреты (см. docs/access.md).
|
||||
EVOBGP_DEV_INSECURE: "1"
|
||||
EVOBGP_BIRDC_SOCKET: /run/bird/bird.ctl
|
||||
EVOBGP_BIRDC_INTERVAL: 30s
|
||||
|
||||
@@ -135,6 +135,7 @@ services:
|
||||
EVOBGP_BIRDC_INTERVAL: 30s
|
||||
EVOBGP_BIRD_ACTIVE_DIR: /etc/bird
|
||||
EVOBGP_BIRD_STAGING_DIR: /tmp/evobgp-bird-staging
|
||||
# DEV ONLY — не для production (см. docs/access.md).
|
||||
EVOBGP_DEV_INSECURE: "1"
|
||||
volumes:
|
||||
- bird_etc:/etc/bird
|
||||
|
||||
@@ -5,8 +5,14 @@ server {
|
||||
gzip on;
|
||||
gzip_types text/css application/javascript application/json image/svg+xml;
|
||||
|
||||
# Docker embedded DNS: без resolver nginx кэширует IP upstream при старте —
|
||||
# после recreate evobgp-all остаётся 502 (connection refused на старый IP).
|
||||
resolver 127.0.0.11 valid=10s ipv6=off;
|
||||
set $evobgp_upstream evobgp-api;
|
||||
|
||||
location /v1/ {
|
||||
proxy_pass http://evobgp-api:8080/v1/;
|
||||
# С переменной в proxy_pass нельзя полагаться на замену URI — передаём $request_uri целиком.
|
||||
proxy_pass http://$evobgp_upstream:8080$request_uri;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
@@ -15,7 +21,7 @@ server {
|
||||
}
|
||||
|
||||
location = /metrics {
|
||||
proxy_pass http://evobgp-api:8080/metrics;
|
||||
proxy_pass http://$evobgp_upstream:8080/metrics;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
|
||||
+10
-1
@@ -36,7 +36,16 @@
|
||||
|
||||
### Сообщения коммитов (Cursor)
|
||||
|
||||
После `git add` попросите агента: **«сгенерируй коммит по staged»**, **«закоммить»**, **«commit message»** — агент **обязан первым делом** запустить `scripts/commit/staged-context.ps1`, затем скилл [commit-message](../.cursor/skills/commit-message/SKILL.md) (заголовок EN, тело RU, auto-split). Просмотр групп вручную: `powershell -NoProfile -File scripts/commit/staged-context.ps1 | ConvertFrom-Json`.
|
||||
После `git add`:
|
||||
|
||||
| Способ | Skill + rule + `staged-context.ps1` |
|
||||
|--------|-------------------------------------|
|
||||
| Agent → **`/commit-message`** или «сгенерируй коммит по staged» | **Да** |
|
||||
| Кнопка **✨ Generate commit message** в Source Control | **Нет** (только diff + история; [ограничение Cursor](https://forum.cursor.com/t/how-to-set-prompt-for-generate-commit-message/148606)) |
|
||||
|
||||
Рекомендуемый workflow: Agent Chat → **`/commit-message`** ([команда](../.cursor/commands/commit-message.md), [скилл](../.cursor/skills/commit-message/SKILL.md), [правило](../.cursor/rules/conventional-commits.mdc)). Агент **обязан первым делом** запустить `scripts/commit/staged-context.ps1` (заголовок EN, тело RU, auto-split).
|
||||
|
||||
Просмотр групп вручную: `powershell -NoProfile -File scripts/commit/staged-context.ps1 | ConvertFrom-Json`.
|
||||
|
||||
## Репозиторий и CI
|
||||
|
||||
|
||||
+6
-1
@@ -37,7 +37,12 @@ opkey|01ARZ3NDEKTSV4RRFFQ69G5FAV|operator,nodekey|01ARZ3NDEKTSV4RRFFQ69G5FAV|nod
|
||||
|
||||
Если установлено `EVOBGP_DEV_INSECURE=1` и в store доступен демо-tenant (`DemoIDs`), то запрос с заголовком **`Authorization: Bearer dev`** получает контекст **`operator`** для этого tenant.
|
||||
|
||||
**Запрещено** в продакшене: любой, кто знает заголовок, получает полные права оператора на демо-данные.
|
||||
**Запрещено** в продакшене: любой, кто знает заголовок, получает полные права оператора на демо-данные. В reference Compose (`deploy/compose/docker-compose.yaml`) флаг включён только для локальной разработки.
|
||||
|
||||
### Синхронные «тяжёлые» GET (control plane)
|
||||
|
||||
- `POST /v1/modules/{module_id}/cdn-sources/preview` — загрузка CDN в том же HTTP-запросе (лимит тела ~8 MiB, см. OpenAPI).
|
||||
- `GET /v1/bird/status` (если маршрут включён в деплое) — опрос локального `birdc`, таймаут сервера ~12 с.
|
||||
|
||||
### Детерминированный ключ подписи бандлов (тесты)
|
||||
|
||||
|
||||
+6
-2
@@ -67,7 +67,9 @@ components:
|
||||
required: false
|
||||
schema:
|
||||
$ref: "#/components/schemas/ResourceId"
|
||||
description: Явный tenant (только супер-роли). Без заголовка tenant определяется по ключу.
|
||||
description: >
|
||||
Явный tenant (только супер-роли). Без заголовка tenant определяется по API-ключу.
|
||||
**Реализация v1:** заголовок в Go handlers не обрабатывается; tenant только из Bearer-токена (см. docs/access.md).
|
||||
IdempotencyKey:
|
||||
name: Idempotency-Key
|
||||
in: header
|
||||
@@ -1811,7 +1813,9 @@ paths:
|
||||
tags: [Modules]
|
||||
summary: Предпросмотр префиксов из CDN-источника
|
||||
description: >
|
||||
Загружает URL, парсит как plaintext или json и возвращает список извлечённых префиксов (до 100 записей).
|
||||
Синхронный запрос: conditional GET к URL (до 8 MiB тела ответа), парсинг plaintext или JSON,
|
||||
возврат до 100 префиксов в `items` (полный счётчик в `total`). Выполняется в HTTP worker;
|
||||
при таймауте клиента используйте короткий URL или меньший payload.
|
||||
operationId: previewCdnSource
|
||||
requestBody:
|
||||
required: true
|
||||
|
||||
+8
-2
@@ -7,12 +7,18 @@ EvoBGP использует [Conventional Commits](https://www.conventionalcommi
|
||||
| Тип коммита | Bump |
|
||||
|-------------|------|
|
||||
| `feat` | minor (1.0.0 → 1.1.0) |
|
||||
| `fix`, `perf` | patch (1.0.0 → 1.0.1) |
|
||||
| `fix`, `perf`, `ci`, `refactor` | patch (1.5.1 → 1.5.2) |
|
||||
| `feat!`, `fix!` или `BREAKING CHANGE:` в теле | major (1.0.0 → 2.0.0) |
|
||||
| `docs`, `chore`, `ci`, `test`, `refactor` | без релиза |
|
||||
| `docs`, `chore`, `test` | без релиза |
|
||||
|
||||
`refactor` — patch без новых функций: перестройка кода/UI при том же поведении для пользователя. По semver на одном уровне с `fix`, но семантически «мельче» `feat` (не minor).
|
||||
|
||||
Отдельного суффикса `1.x.y.fix` в semver нет: «fix» в Conventional Commits означает **patch** (третья цифра). Для починки пайплайна без смены продукта — `fix(ci):` или `ci:` (оба дают patch после настройки `.releaserc.json`).
|
||||
|
||||
Первый релиз при отсутствии git-тегов — **1.0.0**, если есть releasable-коммиты.
|
||||
|
||||
**Как не перепутать `feat` и `fix`:** см. раздел «Выбор type: semver, а не «красивые слова»» в [.cursor/rules/conventional-commits.mdc](../.cursor/rules/conventional-commits.mdc). Кратко: `feat` — новая возможность (minor); `fix` — починка ожидаемого поведения (patch); follow-up баги после недавнего `feat` — всегда `fix`, даже если diff большой.
|
||||
|
||||
Подробные правила сообщений коммитов: [.cursor/rules/conventional-commits.mdc](../.cursor/rules/conventional-commits.mdc).
|
||||
|
||||
## CI-пайплайн (один push в main)
|
||||
|
||||
@@ -42,7 +42,7 @@ func AnnouncedPrefixes(ctx context.Context, hc *http.Client, asn int64) ([]netip
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ripestat fetch AS%d: %w", asn, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 32<<20))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -52,8 +52,8 @@ func AnnouncedPrefixes(ctx context.Context, hc *http.Client, asn int64) ([]netip
|
||||
}
|
||||
|
||||
var wrap struct {
|
||||
Status string `json:"status"`
|
||||
Data struct {
|
||||
Status string `json:"status"`
|
||||
Data struct {
|
||||
Prefixes []struct {
|
||||
Prefix string `json:"prefix"`
|
||||
} `json:"prefixes"`
|
||||
@@ -104,7 +104,7 @@ func ASHolderName(ctx context.Context, hc *http.Client, asn int64) (string, erro
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("ripestat as-overview AS%d: %w", asn, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
|
||||
if err != nil {
|
||||
return "", err
|
||||
|
||||
@@ -60,10 +60,10 @@ func RenderBGPTemplates(opts BGPTemplatesOptions) (string, error) {
|
||||
|
||||
// BGPPeerFromTemplateOptions describes protocol bgp NAME from TEMPLATE { … }.
|
||||
type BGPPeerFromTemplateOptions struct {
|
||||
ProtocolName string
|
||||
TemplateName string
|
||||
NeighborIP string
|
||||
NeighborASN uint32
|
||||
ProtocolName string
|
||||
TemplateName string
|
||||
NeighborIP string
|
||||
NeighborASN uint32
|
||||
// If set, emits "local … as …" before neighbor (overrides template local/ASN for this peer).
|
||||
OverrideLocalIP string
|
||||
OverrideLocalASN uint32
|
||||
|
||||
@@ -9,6 +9,21 @@ import (
|
||||
|
||||
const maxBGPASN = 4294967295
|
||||
|
||||
const filterPrefixChunkSize = 500
|
||||
|
||||
func writePrefixSetAcceptBlocks(b *strings.Builder, keys []string) {
|
||||
for i := 0; i < len(keys); i += filterPrefixChunkSize {
|
||||
end := i + filterPrefixChunkSize
|
||||
if end > len(keys) {
|
||||
end = len(keys)
|
||||
}
|
||||
chunk := keys[i:end]
|
||||
b.WriteString(" if net ~ [ ")
|
||||
b.WriteString(strings.Join(chunk, ", "))
|
||||
b.WriteString(" ] then accept;\n")
|
||||
}
|
||||
}
|
||||
|
||||
func filterUniqueASNs(pathASNs []int64) []int64 {
|
||||
seen := make(map[int64]struct{})
|
||||
for _, a := range pathASNs {
|
||||
@@ -52,9 +67,7 @@ func RenderExportFilterIPv4(filterName string, prefixes []netip.Prefix, pathASNs
|
||||
b.WriteString(strings.TrimSpace(filterName))
|
||||
b.WriteString(" {\n")
|
||||
if len(keys) > 0 {
|
||||
b.WriteString(" if net ~ [ ")
|
||||
b.WriteString(strings.Join(keys, ", "))
|
||||
b.WriteString(" ] then accept;\n")
|
||||
writePrefixSetAcceptBlocks(&b, keys)
|
||||
}
|
||||
for _, asn := range asns {
|
||||
fmt.Fprintf(&b, " if bgp_path ~ [= * %d =] then accept;\n", asn)
|
||||
@@ -95,9 +108,7 @@ func RenderExportFilterIPv6(filterName string, prefixes []netip.Prefix, pathASNs
|
||||
b.WriteString(strings.TrimSpace(filterName))
|
||||
b.WriteString(" {\n")
|
||||
if len(keys) > 0 {
|
||||
b.WriteString(" if net ~ [ ")
|
||||
b.WriteString(strings.Join(keys, ", "))
|
||||
b.WriteString(" ] then accept;\n")
|
||||
writePrefixSetAcceptBlocks(&b, keys)
|
||||
}
|
||||
for _, asn := range asns {
|
||||
fmt.Fprintf(&b, " if bgp_path ~ [= * %d =] then accept;\n", asn)
|
||||
|
||||
@@ -50,3 +50,48 @@ func CountEstablishedBGPSessions(showProtocolsOutput string) int {
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// ParseBGPProtocolStates parses `birdc show protocols all` summary rows into protocol_name -> state.
|
||||
func ParseBGPProtocolStates(output string) map[string]string {
|
||||
out := make(map[string]string)
|
||||
for _, raw := range strings.Split(output, "\n") {
|
||||
line := strings.TrimSpace(raw)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
low := strings.ToLower(line)
|
||||
if strings.HasPrefix(low, "bird ") || strings.HasPrefix(low, "name ") || strings.HasPrefix(low, "table ") {
|
||||
continue
|
||||
}
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 4 {
|
||||
continue
|
||||
}
|
||||
if !strings.EqualFold(fields[1], "BGP") {
|
||||
continue
|
||||
}
|
||||
state := extractBGPSessionStateLine(line)
|
||||
if state == "" {
|
||||
state = fields[3]
|
||||
}
|
||||
out[fields[0]] = state
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func extractBGPSessionStateLine(line string) string {
|
||||
known := []string{
|
||||
"Established",
|
||||
"Idle",
|
||||
"Connect",
|
||||
"Active",
|
||||
"OpenSent",
|
||||
"OpenConfirm",
|
||||
}
|
||||
for _, st := range known {
|
||||
if strings.Contains(line, st) {
|
||||
return st
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -17,12 +17,12 @@ import (
|
||||
|
||||
// Manifest describes bundle contents for evobgp-node verification.
|
||||
type Manifest struct {
|
||||
RevisionID string `json:"revision_id"`
|
||||
SpeakerID string `json:"speaker_id,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
Files []FileEntry `json:"files"`
|
||||
Algorithm string `json:"signature_algorithm"`
|
||||
PublicKeyB64 string `json:"public_key_base64"`
|
||||
RevisionID string `json:"revision_id"`
|
||||
SpeakerID string `json:"speaker_id,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
Files []FileEntry `json:"files"`
|
||||
Algorithm string `json:"signature_algorithm"`
|
||||
PublicKeyB64 string `json:"public_key_base64"`
|
||||
}
|
||||
|
||||
// FileEntry is one file inside the bundle archive.
|
||||
|
||||
@@ -27,7 +27,7 @@ func VerifyGzippedTar(bundle []byte, pub ed25519.PublicKey) (*VerifiedContents,
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer gr.Close()
|
||||
defer func() { _ = gr.Close() }()
|
||||
|
||||
var manifestRaw []byte
|
||||
var sig []byte
|
||||
|
||||
@@ -6,9 +6,12 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"evobgp/migrations"
|
||||
|
||||
@@ -22,6 +25,17 @@ func OpenPostgresPool(ctx context.Context, dsn string) (*pgxpool.Pool, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if max := os.Getenv("EVOBGP_DB_MAX_CONNS"); max != "" {
|
||||
if n, err := strconv.Atoi(strings.TrimSpace(max)); err == nil && n > 0 {
|
||||
cfg.MaxConns = int32(n)
|
||||
}
|
||||
}
|
||||
if min := os.Getenv("EVOBGP_DB_MIN_CONNS"); min != "" {
|
||||
if n, err := strconv.Atoi(strings.TrimSpace(min)); err == nil && n >= 0 {
|
||||
cfg.MinConns = int32(n)
|
||||
}
|
||||
}
|
||||
cfg.MaxConnLifetime = 30 * time.Minute
|
||||
pool, err := pgxpool.NewWithConfig(ctx, cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -53,6 +53,21 @@ func BootstrapWorkers(ctx context.Context, opts Options) (store.Backend, *jobs.R
|
||||
wk := &jobs.Worker{Store: backend, HTTPClient: cdnHTTP}
|
||||
reg := jobs.NewRegistry(wk.Process)
|
||||
wk.Registry = reg
|
||||
if pool != nil {
|
||||
audit := repository.NewJobAuditWriter(pool)
|
||||
reg.SetTerminalHook(func(j *jobs.Job) {
|
||||
if j == nil {
|
||||
return
|
||||
}
|
||||
st := j.Snapshot()
|
||||
status, _ := st["status"].(string)
|
||||
var errMsg *string
|
||||
if e, ok := st["error"].(string); ok && e != "" {
|
||||
errMsg = &e
|
||||
}
|
||||
audit.MarkTerminal(context.Background(), j.TenantID, j.ID, status, errMsg, time.Now().UTC())
|
||||
})
|
||||
}
|
||||
observability.RegisterStoreBackend(backend)
|
||||
return backend, reg, pool, nil
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ package httpapi
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"evobgp/internal/store"
|
||||
)
|
||||
|
||||
func parseListLimit(r *http.Request) int {
|
||||
@@ -22,3 +24,15 @@ func strPtrOrNull(s string) any {
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// writePaginatedListJSON returns a cursor/limit page as OpenAPI list envelopes (items, next_cursor, has_more).
|
||||
func writePaginatedListJSON[T any](w http.ResponseWriter, r *http.Request, all []T, toItem func(T) map[string]any) {
|
||||
page, next, more := store.PaginateOffset(all, r.URL.Query().Get("cursor"), parseListLimit(r))
|
||||
items := make([]map[string]any, 0, len(page))
|
||||
for _, x := range page {
|
||||
items = append(items, toItem(x))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"items": items, "next_cursor": strPtrOrNull(next), "has_more": more,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -9,15 +9,19 @@ import (
|
||||
const (
|
||||
internalErrorDetail = "an internal error occurred"
|
||||
badGatewayDetail = "upstream request failed"
|
||||
notFoundDetail = "resource not found"
|
||||
invalidInputDetail = "invalid request data"
|
||||
cdnExtractDetail = "could not extract prefixes from source"
|
||||
csvInvalidRowDetail = "invalid row in csv file"
|
||||
)
|
||||
|
||||
// Problem is RFC 9457 application/problem+json.
|
||||
type Problem struct {
|
||||
Type string `json:"type,omitempty"`
|
||||
Title string `json:"title"`
|
||||
Status int `json:"status"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
Instance string `json:"instance,omitempty"`
|
||||
Type string `json:"type,omitempty"`
|
||||
Title string `json:"title"`
|
||||
Status int `json:"status"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
Instance string `json:"instance,omitempty"`
|
||||
}
|
||||
|
||||
func writeProblem(w http.ResponseWriter, status int, title, detail string) {
|
||||
|
||||
@@ -69,6 +69,7 @@ func (s *Server) registerV1(m *http.ServeMux) {
|
||||
m.HandleFunc("GET /bird/status", s.handleBirdStatus)
|
||||
m.HandleFunc("GET /jobs", s.handleListJobs)
|
||||
m.HandleFunc("GET /jobs/{job_id}", s.handleGetJob)
|
||||
m.HandleFunc("GET /jobs/{job_id}/report", s.handleGetJobReport)
|
||||
m.HandleFunc("POST /jobs/{job_id}/cancel", s.handleCancelJob)
|
||||
m.HandleFunc("GET /speakers/{speaker_id}/revisions/latest", s.handleNodeLatestRevision)
|
||||
m.HandleFunc("GET /speakers/{speaker_id}/bundle/{revision_id}", s.handleNodeBundle)
|
||||
@@ -84,12 +85,15 @@ func (s *Server) handleReady(w http.ResponseWriter, r *http.Request) {
|
||||
checks := map[string]string{"store": "ok", "jobs": "memory"}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
|
||||
defer cancel()
|
||||
if s.pgPool != nil {
|
||||
if err := s.pgPool.Ping(ctx); err != nil {
|
||||
if err := s.store.Ping(ctx); err != nil {
|
||||
checks["store"] = "unavailable"
|
||||
if s.pgPool != nil {
|
||||
checks["postgres"] = "unavailable"
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]any{"status": "not_ready", "checks": checks})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]any{"status": "not_ready", "checks": checks})
|
||||
return
|
||||
}
|
||||
if s.pgPool != nil {
|
||||
checks["postgres"] = "ok"
|
||||
} else {
|
||||
checks["store_backend"] = "memory"
|
||||
@@ -198,6 +202,22 @@ func (s *Server) handleListModules(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
filtered := make([]*store.Module, 0)
|
||||
limit := parseListLimit(r)
|
||||
cursor := r.URL.Query().Get("cursor")
|
||||
if typeFilter == "" && enabledFilter == nil {
|
||||
page, next, more := s.store.ListModulesPage(a.TenantID, cursor, limit)
|
||||
for _, mod := range page {
|
||||
filtered = append(filtered, mod)
|
||||
}
|
||||
items := make([]map[string]any, 0, len(filtered))
|
||||
for _, mod := range filtered {
|
||||
items = append(items, moduleJSON(mod))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"items": items, "next_cursor": strPtrOrNull(next), "has_more": more,
|
||||
})
|
||||
return
|
||||
}
|
||||
for _, mod := range s.store.ListModules(a.TenantID) {
|
||||
if typeFilter != "" && mod.Type != typeFilter {
|
||||
continue
|
||||
@@ -272,7 +292,7 @@ func (s *Server) handleListPeers(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
allPeers := s.store.ListPeers(a.TenantID)
|
||||
page, next, more := store.PaginateOffset(allPeers, r.URL.Query().Get("cursor"), parseListLimit(r))
|
||||
liveStates := s.liveBGPProtocolStates(r.Context())
|
||||
liveStates := s.liveBGPProtocolStates(r)
|
||||
items := make([]map[string]any, 0, len(page))
|
||||
for _, p := range page {
|
||||
row := peerJSON(p)
|
||||
@@ -286,7 +306,17 @@ func (s *Server) handleListPeers(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) liveBGPProtocolStates(ctx context.Context) map[string]string {
|
||||
func (s *Server) liveBGPProtocolStates(r *http.Request) map[string]string {
|
||||
if r != nil && strings.EqualFold(strings.TrimSpace(r.URL.Query().Get("live")), "1") {
|
||||
return s.liveBGPProtocolStatesFresh(r.Context())
|
||||
}
|
||||
if cached, ok := observability.CachedBirdProtocolStates(90 * time.Second); ok {
|
||||
return cached
|
||||
}
|
||||
return s.liveBGPProtocolStatesFresh(r.Context())
|
||||
}
|
||||
|
||||
func (s *Server) liveBGPProtocolStatesFresh(ctx context.Context) map[string]string {
|
||||
sock := strings.TrimSpace(os.Getenv("EVOBGP_BIRDC_SOCKET"))
|
||||
if sock == "" {
|
||||
return map[string]string{}
|
||||
@@ -295,7 +325,9 @@ func (s *Server) liveBGPProtocolStates(ctx context.Context) map[string]string {
|
||||
if err != nil {
|
||||
return map[string]string{}
|
||||
}
|
||||
return parseBGPProtocolStates(out)
|
||||
states := birdfmt.ParseBGPProtocolStates(out)
|
||||
observability.SetBirdProtocolStates(states)
|
||||
return states
|
||||
}
|
||||
|
||||
// parseBGPProtocolStates parses `birdc show protocols all` summary rows into protocol_name -> state.
|
||||
@@ -519,7 +551,8 @@ func (s *Server) enqueueModuleRefreshIfEnabled(tenantID, moduleID, trigger strin
|
||||
return
|
||||
}
|
||||
mid := moduleID
|
||||
_, _, _ = s.jobs.Enqueue(tenantID, jobs.KindModuleRefresh, nil, &mid, map[string]any{
|
||||
key := "module_refresh:" + moduleID
|
||||
_, _, _ = s.jobs.Enqueue(tenantID, jobs.KindModuleRefresh, &key, &mid, map[string]any{
|
||||
"module_id": moduleID,
|
||||
"trigger": trigger,
|
||||
})
|
||||
@@ -586,6 +619,9 @@ func (s *Server) handleRevisionPreview(w http.ResponseWriter, r *http.Request) {
|
||||
for k, v := range rev.PreviewFragments {
|
||||
obj[k] = v
|
||||
}
|
||||
if expanded := pipeline.BuildExpandedBirdPreview(rev.PreviewFragments); expanded != "" {
|
||||
obj[pipeline.AuxBirdFullExpandedKey()] = expanded
|
||||
}
|
||||
writeJSON(w, http.StatusOK, obj)
|
||||
}
|
||||
|
||||
@@ -840,6 +876,44 @@ func (s *Server) handleGetJob(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, j.Snapshot())
|
||||
}
|
||||
|
||||
func (s *Server) handleGetJobReport(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok {
|
||||
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
|
||||
return
|
||||
}
|
||||
if !s.requireAtLeast(w, a, "viewer") {
|
||||
return
|
||||
}
|
||||
j, err := s.jobs.Get(a.TenantID, r.PathValue("job_id"))
|
||||
if err != nil {
|
||||
writeProblem(w, http.StatusNotFound, "Not Found", "job not found")
|
||||
return
|
||||
}
|
||||
snap := j.Snapshot()
|
||||
meta, _ := snap["meta"].(map[string]any)
|
||||
out := map[string]any{
|
||||
"job_id": snap["job_id"],
|
||||
"kind": snap["kind"],
|
||||
"status": snap["status"],
|
||||
"meta": meta,
|
||||
"error": snap["error"],
|
||||
"created_at": snap["created_at"],
|
||||
}
|
||||
if meta != nil {
|
||||
if v, ok := meta["log_entries"]; ok {
|
||||
out["log_entries"] = v
|
||||
}
|
||||
if v, ok := meta["log_total"]; ok {
|
||||
out["log_total"] = v
|
||||
}
|
||||
if v, ok := meta["revision_id"]; ok {
|
||||
out["revision_id"] = v
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
func (s *Server) handleCancelJob(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok {
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -172,12 +173,15 @@ func (s *Server) handleDeleteModule(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func writeStoreErr(w http.ResponseWriter, err error) {
|
||||
if err != nil {
|
||||
log.Printf("httpapi: store: %v", err)
|
||||
}
|
||||
if err == store.ErrNotFound || err == store.ErrTenantScope {
|
||||
writeProblem(w, http.StatusNotFound, "Not Found", err.Error())
|
||||
writeProblem(w, http.StatusNotFound, "Not Found", notFoundDetail)
|
||||
return
|
||||
}
|
||||
if err == store.ErrInvalidInput {
|
||||
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", err.Error())
|
||||
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", invalidInputDetail)
|
||||
return
|
||||
}
|
||||
writeInternalError(w, "store", err)
|
||||
@@ -193,11 +197,7 @@ func (s *Server) handleListCDNSources(w http.ResponseWriter, r *http.Request) {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
items := make([]map[string]any, 0, len(list))
|
||||
for _, x := range list {
|
||||
items = append(items, cdnSourceJSON(x))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"items": items, "next_cursor": nil, "has_more": false})
|
||||
writePaginatedListJSON(w, r, list, cdnSourceJSON)
|
||||
}
|
||||
|
||||
func cdnSourceJSON(x *store.CDNSource) map[string]any {
|
||||
@@ -258,7 +258,7 @@ func (s *Server) handlePreviewCDNSource(w http.ResponseWriter, r *http.Request)
|
||||
writeBadGateway(w, "cdn preview fetch", err)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
writeBadGateway(w, "cdn preview fetch", fmt.Errorf("upstream status: %s", resp.Status))
|
||||
@@ -271,7 +271,8 @@ func (s *Server) handlePreviewCDNSource(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
pfxs, err := pipeline.ExtractCIDRs(string(raw), body.SourceKind, body.PrefixPath)
|
||||
if err != nil {
|
||||
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", err.Error())
|
||||
log.Printf("httpapi: cdn preview extract: %v", err)
|
||||
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", cdnExtractDetail)
|
||||
return
|
||||
}
|
||||
items := make([]string, 0, len(pfxs))
|
||||
@@ -354,11 +355,7 @@ func (s *Server) handleListAS(w http.ResponseWriter, r *http.Request) {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
items := make([]map[string]any, 0, len(list))
|
||||
for _, x := range list {
|
||||
items = append(items, asEntryJSON(x))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"items": items})
|
||||
writePaginatedListJSON(w, r, list, asEntryJSON)
|
||||
}
|
||||
|
||||
func asEntryJSON(x *store.ASEntry) map[string]any {
|
||||
@@ -450,11 +447,7 @@ func (s *Server) handleListDomain(w http.ResponseWriter, r *http.Request) {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
items := make([]map[string]any, 0, len(list))
|
||||
for _, x := range list {
|
||||
items = append(items, domainEntryJSON(x))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"items": items})
|
||||
writePaginatedListJSON(w, r, list, domainEntryJSON)
|
||||
}
|
||||
|
||||
func domainEntryJSON(x *store.DomainEntry) map[string]any {
|
||||
@@ -531,11 +524,7 @@ func (s *Server) handleListIPRange(w http.ResponseWriter, r *http.Request) {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
items := make([]map[string]any, 0, len(list))
|
||||
for _, x := range list {
|
||||
items = append(items, ipRangeJSON(x))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"items": items})
|
||||
writePaginatedListJSON(w, r, list, ipRangeJSON)
|
||||
}
|
||||
|
||||
func ipRangeJSON(x *store.IPRangeEntry) map[string]any {
|
||||
@@ -702,8 +691,8 @@ func (s *Server) handleImportModuleEntriesCSV(w http.ResponseWriter, r *http.Req
|
||||
return
|
||||
}
|
||||
if strings.Contains(err.Error(), "importer: line") {
|
||||
detail := strings.TrimPrefix(err.Error(), "importer: ")
|
||||
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", detail)
|
||||
log.Printf("httpapi: csv import: %v", err)
|
||||
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", csvInvalidRowDetail)
|
||||
return
|
||||
}
|
||||
if strings.Contains(err.Error(), "importer: csv import/export") {
|
||||
|
||||
@@ -34,7 +34,7 @@ func TestModuleEntriesCSVImportExportIPRanges(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer respList.Body.Close()
|
||||
defer func() { _ = respList.Body.Close() }()
|
||||
if respList.StatusCode != http.StatusOK {
|
||||
b, _ := io.ReadAll(respList.Body)
|
||||
t.Fatalf("communities status %d: %s", respList.StatusCode, b)
|
||||
@@ -59,7 +59,7 @@ func TestModuleEntriesCSVImportExportIPRanges(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer respImport.Body.Close()
|
||||
defer func() { _ = respImport.Body.Close() }()
|
||||
if respImport.StatusCode != http.StatusOK {
|
||||
b, _ := io.ReadAll(respImport.Body)
|
||||
t.Fatalf("import status %d: %s", respImport.StatusCode, b)
|
||||
@@ -80,7 +80,7 @@ func TestModuleEntriesCSVImportExportIPRanges(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer respExport.Body.Close()
|
||||
defer func() { _ = respExport.Body.Close() }()
|
||||
if respExport.StatusCode != http.StatusOK {
|
||||
b, _ := io.ReadAll(respExport.Body)
|
||||
t.Fatalf("export status %d: %s", respExport.StatusCode, b)
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNestedModuleListPagination(t *testing.T) {
|
||||
srv, err := New(Options{InsecureDev: true, SeedDemo: true, BundleSeedHex: testBundleSeed})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer srv.Close()
|
||||
tenant, _, modIP, _, _ := srv.Store().DemoIDs()
|
||||
srv.apiKeys = parseAPIKeysSpec("edkey|" + tenant + "|editor")
|
||||
|
||||
ts := httptest.NewServer(srv.Handler())
|
||||
defer ts.Close()
|
||||
client := ts.Client()
|
||||
base := ts.URL
|
||||
mid := modIP
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
body := strings.NewReader(fmt.Sprintf(`{"prefix":"10.%d.0.0/24"}`, 200+i))
|
||||
req, _ := http.NewRequest(http.MethodPost, base+"/v1/modules/"+mid+"/ip-range-entries", body)
|
||||
req.Header.Set("Authorization", "Bearer edkey")
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
_ = resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("create entry %d: status %d", i, resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
req, _ := http.NewRequest(http.MethodGet, base+"/v1/modules/"+mid+"/ip-range-entries?limit=2", nil)
|
||||
req.Header.Set("Authorization", "Bearer edkey")
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("list status %d: %s", resp.StatusCode, b)
|
||||
}
|
||||
var page1 struct {
|
||||
Items []map[string]any `json:"items"`
|
||||
NextCursor *string `json:"next_cursor"`
|
||||
HasMore bool `json:"has_more"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&page1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(page1.Items) != 2 {
|
||||
t.Fatalf("page1 items: got %d want 2", len(page1.Items))
|
||||
}
|
||||
if !page1.HasMore || page1.NextCursor == nil || *page1.NextCursor == "" {
|
||||
t.Fatalf("page1: has_more=%v next_cursor=%v", page1.HasMore, page1.NextCursor)
|
||||
}
|
||||
|
||||
req2, _ := http.NewRequest(http.MethodGet, base+"/v1/modules/"+mid+"/ip-range-entries?limit=2&cursor="+*page1.NextCursor, nil)
|
||||
req2.Header.Set("Authorization", "Bearer edkey")
|
||||
resp2, err := client.Do(req2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = resp2.Body.Close() }()
|
||||
var page2 struct {
|
||||
Items []map[string]any `json:"items"`
|
||||
HasMore bool `json:"has_more"`
|
||||
}
|
||||
if err := json.NewDecoder(resp2.Body).Decode(&page2); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(page1.Items)+len(page2.Items) < 3 {
|
||||
t.Fatalf("expected at least 3 entries across pages, got %d+%d", len(page1.Items), len(page2.Items))
|
||||
}
|
||||
}
|
||||
@@ -32,10 +32,10 @@ type Server struct {
|
||||
type Options struct {
|
||||
APIKeys string
|
||||
// DatabaseURL enables PostgreSQL-backed store (migrations applied on connect).
|
||||
DatabaseURL string
|
||||
InsecureDev bool
|
||||
SeedDemo bool
|
||||
BundleSeedHex string
|
||||
DatabaseURL string
|
||||
InsecureDev bool
|
||||
SeedDemo bool
|
||||
BundleSeedHex string
|
||||
CORSAllowedOrigins string
|
||||
}
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ func TestAPIRefreshApplyJobsBundle(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("status %d: %s", resp.StatusCode, b)
|
||||
@@ -76,7 +76,7 @@ func TestAPIRefreshApplyJobsBundle(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusAccepted {
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("status %d: %s", resp.StatusCode, b)
|
||||
@@ -97,7 +97,7 @@ func TestAPIRefreshApplyJobsBundle(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusAccepted {
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("status %d: %s", resp.StatusCode, b)
|
||||
@@ -118,7 +118,7 @@ func TestAPIRefreshApplyJobsBundle(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("status %d: %s", resp.StatusCode, b)
|
||||
@@ -132,7 +132,7 @@ func TestAPIRefreshApplyJobsBundle(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("status %d: %s", resp.StatusCode, b)
|
||||
@@ -162,7 +162,7 @@ func TestAPIRefreshApplyJobsBundle(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
_ = resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("%s status %d: %s", path, resp.StatusCode, b)
|
||||
}
|
||||
@@ -185,7 +185,7 @@ func TestAPIRefreshApplyJobsBundle(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("status %d: %s", resp.StatusCode, b)
|
||||
@@ -215,7 +215,7 @@ func TestAPIRefreshApplyJobsBundle(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("status %d: %s", resp.StatusCode, b)
|
||||
@@ -255,7 +255,7 @@ func TestAPIRefreshApplyJobsBundle(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("status %d want 403: %s", resp.StatusCode, b)
|
||||
@@ -269,7 +269,7 @@ func TestAPIRefreshApplyJobsBundle(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusAccepted {
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("status %d: %s", resp.StatusCode, b)
|
||||
@@ -289,7 +289,7 @@ func TestAPIRefreshApplyJobsBundle(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusAccepted {
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("status %d: %s", resp.StatusCode, b)
|
||||
@@ -314,7 +314,7 @@ func TestAPIRefreshApplyJobsBundle(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("status %d: %s", resp.StatusCode, b)
|
||||
@@ -341,7 +341,7 @@ func waitJob(t *testing.T, client *http.Client, base, token, jobID string) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
_ = resp.Body.Close()
|
||||
var body struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
@@ -405,7 +405,7 @@ func TestVersionEndpoints(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("status %d: %s", resp.StatusCode, b)
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
package jobs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultJobTimeoutModuleRefresh = 10 * time.Minute
|
||||
defaultJobTimeoutTenantRefresh = 15 * time.Minute
|
||||
defaultJobTimeoutDeployApply = 5 * time.Minute
|
||||
defaultJobTimeoutPeerReconcile = 10 * time.Minute
|
||||
defaultJobTimeoutRollback = 5 * time.Minute
|
||||
defaultJobTimeoutBirdReload = 2 * time.Minute
|
||||
)
|
||||
|
||||
func jobTimeout(kind string) time.Duration {
|
||||
envKey := map[string]string{
|
||||
KindModuleRefresh: "EVOBGP_JOB_TIMEOUT_MODULE_REFRESH",
|
||||
KindTenantRefresh: "EVOBGP_JOB_TIMEOUT_TENANT_REFRESH",
|
||||
KindDeployApply: "EVOBGP_JOB_TIMEOUT_DEPLOY_APPLY",
|
||||
KindPeerReconcile: "EVOBGP_JOB_TIMEOUT_PEER_RECONCILE",
|
||||
KindRevisionRollback: "EVOBGP_JOB_TIMEOUT_ROLLBACK",
|
||||
KindBirdReload: "EVOBGP_JOB_TIMEOUT_BIRD_RELOAD",
|
||||
}[kind]
|
||||
if envKey != "" {
|
||||
if d, err := time.ParseDuration(os.Getenv(envKey)); err == nil && d > 0 {
|
||||
return d
|
||||
}
|
||||
}
|
||||
switch kind {
|
||||
case KindModuleRefresh:
|
||||
return defaultJobTimeoutModuleRefresh
|
||||
case KindTenantRefresh:
|
||||
return defaultJobTimeoutTenantRefresh
|
||||
case KindDeployApply:
|
||||
return defaultJobTimeoutDeployApply
|
||||
case KindPeerReconcile:
|
||||
return defaultJobTimeoutPeerReconcile
|
||||
case KindRevisionRollback:
|
||||
return defaultJobTimeoutRollback
|
||||
case KindBirdReload:
|
||||
return defaultJobTimeoutBirdReload
|
||||
default:
|
||||
if n, err := strconv.Atoi(os.Getenv("EVOBGP_JOB_TIMEOUT_SEC")); err == nil && n > 0 {
|
||||
return time.Duration(n) * time.Second
|
||||
}
|
||||
return defaultJobTimeoutModuleRefresh
|
||||
}
|
||||
}
|
||||
|
||||
// workContext returns a timeout context that also cancels when the job is cancelled.
|
||||
func (j *Job) workContext() (context.Context, context.CancelFunc) {
|
||||
if j == nil {
|
||||
return context.Background(), func() {}
|
||||
}
|
||||
timeout := jobTimeout(j.Kind)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
go func() {
|
||||
ticker := time.NewTicker(500 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
if j.IsCancelRequested() {
|
||||
cancel()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
return ctx, cancel
|
||||
}
|
||||
+51
-2
@@ -9,6 +9,8 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"evobgp/internal/observability"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
@@ -178,6 +180,8 @@ type Registry struct {
|
||||
byID map[string]*Job
|
||||
byIdempo map[idempoKey]*Job
|
||||
workerStart func(j *Job)
|
||||
workerSem chan struct{}
|
||||
onTerminal func(j *Job)
|
||||
}
|
||||
|
||||
type idempoKey struct {
|
||||
@@ -186,13 +190,44 @@ type idempoKey struct {
|
||||
}
|
||||
|
||||
func NewRegistry(workerStart func(j *Job)) *Registry {
|
||||
maxWorkers := registryMaxConcurrentJobs()
|
||||
return &Registry{
|
||||
byID: make(map[string]*Job),
|
||||
byIdempo: make(map[idempoKey]*Job),
|
||||
workerStart: workerStart,
|
||||
workerSem: make(chan struct{}, maxWorkers),
|
||||
}
|
||||
}
|
||||
|
||||
// SetTerminalHook registers a best-effort callback when jobs reach a terminal state.
|
||||
func (r *Registry) SetTerminalHook(fn func(j *Job)) {
|
||||
if r == nil {
|
||||
return
|
||||
}
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.onTerminal = fn
|
||||
}
|
||||
|
||||
func (r *Registry) fireTerminal(j *Job) {
|
||||
if r == nil || j == nil {
|
||||
return
|
||||
}
|
||||
r.mu.RLock()
|
||||
fn := r.onTerminal
|
||||
r.mu.RUnlock()
|
||||
if fn != nil {
|
||||
fn(j)
|
||||
}
|
||||
}
|
||||
|
||||
func registryMaxConcurrentJobs() int {
|
||||
if n, err := strconv.Atoi(strings.TrimSpace(os.Getenv("EVOBGP_JOB_MAX_CONCURRENT"))); err == nil && n > 0 {
|
||||
return n
|
||||
}
|
||||
return 8
|
||||
}
|
||||
|
||||
// pruneTerminalIfOver удаляет самые старые завершённые джобы (succeeded/failed/cancelled), пока len(byID) > maxJobs.
|
||||
func (r *Registry) pruneTerminalIfOver(maxJobs int) {
|
||||
if r == nil || maxJobs <= 0 || len(r.byID) <= maxJobs {
|
||||
@@ -244,7 +279,11 @@ func (r *Registry) Enqueue(tenantID, kind string, idempotencyKey *string, module
|
||||
if idempotencyKey != nil && *idempotencyKey != "" {
|
||||
k := idempoKey{tenant: tenantID, key: *idempotencyKey}
|
||||
if existing, ok := r.byIdempo[k]; ok {
|
||||
return existing, false, nil
|
||||
st := existing.statusLocked()
|
||||
if st == StatusQueued || st == StatusRunning {
|
||||
return existing, false, nil
|
||||
}
|
||||
delete(r.byIdempo, k)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -265,7 +304,17 @@ func (r *Registry) Enqueue(tenantID, kind string, idempotencyKey *string, module
|
||||
r.pruneTerminalIfOver(maxJobs)
|
||||
|
||||
if r.workerStart != nil {
|
||||
go r.workerStart(j)
|
||||
go func() {
|
||||
r.workerSem <- struct{}{}
|
||||
active := len(r.workerSem)
|
||||
capacity := cap(r.workerSem)
|
||||
observability.RecordJobQueueDepth(active, capacity)
|
||||
defer func() {
|
||||
<-r.workerSem
|
||||
observability.RecordJobQueueDepth(len(r.workerSem), capacity)
|
||||
}()
|
||||
r.workerStart(j)
|
||||
}()
|
||||
}
|
||||
return j, true, nil
|
||||
}
|
||||
|
||||
+56
-8
@@ -82,6 +82,9 @@ func (w *Worker) httpClient() *http.Client {
|
||||
func (w *Worker) Process(j *Job) {
|
||||
defer func() {
|
||||
observability.RecordJobTerminal(j.Kind, j.statusLocked())
|
||||
if w != nil && w.Registry != nil {
|
||||
w.Registry.fireTerminal(j)
|
||||
}
|
||||
}()
|
||||
|
||||
if w == nil || w.Store == nil {
|
||||
@@ -102,7 +105,17 @@ func (w *Worker) Process(j *Job) {
|
||||
j.Fail("missing module_id in job meta")
|
||||
return
|
||||
}
|
||||
if err := pipeline.RefreshModuleIngest(context.Background(), w.Store, w.httpClient(), j.TenantID, mid); err != nil {
|
||||
ctx, cancel := j.workContext()
|
||||
defer cancel()
|
||||
if ctx.Err() != nil {
|
||||
j.MarkCancelled()
|
||||
return
|
||||
}
|
||||
if err := pipeline.RefreshModuleIngest(ctx, w.Store, w.httpClient(), j.TenantID, mid); err != nil {
|
||||
if ctx.Err() != nil {
|
||||
j.MarkCancelled()
|
||||
return
|
||||
}
|
||||
j.Fail(err.Error())
|
||||
return
|
||||
}
|
||||
@@ -121,11 +134,17 @@ func (w *Worker) Process(j *Job) {
|
||||
j.Succeed()
|
||||
return
|
||||
}
|
||||
ctx, cancel := j.workContext()
|
||||
defer cancel()
|
||||
ctl := &birdfmt.BirdCtl{
|
||||
Socket: sock,
|
||||
Birdc: strings.TrimSpace(os.Getenv("EVOBGP_BIRDC_BIN")),
|
||||
}
|
||||
if err := ctl.Configure(context.Background()); err != nil {
|
||||
if err := ctl.Configure(ctx); err != nil {
|
||||
if ctx.Err() != nil {
|
||||
j.MarkCancelled()
|
||||
return
|
||||
}
|
||||
j.Fail(err.Error())
|
||||
return
|
||||
}
|
||||
@@ -152,9 +171,14 @@ func (w *Worker) runPeerReconcile(j *Job) {
|
||||
return
|
||||
}
|
||||
if len(latest) == 0 {
|
||||
// First run fallback: render full tenant state once if no baseline revision exists yet.
|
||||
rid, err := pipeline.RenderTenantRevision(context.Background(), w.Store, w.httpClient(), j.TenantID, triggerModuleID)
|
||||
ctx, cancel := j.workContext()
|
||||
defer cancel()
|
||||
rid, err := pipeline.RenderTenantRevision(ctx, w.Store, w.httpClient(), j.TenantID, triggerModuleID)
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
j.MarkCancelled()
|
||||
return
|
||||
}
|
||||
j.Fail(err.Error())
|
||||
return
|
||||
}
|
||||
@@ -171,8 +195,14 @@ func (w *Worker) runPeerReconcile(j *Job) {
|
||||
}
|
||||
cursor = next
|
||||
}
|
||||
rid, err := pipeline.RenderTenantRevisionFromPrefixes(context.Background(), w.Store, w.httpClient(), j.TenantID, triggerModuleID, rows)
|
||||
ctx, cancel := j.workContext()
|
||||
defer cancel()
|
||||
rid, err := pipeline.RenderTenantRevisionFromPrefixes(ctx, w.Store, w.httpClient(), j.TenantID, triggerModuleID, rows)
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
j.MarkCancelled()
|
||||
return
|
||||
}
|
||||
j.Fail(err.Error())
|
||||
return
|
||||
}
|
||||
@@ -230,7 +260,13 @@ func (w *Worker) runTenantRefresh(j *Job) {
|
||||
j.Fail("missing module_ids in job meta")
|
||||
return
|
||||
}
|
||||
if err := pipeline.RefreshTenantModules(context.Background(), w.Store, w.httpClient(), j.TenantID, moduleIDs); err != nil {
|
||||
ctx, cancel := j.workContext()
|
||||
defer cancel()
|
||||
if err := pipeline.RefreshTenantModules(ctx, w.Store, w.httpClient(), j.TenantID, moduleIDs); err != nil {
|
||||
if ctx.Err() != nil {
|
||||
j.MarkCancelled()
|
||||
return
|
||||
}
|
||||
j.Fail(err.Error())
|
||||
return
|
||||
}
|
||||
@@ -275,6 +311,8 @@ func (w *Worker) finishModuleRefreshSuccess(j *Job, triggerModuleID string) {
|
||||
mu := w.tenantRefreshMu(j.TenantID)
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
ctx, cancel := j.workContext()
|
||||
defer cancel()
|
||||
deferDeploy := false
|
||||
if w.Registry != nil {
|
||||
deferDeploy = w.Registry.CountOtherActiveRefresh(j.TenantID, j.ID) > 0
|
||||
@@ -288,8 +326,12 @@ func (w *Worker) finishModuleRefreshSuccess(j *Job, triggerModuleID string) {
|
||||
return
|
||||
}
|
||||
|
||||
rev, err := pipeline.RenderTenantRevision(context.Background(), w.Store, w.httpClient(), j.TenantID, triggerModuleID)
|
||||
rev, err := pipeline.RenderTenantRevision(ctx, w.Store, w.httpClient(), j.TenantID, triggerModuleID)
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
j.MarkCancelled()
|
||||
return
|
||||
}
|
||||
j.Fail(err.Error())
|
||||
return
|
||||
}
|
||||
@@ -335,6 +377,8 @@ func (w *Worker) runDeployApply(j *Job) {
|
||||
j.Fail("missing revision_id in job meta")
|
||||
return
|
||||
}
|
||||
ctx, cancel := j.workContext()
|
||||
defer cancel()
|
||||
activeDir := strings.TrimSpace(os.Getenv("EVOBGP_BIRD_ACTIVE_DIR"))
|
||||
if activeDir != "" {
|
||||
revObj, err := w.Store.GetRevision(j.TenantID, revID)
|
||||
@@ -354,7 +398,11 @@ func (w *Worker) runDeployApply(j *Job) {
|
||||
Socket: strings.TrimSpace(os.Getenv("EVOBGP_BIRDC_SOCKET")),
|
||||
}
|
||||
ctl := &birdfmt.BirdCtl{Bird: cfg.BirdBin, Birdc: cfg.BirdcBin, Socket: cfg.Socket}
|
||||
if err := birddeploy.ApplyRevision(context.Background(), ctl, revObj, cfg); err != nil {
|
||||
if err := birddeploy.ApplyRevision(ctx, ctl, revObj, cfg); err != nil {
|
||||
if ctx.Err() != nil {
|
||||
j.MarkCancelled()
|
||||
return
|
||||
}
|
||||
j.Fail(err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
@@ -66,7 +66,7 @@ func fetchLatestRevision(base, token, speaker string) (string, error) {
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
return "", fmt.Errorf("latest revision: %s: %s", resp.Status, strings.TrimSpace(string(b)))
|
||||
@@ -94,7 +94,7 @@ func fetchBundle(base, token, speaker, revision string) ([]byte, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("bundle: %s: %s", resp.Status, strings.TrimSpace(string(b)))
|
||||
|
||||
@@ -27,7 +27,7 @@ func Run(args []string) int {
|
||||
|
||||
// Usage prints CLI help to w.
|
||||
func Usage(w interface{ Write([]byte) (int, error) }) {
|
||||
fmt.Fprintf(w, `Usage:
|
||||
_, _ = fmt.Fprintf(w, `Usage:
|
||||
evobgp-node pull-bundle -base-url URL -token TOKEN -speaker-id ID [-revision-id ID] [-o path]
|
||||
evobgp-node verify-bundle -f bundle.tar.gz (-pubkey-base64 B64 | -pubkey-hex HEX)
|
||||
evobgp-node apply-bundle -f bundle.tar.gz -extract-dir DIR (-pubkey-base64 B64 | -pubkey-hex HEX)
|
||||
|
||||
@@ -80,6 +80,38 @@ var (
|
||||
Help: "Prefix row count after CIDR aggregation on tenant render.",
|
||||
Buckets: prometheus.ExponentialBuckets(1, 2, 16),
|
||||
})
|
||||
|
||||
pipelineRefreshDuration = promauto.NewHistogramVec(prometheus.HistogramOpts{
|
||||
Namespace: namespace,
|
||||
Name: "pipeline_refresh_duration_seconds",
|
||||
Help: "Module refresh ingest duration by module type.",
|
||||
Buckets: prometheus.ExponentialBuckets(0.05, 2, 14),
|
||||
}, []string{"module_type"})
|
||||
|
||||
renderPrefixCount = promauto.NewHistogram(prometheus.HistogramOpts{
|
||||
Namespace: namespace,
|
||||
Name: "render_prefix_count",
|
||||
Help: "Materialized prefix count per tenant render.",
|
||||
Buckets: prometheus.ExponentialBuckets(10, 2, 16),
|
||||
})
|
||||
|
||||
jobQueueActive = promauto.NewGauge(prometheus.GaugeOpts{
|
||||
Namespace: namespace,
|
||||
Name: "job_queue_active",
|
||||
Help: "Currently running in-process async jobs.",
|
||||
})
|
||||
|
||||
jobQueueCapacity = promauto.NewGauge(prometheus.GaugeOpts{
|
||||
Namespace: namespace,
|
||||
Name: "job_queue_capacity",
|
||||
Help: "Maximum concurrent in-process async jobs.",
|
||||
})
|
||||
)
|
||||
|
||||
var (
|
||||
birdProtocolStatesMu sync.RWMutex
|
||||
birdProtocolStates map[string]string
|
||||
birdProtocolStatesAt time.Time
|
||||
)
|
||||
|
||||
// RecordPrefixAggregation records tenant render CIDR aggregation stats.
|
||||
@@ -93,6 +125,27 @@ func RecordPrefixAggregation(rawCount, aggregatedCount int, duration time.Durati
|
||||
prefixAggregationDuration.Observe(duration.Seconds())
|
||||
prefixAggregationRawCount.Observe(float64(rawCount))
|
||||
prefixAggregationAggregatedCount.Observe(float64(aggregatedCount))
|
||||
renderPrefixCount.Observe(float64(aggregatedCount))
|
||||
}
|
||||
|
||||
// RecordPipelineRefresh records module ingest duration.
|
||||
func RecordPipelineRefresh(moduleType string, duration time.Duration) {
|
||||
if moduleType == "" {
|
||||
moduleType = "unknown"
|
||||
}
|
||||
pipelineRefreshDuration.WithLabelValues(moduleType).Observe(duration.Seconds())
|
||||
}
|
||||
|
||||
// RecordJobQueueDepth updates in-process job worker utilization gauges.
|
||||
func RecordJobQueueDepth(active, capacity int) {
|
||||
if active < 0 {
|
||||
active = 0
|
||||
}
|
||||
if capacity < 0 {
|
||||
capacity = 0
|
||||
}
|
||||
jobQueueActive.Set(float64(active))
|
||||
jobQueueCapacity.Set(float64(capacity))
|
||||
}
|
||||
|
||||
// RecordJobTerminal increments jobs_finished_total for terminal statuses.
|
||||
@@ -205,6 +258,35 @@ func SetBirdSessionMetrics(established int, scrapeOK bool) {
|
||||
}
|
||||
}
|
||||
|
||||
// SetBirdProtocolStates caches parsed BGP protocol states from the last birdc scrape.
|
||||
func SetBirdProtocolStates(states map[string]string) {
|
||||
birdProtocolStatesMu.Lock()
|
||||
defer birdProtocolStatesMu.Unlock()
|
||||
if states == nil {
|
||||
birdProtocolStates = map[string]string{}
|
||||
} else {
|
||||
birdProtocolStates = states
|
||||
}
|
||||
birdProtocolStatesAt = time.Now()
|
||||
}
|
||||
|
||||
// CachedBirdProtocolStates returns cached protocol states if younger than maxAge.
|
||||
func CachedBirdProtocolStates(maxAge time.Duration) (map[string]string, bool) {
|
||||
if maxAge <= 0 {
|
||||
maxAge = 60 * time.Second
|
||||
}
|
||||
birdProtocolStatesMu.RLock()
|
||||
defer birdProtocolStatesMu.RUnlock()
|
||||
if birdProtocolStates == nil || time.Since(birdProtocolStatesAt) > maxAge {
|
||||
return nil, false
|
||||
}
|
||||
out := make(map[string]string, len(birdProtocolStates))
|
||||
for k, v := range birdProtocolStates {
|
||||
out[k] = v
|
||||
}
|
||||
return out, true
|
||||
}
|
||||
|
||||
// MetricsHandler returns the Prometheus scrape handler.
|
||||
func MetricsHandler() http.Handler {
|
||||
return promhttp.HandlerFor(prometheus.DefaultGatherer, promhttp.HandlerOpts{})
|
||||
@@ -231,7 +313,7 @@ func (s *statusRecorder) WriteHeader(code int) {
|
||||
|
||||
// StartBirdProtocolsPoller runs birdc "show protocols" on interval when socket is non-empty.
|
||||
// Горутина завершается при отмене ctx (корректное завершение вместе с процессом API).
|
||||
func StartBirdProtocolsPoller(ctx context.Context, socket string, birdcPath string, interval time.Duration, showFn func(ctx context.Context, socket, birdcBin string) (string, error), countFn func(output string) int) {
|
||||
func StartBirdProtocolsPoller(ctx context.Context, socket string, birdcPath string, interval time.Duration, showFn func(ctx context.Context, socket, birdcBin string) (string, error), countFn func(output string) int, parseFn func(output string) map[string]string) {
|
||||
socket = trimSpace(socket)
|
||||
if ctx == nil || socket == "" || interval <= 0 || showFn == nil || countFn == nil {
|
||||
return
|
||||
@@ -245,6 +327,9 @@ func StartBirdProtocolsPoller(ctx context.Context, socket string, birdcPath stri
|
||||
return
|
||||
}
|
||||
SetBirdSessionMetrics(countFn(out), true)
|
||||
if parseFn != nil {
|
||||
SetBirdProtocolStates(parseFn(out))
|
||||
}
|
||||
}
|
||||
go func() {
|
||||
scrape()
|
||||
|
||||
@@ -44,6 +44,7 @@ func resolveASNForEntry(ctx context.Context, st store.Backend, hc *http.Client,
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
asnresolve.PolitePause()
|
||||
holder, _ := asnresolve.ASHolderName(ctx, hc, asn)
|
||||
if st != nil {
|
||||
strs := make([]string, len(pfxs))
|
||||
|
||||
@@ -25,9 +25,6 @@ func cachedCDNPrefixRows(st store.Backend, tenantID, moduleID string, priorSnaps
|
||||
return cached
|
||||
}
|
||||
}
|
||||
if cached := latestCDNRowsBySource(st, tenantID)[sourceKey]; len(cached) > 0 {
|
||||
return cached
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -45,6 +42,35 @@ func mergeSnapshotDropSource(rows []store.PrefixRow, sourceKey string) []store.P
|
||||
return out
|
||||
}
|
||||
|
||||
// mergeSnapshotDropCDNSources removes all cdn:* rows (used before batch CDN merge).
|
||||
func mergeSnapshotDropCDNSources(rows []store.PrefixRow) []store.PrefixRow {
|
||||
if len(rows) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]store.PrefixRow, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
if !strings.HasPrefix(strings.TrimSpace(row.Source), "cdn:") {
|
||||
out = append(out, row)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// mergeAllCDNSourcesIntoModuleSnapshot replaces all CDN rows in one write (avoids parallel read-modify-write races).
|
||||
func mergeAllCDNSourcesIntoModuleSnapshot(st store.Backend, tenantID string, mod *store.Module, priorSnapshot []store.PrefixRow, cdnRows []store.PrefixRow) error {
|
||||
if st == nil || mod == nil {
|
||||
return nil
|
||||
}
|
||||
var base []store.PrefixRow
|
||||
if len(priorSnapshot) > 0 {
|
||||
base = mergeSnapshotDropCDNSources(priorSnapshot)
|
||||
} else if snap, ok, _ := st.GetModulePrefixSnapshot(tenantID, mod.ID); ok && snap != nil {
|
||||
base = mergeSnapshotDropCDNSources(snap.Prefixes)
|
||||
}
|
||||
merged := append(base, cdnRows...)
|
||||
return persistModuleSnapshot(st, tenantID, mod, merged)
|
||||
}
|
||||
|
||||
func cdnRowsFromParsed(mod *store.Module, src *store.CDNSource, pfxStrings []string) []store.PrefixRow {
|
||||
var rows []store.PrefixRow
|
||||
for _, p := range pfxStrings {
|
||||
@@ -126,7 +152,7 @@ func applyCDNSourceHTTPResult(ctx context.Context, st store.Backend, hc *http.Cl
|
||||
return nil, fmt.Errorf("cdn url %s: 304 without cached prefixes", u)
|
||||
}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
@@ -156,3 +182,68 @@ func applyCDNSourceHTTPResult(ctx context.Context, st store.Backend, hc *http.Cl
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// fetchCDNSourceRows loads CDN prefixes without persisting the module snapshot (caller merges once).
|
||||
func fetchCDNSourceRows(ctx context.Context, st store.Backend, hc *http.Client, tenantID, moduleID string, mod *store.Module, src *store.CDNSource, priorSnapshot []store.PrefixRow, now time.Time) ([]store.PrefixRow, error) {
|
||||
u := strings.TrimSpace(src.URL)
|
||||
if u == "" {
|
||||
return nil, nil
|
||||
}
|
||||
sourceKey := cdnSourceKey(src.ID)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if etag := strings.TrimSpace(src.Etag); etag != "" {
|
||||
req.Header.Set("If-None-Match", etag)
|
||||
}
|
||||
resp, err := hc.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cdn fetch %s: %w", u, err)
|
||||
}
|
||||
|
||||
if resp.StatusCode == http.StatusNotModified {
|
||||
if cached := cachedCDNPrefixRows(st, tenantID, moduleID, priorSnapshot, sourceKey); len(cached) > 0 {
|
||||
_ = resp.Body.Close()
|
||||
return cached, nil
|
||||
}
|
||||
_ = resp.Body.Close()
|
||||
req2, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err = hc.Do(req2)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cdn fetch %s: %w", u, err)
|
||||
}
|
||||
if resp.StatusCode == http.StatusNotModified {
|
||||
_ = resp.Body.Close()
|
||||
return nil, fmt.Errorf("cdn url %s: 304 without cached prefixes", u)
|
||||
}
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
return nil, fmt.Errorf("cdn url %s: %s", u, resp.Status)
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
prefixStrs, err := parseCDNBody(string(body), src)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cdn parse %s: %w", u, err)
|
||||
}
|
||||
etag := strings.TrimSpace(resp.Header.Get("ETag"))
|
||||
patch := &store.CDNSourcePatch{}
|
||||
if etag != "" && etag != strings.TrimSpace(src.Etag) {
|
||||
e := etag
|
||||
patch.Etag = &e
|
||||
}
|
||||
refreshedAt := now
|
||||
patch.LastRefreshedAt = &refreshedAt
|
||||
_, _ = st.UpdateCDNSource(tenantID, moduleID, src.ID, patch)
|
||||
|
||||
return cdnRowsFromParsed(mod, src, prefixStrs), nil
|
||||
}
|
||||
|
||||
@@ -97,15 +97,18 @@ func collectASPrefixRows(ctx context.Context, st store.Backend, hc *http.Client,
|
||||
|
||||
seenPfx := make(map[string]struct{})
|
||||
var out []store.PrefixRow
|
||||
var metaUpdates []store.ASEntryResolveMetaUpdate
|
||||
now := time.Now().UTC()
|
||||
for _, r := range results {
|
||||
if r.err != nil {
|
||||
return nil, r.err
|
||||
}
|
||||
if r.metaID != "" {
|
||||
if err := st.UpdateASEntryResolveMeta(tenantID, moduleID, r.metaID, r.holder, r.count, now); err != nil {
|
||||
return nil, fmt.Errorf("as entry meta AS%d: %w", r.asn, err)
|
||||
}
|
||||
metaUpdates = append(metaUpdates, store.ASEntryResolveMetaUpdate{
|
||||
EntryID: r.metaID,
|
||||
ASNName: r.holder,
|
||||
PrefixCount: r.count,
|
||||
})
|
||||
}
|
||||
for _, row := range r.rows {
|
||||
k := row.Prefix
|
||||
@@ -116,6 +119,11 @@ func collectASPrefixRows(ctx context.Context, st store.Backend, hc *http.Client,
|
||||
out = append(out, row)
|
||||
}
|
||||
}
|
||||
if len(metaUpdates) > 0 {
|
||||
if err := st.UpdateASEntryResolveMetaBatch(tenantID, moduleID, metaUpdates, now); err != nil {
|
||||
return nil, fmt.Errorf("as entry meta batch: %w", err)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@@ -150,12 +158,14 @@ func collectCDNPrefixRows(ctx context.Context, st store.Backend, hc *http.Client
|
||||
results[idx] = srcResult{rows: cached}
|
||||
return
|
||||
}
|
||||
if cached := latestCDNRowsBySource(st, tenantID)[sourceKey]; len(cached) > 0 {
|
||||
results[idx] = srcResult{rows: cached}
|
||||
return
|
||||
if snap, ok, _ := st.GetModulePrefixSnapshot(tenantID, moduleID); ok && snap != nil {
|
||||
if cached := prefixRowsForSource(snap.Prefixes, sourceKey); len(cached) > 0 {
|
||||
results[idx] = srcResult{rows: cached}
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
rows, err := applyCDNSourceHTTPResult(ctx, st, hc, tenantID, moduleID, mod, src, priorSnapshot, now)
|
||||
rows, err := fetchCDNSourceRows(ctx, st, hc, tenantID, moduleID, mod, src, priorSnapshot, now)
|
||||
if err != nil {
|
||||
results[idx] = srcResult{err: err}
|
||||
return
|
||||
@@ -172,6 +182,11 @@ func collectCDNPrefixRows(ctx context.Context, st store.Backend, hc *http.Client
|
||||
}
|
||||
out = append(out, r.rows...)
|
||||
}
|
||||
if len(valid) > 0 {
|
||||
if err := mergeAllCDNSourcesIntoModuleSnapshot(st, tenantID, mod, priorSnapshot, out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -46,4 +46,3 @@ func TestBuildPreviewFragments_SamePrefixDifferentCommunity(t *testing.T) {
|
||||
t.Fatalf("expected deterministic static preview text, got first:\n%s\nsecond:\n%s", staticV4, staticV4Second)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ func ParseCIDRLines(body string) []netip.Prefix {
|
||||
return out
|
||||
}
|
||||
|
||||
// ExtractCIDRs parses CIDRs from either plaintext lines or JSON payload.
|
||||
// ExtractCIDRs parses CIDR prefixes from plaintext lines or a JSON payload (see sourceKind and prefixPath).
|
||||
// For sourceKind="json", prefixPath supports dotted traversal, with [] for arrays:
|
||||
// e.g. "prefixes[]", "data.items[].cidr".
|
||||
func ExtractCIDRs(body, sourceKind, prefixPath string) ([]netip.Prefix, error) {
|
||||
|
||||
@@ -36,6 +36,11 @@ const (
|
||||
revisionDefaultTTL = 30 * 24 * time.Hour
|
||||
)
|
||||
|
||||
// AuxBirdFullExpandedKey returns the preview map key for the expanded BIRD config (generated on demand).
|
||||
func AuxBirdFullExpandedKey() string {
|
||||
return auxBirdFullExpanded
|
||||
}
|
||||
|
||||
// MaterializedASPrefixKey returns the revision snapshot key for an AS-only entry (not a CIDR).
|
||||
func MaterializedASPrefixKey(asn int64) string {
|
||||
return fmt.Sprintf("as:%d", asn)
|
||||
@@ -47,10 +52,14 @@ func RefreshModuleIngest(ctx context.Context, st store.Backend, hc *http.Client,
|
||||
if hc == nil {
|
||||
hc = http.DefaultClient
|
||||
}
|
||||
start := time.Now()
|
||||
mod, err := st.GetModule(tenantID, moduleID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
observability.RecordPipelineRefresh(mod.Type, time.Since(start))
|
||||
}()
|
||||
if !mod.Enabled {
|
||||
return fmt.Errorf("pipeline: module disabled")
|
||||
}
|
||||
@@ -197,33 +206,6 @@ func shouldSkipCDNSourceFetch(src *store.CDNSource, now time.Time) bool {
|
||||
return now.UTC().Before(nextRefreshAt)
|
||||
}
|
||||
|
||||
func latestCDNRowsBySource(st store.Backend, tenantID string) map[string][]store.PrefixRow {
|
||||
out := make(map[string][]store.PrefixRow)
|
||||
if st == nil {
|
||||
return out
|
||||
}
|
||||
revs, _, _ := st.ListRevisions(tenantID, "", "", 1)
|
||||
if len(revs) == 0 || strings.TrimSpace(revs[0].ID) == "" {
|
||||
return out
|
||||
}
|
||||
revID := strings.TrimSpace(revs[0].ID)
|
||||
cursor := ""
|
||||
for {
|
||||
page, next, more := st.ListRevisionPrefixes(tenantID, revID, cursor, 2000)
|
||||
for _, row := range page {
|
||||
if !strings.HasPrefix(strings.TrimSpace(row.Source), "cdn:") {
|
||||
continue
|
||||
}
|
||||
out[row.Source] = append(out[row.Source], row)
|
||||
}
|
||||
if !more || strings.TrimSpace(next) == "" {
|
||||
break
|
||||
}
|
||||
cursor = next
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
type dohJSONAnswer struct {
|
||||
Type int `json:"type"`
|
||||
Data string `json:"data"`
|
||||
@@ -306,7 +288,7 @@ func resolveDomainWithDOHMessage(ctx context.Context, hc *http.Client, baseURL,
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
|
||||
return nil, fmt.Errorf("doh dns-message status %s: %s", resp.Status, strings.TrimSpace(string(body)))
|
||||
@@ -378,7 +360,7 @@ func resolveDomainWithDOHJSON(ctx context.Context, hc *http.Client, baseURL, hos
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
|
||||
return nil, fmt.Errorf("doh status %s: %s", resp.Status, strings.TrimSpace(string(body)))
|
||||
@@ -725,15 +707,15 @@ func dedupeSortedPrefixLines(rows []store.PrefixRow) []prefixHashLine {
|
||||
}
|
||||
|
||||
func writePrefixLinesHash(h interface{ Write([]byte) (int, error) }, tenantID string, lines []prefixHashLine) {
|
||||
h.Write([]byte(strings.TrimSpace(tenantID)))
|
||||
h.Write([]byte{0})
|
||||
_, _ = h.Write([]byte(strings.TrimSpace(tenantID)))
|
||||
_, _ = h.Write([]byte{0})
|
||||
for _, l := range lines {
|
||||
h.Write([]byte(l.p))
|
||||
h.Write([]byte{1})
|
||||
h.Write([]byte(l.c))
|
||||
h.Write([]byte{1})
|
||||
h.Write([]byte(l.s))
|
||||
h.Write([]byte{0})
|
||||
_, _ = h.Write([]byte(l.p))
|
||||
_, _ = h.Write([]byte{1})
|
||||
_, _ = h.Write([]byte(l.c))
|
||||
_, _ = h.Write([]byte{1})
|
||||
_, _ = h.Write([]byte(l.s))
|
||||
_, _ = h.Write([]byte{0})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -836,10 +818,18 @@ func buildPreviewFragments(st store.Backend, tenantID, moduleID, revisionID stri
|
||||
px6: birdfmt.JoinFragments(birdfmt.ManagedBanner(revisionID), staticV6),
|
||||
pPeers: peersBody,
|
||||
}
|
||||
out[auxBirdFullExpanded] = buildExpandedBirdText(main, out)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// BuildExpandedBirdPreview concatenates bird.conf and deployable includes for UI preview (not persisted in revision).
|
||||
func BuildExpandedBirdPreview(frags map[string]string) string {
|
||||
if frags == nil {
|
||||
return ""
|
||||
}
|
||||
main := frags["bird.conf"]
|
||||
return buildExpandedBirdText(main, frags)
|
||||
}
|
||||
|
||||
func renderStaticProtocolsByCommunity(groups []staticCommunityRoutes) (string, string) {
|
||||
var b4 strings.Builder
|
||||
var b6 strings.Builder
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"evobgp/internal/db"
|
||||
"evobgp/internal/store"
|
||||
)
|
||||
|
||||
func TestPostgresImplementsBackend(t *testing.T) {
|
||||
var _ store.Backend = (*Postgres)(nil)
|
||||
}
|
||||
|
||||
func TestPostgresPingIntegration(t *testing.T) {
|
||||
dsn := os.Getenv("EVOBGP_TEST_DATABASE_URL")
|
||||
if dsn == "" {
|
||||
t.Skip("EVOBGP_TEST_DATABASE_URL not set")
|
||||
}
|
||||
ctx := context.Background()
|
||||
pool, err := db.OpenPostgresPool(ctx, dsn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer pool.Close()
|
||||
pg, err := NewPostgres(ctx, pool, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := pg.Ping(ctx); err != nil {
|
||||
t.Fatalf("ping: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostgresModuleCRUDIntegration(t *testing.T) {
|
||||
dsn := os.Getenv("EVOBGP_TEST_DATABASE_URL")
|
||||
if dsn == "" {
|
||||
t.Skip("EVOBGP_TEST_DATABASE_URL not set")
|
||||
}
|
||||
ctx := context.Background()
|
||||
pool, err := db.OpenPostgresPool(ctx, dsn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer pool.Close()
|
||||
pg, err := NewPostgres(ctx, pool, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tenant := "01TESTTENANT00000000000001"
|
||||
mod, err := pg.CreateModule(tenant, &store.Module{
|
||||
Name: "audit-test",
|
||||
Type: "IP_RANGES",
|
||||
Enabled: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := pg.GetModule(tenant, mod.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Name != "audit-test" {
|
||||
t.Fatalf("name: got %q", got.Name)
|
||||
}
|
||||
if err := pg.SoftDeleteModule(tenant, mod.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// JobAuditWriter persists async job lifecycle rows to job_audit (optional cross-process queue foundation).
|
||||
type JobAuditWriter struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewJobAuditWriter(pool *pgxpool.Pool) *JobAuditWriter {
|
||||
if pool == nil {
|
||||
return nil
|
||||
}
|
||||
return &JobAuditWriter{pool: pool}
|
||||
}
|
||||
|
||||
// UpsertRunning inserts or updates a running job row (best-effort).
|
||||
func (w *JobAuditWriter) UpsertRunning(ctx context.Context, tenantID, jobID, kind string, idempotencyKey *string, meta map[string]any) {
|
||||
if w == nil || w.pool == nil {
|
||||
return
|
||||
}
|
||||
metaJSON, _ := json.Marshal(meta)
|
||||
var idem any
|
||||
if idempotencyKey != nil && *idempotencyKey != "" {
|
||||
idem = *idempotencyKey
|
||||
}
|
||||
_, _ = w.pool.Exec(ctx, `
|
||||
INSERT INTO job_audit (id, tenant_id, kind, status, idempotency_key, meta_json, created_at, started_at)
|
||||
VALUES ($1::uuid, $2::uuid, $3, 'running', $4, $5::jsonb, now(), now())
|
||||
ON CONFLICT (tenant_id, idempotency_key) WHERE idempotency_key IS NOT NULL
|
||||
DO UPDATE SET status='running', started_at=now(), meta_json=EXCLUDED.meta_json`,
|
||||
jobID, tenantID, kind, idem, metaJSON)
|
||||
}
|
||||
|
||||
// MarkTerminal updates job_audit terminal state (best-effort).
|
||||
func (w *JobAuditWriter) MarkTerminal(ctx context.Context, tenantID, jobID, status string, errMsg *string, finishedAt time.Time) {
|
||||
if w == nil || w.pool == nil {
|
||||
return
|
||||
}
|
||||
_, _ = w.pool.Exec(ctx, `
|
||||
UPDATE job_audit SET status=$3, error_message=$4, finished_at=$5
|
||||
WHERE id=$1::uuid AND tenant_id=$2::uuid`,
|
||||
jobID, tenantID, status, errMsg, finishedAt.UTC())
|
||||
}
|
||||
+133
-28
@@ -28,7 +28,7 @@ func agentDebugNDJSON3214(hypothesisID, location, message string, data map[strin
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
defer func() { _ = f.Close() }()
|
||||
var ms runtime.MemStats
|
||||
runtime.ReadMemStats(&ms)
|
||||
payload := map[string]any{
|
||||
@@ -71,6 +71,11 @@ func (p *Postgres) DemoIDs() (tenant, moduleCDN, moduleIP, revision, speaker str
|
||||
return p.demoTenant, p.demoCDN, p.demoIP, p.demoRev, p.demoSpk
|
||||
}
|
||||
|
||||
// Ping checks PostgreSQL connectivity.
|
||||
func (p *Postgres) Ping(ctx context.Context) error {
|
||||
return p.pool.Ping(ctx)
|
||||
}
|
||||
|
||||
func (p *Postgres) MaterializedPrefixStats() (max int, sum int) {
|
||||
ctx := context.Background()
|
||||
// Агрегация в БД — не тащим все строки config_revision в память.
|
||||
@@ -121,6 +126,7 @@ func (p *Postgres) ListModules(tenantID string) []*store.Module {
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []*store.Module
|
||||
moduleByID := make(map[string]*store.Module)
|
||||
for rows.Next() {
|
||||
var m store.Module
|
||||
m.TenantID = tenantID
|
||||
@@ -147,14 +153,84 @@ func (p *Postgres) ListModules(tenantID string) []*store.Module {
|
||||
t := last.UTC()
|
||||
m.LastRefreshedAt = &t
|
||||
}
|
||||
if err := p.fillModuleDohFields(ctx, &m); err != nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, &m)
|
||||
moduleByID[m.ID] = &m
|
||||
}
|
||||
if err := p.batchFillModuleDohFields(ctx, moduleByID); err != nil {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (p *Postgres) ListModulesPage(tenantID, cursor string, limit int) ([]*store.Module, string, bool) {
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
off := 0
|
||||
if cursor != "" {
|
||||
if n, err := strconv.Atoi(cursor); err == nil && n >= 0 {
|
||||
off = n
|
||||
}
|
||||
}
|
||||
ctx := context.Background()
|
||||
rows, err := p.pool.Query(ctx, `
|
||||
SELECT id, type, name, enabled, priority, doh_profile_id::text, doh_resolver_policy,
|
||||
refresh_interval_sec, cron_expr, default_community_id::text, last_refreshed_at
|
||||
FROM module WHERE tenant_id = $1 AND deleted_at IS NULL
|
||||
ORDER BY priority, name
|
||||
LIMIT $2 OFFSET $3`, tenantID, limit+1, off)
|
||||
if err != nil {
|
||||
return nil, "", false
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []*store.Module
|
||||
moduleByID := make(map[string]*store.Module)
|
||||
for rows.Next() {
|
||||
var m store.Module
|
||||
m.TenantID = tenantID
|
||||
var doh, dc, cron *string
|
||||
var refresh *int32
|
||||
var last *time.Time
|
||||
if err := rows.Scan(&m.ID, &m.Type, &m.Name, &m.Enabled, &m.Priority, &doh, &m.DohResolverPolicy, &refresh, &cron, &dc, &last); err != nil {
|
||||
continue
|
||||
}
|
||||
m.DohResolverPolicy = store.NormalizeDohResolverPolicy(m.DohResolverPolicy)
|
||||
if refresh != nil {
|
||||
m.RefreshIntervalSec = int(*refresh)
|
||||
}
|
||||
if cron != nil {
|
||||
m.CronExpr = *cron
|
||||
}
|
||||
if doh != nil && *doh != "" {
|
||||
m.DohProfileID = doh
|
||||
}
|
||||
if dc != nil && *dc != "" {
|
||||
m.DefaultCommunityID = dc
|
||||
}
|
||||
if last != nil {
|
||||
t := last.UTC()
|
||||
m.LastRefreshedAt = &t
|
||||
}
|
||||
out = append(out, &m)
|
||||
moduleByID[m.ID] = &m
|
||||
}
|
||||
if err := p.batchFillModuleDohFields(ctx, moduleByID); err != nil {
|
||||
return nil, "", false
|
||||
}
|
||||
more := len(out) > limit
|
||||
if more {
|
||||
out = out[:limit]
|
||||
}
|
||||
next := ""
|
||||
if more {
|
||||
next = fmt.Sprintf("%d", off+limit)
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil, "", false
|
||||
}
|
||||
return out, next, more
|
||||
}
|
||||
|
||||
func (p *Postgres) GetModule(tenantID, moduleID string) (*store.Module, error) {
|
||||
ctx := context.Background()
|
||||
var m store.Module
|
||||
@@ -680,7 +756,13 @@ func (p *Postgres) ListRevisionPrefixes(tenantID, revisionID string, cursor stri
|
||||
}
|
||||
}
|
||||
ctx := context.Background()
|
||||
if _, err := p.GetRevision(tenantID, revisionID); err != nil {
|
||||
var one int
|
||||
if err := p.pool.QueryRow(ctx, `
|
||||
SELECT 1 FROM config_revision WHERE id = $1::uuid AND tenant_id = $2::uuid`,
|
||||
revisionID, tenantID).Scan(&one); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, "", false
|
||||
}
|
||||
return nil, "", false
|
||||
}
|
||||
rows, err := p.pool.Query(ctx, `
|
||||
@@ -749,6 +831,8 @@ func (p *Postgres) CreateRollbackRevision(tenantID, sourceRevisionID string) (st
|
||||
return newID, nil
|
||||
}
|
||||
|
||||
const maxRevisionDiffRows = 5000
|
||||
|
||||
func (p *Postgres) RevisionDiff(tenantID, aID, bID string) (map[string]any, error) {
|
||||
if _, err := p.GetRevision(tenantID, aID); err != nil {
|
||||
return nil, err
|
||||
@@ -773,7 +857,7 @@ func (p *Postgres) RevisionDiff(tenantID, aID, bID string) (map[string]any, erro
|
||||
SELECT prefix FROM revision_materialized_prefix WHERE revision_id=$1::uuid
|
||||
EXCEPT
|
||||
SELECT prefix FROM revision_materialized_prefix WHERE revision_id=$2::uuid
|
||||
) s ORDER BY 1`, bID, aID)
|
||||
) s ORDER BY 1 LIMIT $3`, bID, aID, maxRevisionDiffRows+1)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -785,13 +869,18 @@ func (p *Postgres) RevisionDiff(tenantID, aID, bID string) (map[string]any, erro
|
||||
continue
|
||||
}
|
||||
added = append(added, s)
|
||||
if len(added) > maxRevisionDiffRows {
|
||||
added = added[:maxRevisionDiffRows]
|
||||
break
|
||||
}
|
||||
}
|
||||
addedTruncated := len(added) >= maxRevisionDiffRows
|
||||
rowsRem, err := p.pool.Query(ctx, `
|
||||
SELECT prefix::text FROM (
|
||||
SELECT prefix FROM revision_materialized_prefix WHERE revision_id=$1::uuid
|
||||
EXCEPT
|
||||
SELECT prefix FROM revision_materialized_prefix WHERE revision_id=$2::uuid
|
||||
) s ORDER BY 1`, aID, bID)
|
||||
) s ORDER BY 1 LIMIT $3`, aID, bID, maxRevisionDiffRows+1)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -803,40 +892,56 @@ func (p *Postgres) RevisionDiff(tenantID, aID, bID string) (map[string]any, erro
|
||||
continue
|
||||
}
|
||||
removed = append(removed, s)
|
||||
if len(removed) > maxRevisionDiffRows {
|
||||
removed = removed[:maxRevisionDiffRows]
|
||||
break
|
||||
}
|
||||
}
|
||||
return map[string]any{
|
||||
"revision_a": aID,
|
||||
"revision_b": bID,
|
||||
"prefixes": map[string]any{
|
||||
"added": added, "removed": removed, "unchanged_count": unchanged,
|
||||
"truncated": addedTruncated || len(removed) >= maxRevisionDiffRows,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *Postgres) PruneRevisionsBefore(tenantID string, cutoff time.Time) (int, error) {
|
||||
ctx := context.Background()
|
||||
cmd, err := p.pool.Exec(ctx, `
|
||||
DELETE FROM config_revision AS cr
|
||||
WHERE cr.tenant_id = $1
|
||||
AND cr.created_at < $2
|
||||
AND cr.id <> (
|
||||
SELECT id
|
||||
FROM config_revision
|
||||
WHERE tenant_id = $1
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM bgp_speaker AS sp
|
||||
WHERE sp.tenant_id = $1
|
||||
AND (sp.last_applied_revision_id = cr.id OR sp.published_revision_id = cr.id)
|
||||
)`,
|
||||
tenantID, cutoff.UTC())
|
||||
if err != nil {
|
||||
return 0, err
|
||||
total := 0
|
||||
const batchSize = 50
|
||||
for {
|
||||
cmd, err := p.pool.Exec(ctx, `
|
||||
DELETE FROM config_revision AS cr
|
||||
WHERE cr.id IN (
|
||||
SELECT id FROM config_revision
|
||||
WHERE tenant_id = $1
|
||||
AND created_at < $2
|
||||
AND id <> (
|
||||
SELECT id FROM config_revision
|
||||
WHERE tenant_id = $1
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM bgp_speaker AS sp
|
||||
WHERE sp.tenant_id = $1
|
||||
AND (sp.last_applied_revision_id = config_revision.id OR sp.published_revision_id = config_revision.id)
|
||||
)
|
||||
ORDER BY created_at ASC
|
||||
LIMIT $3
|
||||
)`, tenantID, cutoff.UTC(), batchSize)
|
||||
if err != nil {
|
||||
return total, err
|
||||
}
|
||||
n := int(cmd.RowsAffected())
|
||||
total += n
|
||||
if n < batchSize {
|
||||
break
|
||||
}
|
||||
}
|
||||
return int(cmd.RowsAffected()), nil
|
||||
return total, nil
|
||||
}
|
||||
|
||||
func (p *Postgres) SetLastAppliedRevision(tenantID, speakerID, revisionID string) error {
|
||||
|
||||
@@ -325,6 +325,38 @@ func (p *Postgres) UpdateASEntryResolveMeta(tenantID, moduleID, entryID string,
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Postgres) UpdateASEntryResolveMetaBatch(tenantID, moduleID string, updates []store.ASEntryResolveMetaUpdate, resolvedAt time.Time) error {
|
||||
if len(updates) == 0 {
|
||||
return nil
|
||||
}
|
||||
if _, err := p.GetModule(tenantID, moduleID); err != nil {
|
||||
return err
|
||||
}
|
||||
ctx := context.Background()
|
||||
batch := &pgx.Batch{}
|
||||
for _, u := range updates {
|
||||
var nameArg any
|
||||
sn := strings.TrimSpace(u.ASNName)
|
||||
if sn == "" {
|
||||
nameArg = nil
|
||||
} else {
|
||||
nameArg = sn
|
||||
}
|
||||
batch.Queue(`
|
||||
UPDATE module_as_entry SET asn_name=$3, prefix_count=$4, asn_resolved_at=$5, updated_at=now()
|
||||
WHERE id=$1 AND module_id=$2`,
|
||||
u.EntryID, moduleID, nameArg, u.PrefixCount, resolvedAt.UTC())
|
||||
}
|
||||
br := p.pool.SendBatch(ctx, batch)
|
||||
defer func() { _ = br.Close() }()
|
||||
for range updates {
|
||||
if _, err := br.Exec(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Postgres) DeleteASEntry(tenantID, moduleID, entryID string) error {
|
||||
if _, err := p.GetModule(tenantID, moduleID); err != nil {
|
||||
return err
|
||||
|
||||
@@ -10,28 +10,41 @@ func (p *Postgres) fillModuleDohFields(ctx context.Context, m *store.Module) err
|
||||
if m == nil {
|
||||
return nil
|
||||
}
|
||||
return p.batchFillModuleDohFields(ctx, map[string]*store.Module{m.ID: m})
|
||||
}
|
||||
|
||||
func (p *Postgres) batchFillModuleDohFields(ctx context.Context, modules map[string]*store.Module) error {
|
||||
if len(modules) == 0 {
|
||||
return nil
|
||||
}
|
||||
ids := make([]string, 0, len(modules))
|
||||
for id := range modules {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
rows, err := p.pool.Query(ctx, `
|
||||
SELECT doh_profile_id::text
|
||||
SELECT module_id::text, doh_profile_id::text
|
||||
FROM module_doh_profile
|
||||
WHERE module_id = $1
|
||||
ORDER BY sort_order, doh_profile_id`, m.ID)
|
||||
WHERE module_id = ANY($1::uuid[])
|
||||
ORDER BY module_id, sort_order, doh_profile_id`, ids)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
var ids []string
|
||||
byModule := make(map[string][]string, len(modules))
|
||||
for rows.Next() {
|
||||
var id string
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
var moduleID, profileID string
|
||||
if err := rows.Scan(&moduleID, &profileID); err != nil {
|
||||
return err
|
||||
}
|
||||
ids = append(ids, id)
|
||||
byModule[moduleID] = append(byModule[moduleID], profileID)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
m.DohProfileIDs = store.NormalizeDohProfileIDList(ids)
|
||||
m.SyncLegacyDohProfileID()
|
||||
for id, m := range modules {
|
||||
m.DohProfileIDs = store.NormalizeDohProfileIDList(byModule[id])
|
||||
m.SyncLegacyDohProfileID()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -121,7 +121,7 @@ func postTenantRefresh(ctx context.Context, deps *Deps, moduleIDs []string, idem
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode == http.StatusNoContent || resp.StatusCode == http.StatusAccepted {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
package store
|
||||
|
||||
// ASEntryResolveMetaUpdate is one row for batch AS resolve metadata writes.
|
||||
type ASEntryResolveMetaUpdate struct {
|
||||
EntryID string
|
||||
ASNName string
|
||||
PrefixCount int64
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
package store
|
||||
|
||||
import "time"
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Backend is the persistence abstraction for the control plane (memory, PostgreSQL, SQLite).
|
||||
type Backend interface {
|
||||
@@ -15,6 +18,8 @@ type Backend interface {
|
||||
|
||||
// ListModules returns all modules for a tenant (control plane may paginate in httpapi).
|
||||
ListModules(tenantID string) []*Module
|
||||
// ListModulesPage returns one page of modules (limit capped by caller).
|
||||
ListModulesPage(tenantID, cursor string, limit int) ([]*Module, string, bool)
|
||||
GetModule(tenantID, moduleID string) (*Module, error)
|
||||
CreateModule(tenantID string, in *Module) (*Module, error)
|
||||
UpdateModule(tenantID, moduleID string, patch *ModulePatch) (*Module, error)
|
||||
@@ -31,6 +36,7 @@ type Backend interface {
|
||||
DeleteASEntry(tenantID, moduleID, entryID string) error
|
||||
// UpdateASEntryResolveMeta записывает имя AS, число объявленных префиксов и время успешного резолва (pipeline).
|
||||
UpdateASEntryResolveMeta(tenantID, moduleID, entryID string, asnName string, prefixCount int64, resolvedAt time.Time) error
|
||||
UpdateASEntryResolveMetaBatch(tenantID, moduleID string, updates []ASEntryResolveMetaUpdate, resolvedAt time.Time) error
|
||||
|
||||
ListDomainEntries(tenantID, moduleID string) ([]*DomainEntry, error)
|
||||
CreateDomainEntry(tenantID, moduleID string, in *DomainEntry) (*DomainEntry, error)
|
||||
@@ -90,6 +96,9 @@ type Backend interface {
|
||||
// ASNPrefixCache stores RIPEstat announced-prefixes per ASN (global TTL cache).
|
||||
GetASNPrefixCache(asn int64) (*ASNPrefixCacheEntry, bool, error)
|
||||
SetASNPrefixCache(asn int64, holder string, prefixes []string) error
|
||||
|
||||
// Ping verifies backend connectivity (no-op for in-memory).
|
||||
Ping(ctx context.Context) error
|
||||
}
|
||||
|
||||
// ASNPrefixCacheEntry is a cached RIPEstat response for one ASN.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
@@ -32,13 +33,13 @@ type Memory struct {
|
||||
|
||||
peers map[string]*BGPPeer
|
||||
|
||||
dohProfiles map[string]*DohProfile
|
||||
communities map[string]*Community
|
||||
cdnSources map[string]*CDNSource
|
||||
asEntries map[string]*ASEntry
|
||||
domainEnt map[string]*DomainEntry
|
||||
ipRanges map[string]*IPRangeEntry
|
||||
settings map[string]map[string]any // tenantID -> key -> JSON-compatible value
|
||||
dohProfiles map[string]*DohProfile
|
||||
communities map[string]*Community
|
||||
cdnSources map[string]*CDNSource
|
||||
asEntries map[string]*ASEntry
|
||||
domainEnt map[string]*DomainEntry
|
||||
ipRanges map[string]*IPRangeEntry
|
||||
settings map[string]map[string]any // tenantID -> key -> JSON-compatible value
|
||||
revPrefixes map[string][]PrefixRow
|
||||
moduleSnapshots map[string]*moduleSnapshotRec
|
||||
asnPrefixCache map[int64]*ASNPrefixCacheEntry
|
||||
@@ -291,6 +292,12 @@ func (m *Memory) DemoIDs() (tenant, moduleCDN, moduleIP, revision, speaker strin
|
||||
return m.demoTenantID, m.demoModuleCDN, m.demoModuleIP, m.demoRevisionID, m.demoSpeakerID
|
||||
}
|
||||
|
||||
// Ping is a no-op for the in-memory backend.
|
||||
func (m *Memory) Ping(ctx context.Context) error {
|
||||
_ = ctx
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListTenantIDs returns tenant ids sorted lexicographically.
|
||||
func (m *Memory) ListTenantIDs() ([]string, error) {
|
||||
m.mu.RLock()
|
||||
@@ -375,6 +382,11 @@ func (m *Memory) ListModules(tenantID string) []*Module {
|
||||
return out
|
||||
}
|
||||
|
||||
func (m *Memory) ListModulesPage(tenantID, cursor string, limit int) ([]*Module, string, bool) {
|
||||
all := m.ListModules(tenantID)
|
||||
return PaginateOffset(all, cursor, limit)
|
||||
}
|
||||
|
||||
// ListPeers returns BGP peers for a tenant (sorted by name).
|
||||
func (m *Memory) ListPeers(tenantID string) []*BGPPeer {
|
||||
m.mu.RLock()
|
||||
|
||||
@@ -301,6 +301,15 @@ func (m *Memory) UpdateASEntryResolveMeta(tenantID, moduleID, entryID string, as
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Memory) UpdateASEntryResolveMetaBatch(tenantID, moduleID string, updates []ASEntryResolveMetaUpdate, resolvedAt time.Time) error {
|
||||
for _, u := range updates {
|
||||
if err := m.UpdateASEntryResolveMeta(tenantID, moduleID, u.EntryID, u.ASNName, u.PrefixCount, resolvedAt); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Memory) DeleteASEntry(tenantID, moduleID, entryID string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
@@ -19,9 +19,9 @@ func TestEffectivePeerEnabledOnCreate(t *testing.T) {
|
||||
|
||||
func TestParsePeerNeighbor(t *testing.T) {
|
||||
tests := []struct {
|
||||
in string
|
||||
want string
|
||||
wantOK bool
|
||||
in string
|
||||
want string
|
||||
wantOK bool
|
||||
}{
|
||||
{"192.168.0.2", "192.168.0.2", true},
|
||||
{"192.168.0.2/32", "192.168.0.2", true},
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
DROP INDEX IF EXISTS idx_rev_mat_prefix_rev_id;
|
||||
DROP INDEX IF EXISTS idx_config_revision_tenant_module_created;
|
||||
DROP INDEX IF EXISTS idx_bgp_speaker_published;
|
||||
DROP INDEX IF EXISTS idx_bgp_speaker_last_applied;
|
||||
DROP INDEX IF EXISTS idx_module_default_community;
|
||||
DROP INDEX IF EXISTS idx_module_doh_profile_id;
|
||||
@@ -0,0 +1,17 @@
|
||||
CREATE INDEX IF NOT EXISTS idx_module_doh_profile_id
|
||||
ON module (doh_profile_id) WHERE deleted_at IS NULL AND doh_profile_id IS NOT NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_module_default_community
|
||||
ON module (default_community_id) WHERE deleted_at IS NULL AND default_community_id IS NOT NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_bgp_speaker_last_applied
|
||||
ON bgp_speaker (last_applied_revision_id) WHERE last_applied_revision_id IS NOT NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_bgp_speaker_published
|
||||
ON bgp_speaker (published_revision_id) WHERE published_revision_id IS NOT NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_config_revision_tenant_module_created
|
||||
ON config_revision (tenant_id, module_id, created_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_rev_mat_prefix_rev_id
|
||||
ON revision_materialized_prefix (revision_id, id);
|
||||
@@ -0,0 +1,6 @@
|
||||
DROP INDEX IF EXISTS idx_rev_mat_prefix_rev_id;
|
||||
DROP INDEX IF EXISTS idx_config_revision_tenant_module_created;
|
||||
DROP INDEX IF EXISTS idx_bgp_speaker_published;
|
||||
DROP INDEX IF EXISTS idx_bgp_speaker_last_applied;
|
||||
DROP INDEX IF EXISTS idx_module_default_community;
|
||||
DROP INDEX IF EXISTS idx_module_doh_profile_id;
|
||||
@@ -0,0 +1,17 @@
|
||||
CREATE INDEX IF NOT EXISTS idx_module_doh_profile_id
|
||||
ON module (doh_profile_id) WHERE deleted_at IS NULL AND doh_profile_id IS NOT NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_module_default_community
|
||||
ON module (default_community_id) WHERE deleted_at IS NULL AND default_community_id IS NOT NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_bgp_speaker_last_applied
|
||||
ON bgp_speaker (last_applied_revision_id) WHERE last_applied_revision_id IS NOT NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_bgp_speaker_published
|
||||
ON bgp_speaker (published_revision_id) WHERE published_revision_id IS NOT NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_config_revision_tenant_module_created
|
||||
ON config_revision (tenant_id, module_id, created_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_rev_mat_prefix_rev_id
|
||||
ON revision_materialized_prefix (revision_id, id);
|
||||
@@ -0,0 +1,74 @@
|
||||
#!/usr/bin/env sh
|
||||
# DEP-03: postgres and sqlite migration sets must have matching numbered pairs.
|
||||
set -eu
|
||||
|
||||
ROOT="$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)"
|
||||
PG="$ROOT/migrations/postgres"
|
||||
SQL="$ROOT/migrations/sqlite"
|
||||
|
||||
list_nums() {
|
||||
dir="$1"
|
||||
ls "$dir" 2>/dev/null | sed -n 's/^\([0-9]\{6\}\)_.*\.up\.sql$/\1/p' | sort -u
|
||||
}
|
||||
|
||||
migration_only_in() {
|
||||
# Prints numbers present in $1 but not in $2 (space-separated lists).
|
||||
haystack="$2 "
|
||||
for n in $1; do
|
||||
case "$haystack" in
|
||||
*" $n "*) ;;
|
||||
*) echo "$n" ;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
pg_nums="$(list_nums "$PG")"
|
||||
sql_nums="$(list_nums "$SQL")"
|
||||
|
||||
if [ "$pg_nums" != "$sql_nums" ]; then
|
||||
echo "check-migrations-pair: postgres and sqlite migration numbers differ" >&2
|
||||
only_pg="$(migration_only_in "$pg_nums" "$sql_nums")"
|
||||
only_sql="$(migration_only_in "$sql_nums" "$pg_nums")"
|
||||
if [ -n "$only_pg" ]; then
|
||||
echo "postgres only:" >&2
|
||||
for n in $only_pg; do
|
||||
echo " $n" >&2
|
||||
done
|
||||
fi
|
||||
if [ -n "$only_sql" ]; then
|
||||
echo "sqlite only:" >&2
|
||||
for n in $only_sql; do
|
||||
echo " $n" >&2
|
||||
done
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
|
||||
for n in $pg_nums; do
|
||||
pg_up=""
|
||||
sql_up=""
|
||||
for f in "$PG"/${n}_*.up.sql; do
|
||||
if [ -f "$f" ]; then
|
||||
pg_up="$f"
|
||||
break
|
||||
fi
|
||||
done
|
||||
for f in "$SQL"/${n}_*.up.sql; do
|
||||
if [ -f "$f" ]; then
|
||||
sql_up="$f"
|
||||
break
|
||||
fi
|
||||
done
|
||||
if [ -z "$pg_up" ] || [ -z "$sql_up" ]; then
|
||||
echo "check-migrations-pair: missing .up.sql for $n" >&2
|
||||
exit 1
|
||||
fi
|
||||
pg_base="$(basename "$pg_up" .up.sql)"
|
||||
sql_base="$(basename "$sql_up" .up.sql)"
|
||||
if [ "$pg_base" != "$sql_base" ]; then
|
||||
echo "check-migrations-pair: name mismatch for $n: $pg_base vs $sql_base" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
echo "check-migrations-pair: ok ($PG and $SQL)"
|
||||
@@ -16,6 +16,14 @@ if grep -rE 'writeProblem\(w, http\.StatusBadGateway.*err\.Error\(\)' "$HTTPAPI"
|
||||
FAIL=1
|
||||
fi
|
||||
|
||||
echo "==> ERR-01: no err.Error() in 4xx writeProblem (store/cdn/csv)"
|
||||
if grep -rE 'writeProblem\(w, http\.Status(NotFound|UnprocessableEntity|BadRequest).*, err\.Error\(\)' "$HTTPAPI" 2>/dev/null; then
|
||||
FAIL=1
|
||||
fi
|
||||
if grep -rE 'writeStoreErr.*err\.Error|writeProblem.*Unprocessable.*err\.Error' "$HTTPAPI" 2>/dev/null; then
|
||||
FAIL=1
|
||||
fi
|
||||
|
||||
echo "==> ARCH-01: no SQL/pgx queries in httpapi"
|
||||
if grep -rE 'pool\.(Query|Exec|QueryRow)|SELECT |INSERT INTO |UPDATE .* SET |DELETE FROM ' "$HTTPAPI" 2>/dev/null; then
|
||||
FAIL=1
|
||||
|
||||
Generated
+2
-3
@@ -14,7 +14,8 @@
|
||||
"svelte-sonner": "^1.1.0",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwind-variants": "^3.2.2",
|
||||
"tw-animate-css": "^1.4.0"
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@internationalized/date": "^3.12.0",
|
||||
@@ -3137,9 +3138,7 @@
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
|
||||
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
|
||||
+2
-1
@@ -39,6 +39,7 @@
|
||||
"svelte-sonner": "^1.1.0",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwind-variants": "^3.2.2",
|
||||
"tw-animate-css": "^1.4.0"
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"zod": "^4.4.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
<script lang="ts">
|
||||
import { apiMutate } from '$lib/api/client.js';
|
||||
import type { BgpCommunity, BgpCommunityCreate } from '$lib/api/types.js';
|
||||
import { Button } from '$lib/ui/core/button/index.js';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription
|
||||
} from '$lib/ui/core/card/index.js';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter
|
||||
} from '$lib/ui/core/dialog/index.js';
|
||||
import FormField from '$lib/ui/patterns/form/form-field.svelte';
|
||||
import AppInput from '$lib/ui/patterns/form/app-input.svelte';
|
||||
import AppDataTable from '$lib/ui/patterns/data-table/app-data-table.svelte';
|
||||
import { confirm } from '$lib/ui/patterns/confirm/confirm-state.svelte.js';
|
||||
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
|
||||
import Plus from '@lucide/svelte/icons/plus';
|
||||
import Pencil from '@lucide/svelte/icons/pencil';
|
||||
import Trash2 from '@lucide/svelte/icons/trash-2';
|
||||
|
||||
type Props = {
|
||||
items: BgpCommunity[];
|
||||
loading?: boolean;
|
||||
initialLoading?: boolean;
|
||||
error?: string | null;
|
||||
onRefresh: () => void | Promise<void>;
|
||||
};
|
||||
|
||||
let { items, loading = false, initialLoading = false, error = null, onRefresh }: Props = $props();
|
||||
|
||||
let dialogOpen = $state(false);
|
||||
let editTarget = $state<BgpCommunity | null>(null);
|
||||
let form = $state<BgpCommunityCreate>({ community: '', title: '' });
|
||||
let saving = $state(false);
|
||||
|
||||
const columns = [
|
||||
{
|
||||
id: 'community',
|
||||
label: 'Код сообщества',
|
||||
sortable: true,
|
||||
sortValue: (c: BgpCommunity) => c.community
|
||||
},
|
||||
{
|
||||
id: 'title',
|
||||
label: 'Название',
|
||||
sortable: true,
|
||||
sortValue: (c: BgpCommunity) => c.title ?? ''
|
||||
},
|
||||
{ id: 'id', label: 'ID' },
|
||||
{ id: 'actions', label: '', class: 'w-20' }
|
||||
] as const;
|
||||
|
||||
function displayName(c: BgpCommunity | null) {
|
||||
if (!c) return '';
|
||||
const t = c.title?.trim();
|
||||
return t || c.community;
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
editTarget = null;
|
||||
form = { community: '', title: '' };
|
||||
dialogOpen = true;
|
||||
}
|
||||
|
||||
function openEdit(c: BgpCommunity) {
|
||||
editTarget = c;
|
||||
form = { community: c.community, title: c.title ?? '' };
|
||||
dialogOpen = true;
|
||||
}
|
||||
|
||||
function requestDelete(c: BgpCommunity) {
|
||||
void confirm({
|
||||
title: `Удалить сообщество «${displayName(c)}»?`,
|
||||
description: 'Это приведёт к удалению привязки во всех модулях.',
|
||||
confirmLabel: 'Удалить',
|
||||
destructive: true,
|
||||
onConfirm: async () => {
|
||||
await apiMutate(`/v1/communities/${c.id}`, 'DELETE', undefined, { idempotent: false });
|
||||
notify.success('Удалено');
|
||||
await onRefresh();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!form.community.trim()) {
|
||||
notify.error('Укажите community');
|
||||
return;
|
||||
}
|
||||
saving = true;
|
||||
try {
|
||||
const body = { ...form, title: form.title?.trim() || undefined };
|
||||
if (editTarget) {
|
||||
await apiMutate(`/v1/communities/${editTarget.id}`, 'PATCH', body);
|
||||
notify.success('Запись сообщества обновлена');
|
||||
} else {
|
||||
await apiMutate('/v1/communities', 'POST', body);
|
||||
notify.success('Сообщество создано');
|
||||
}
|
||||
dialogOpen = false;
|
||||
await onRefresh();
|
||||
} catch (e) {
|
||||
notifyApiError(e);
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Card>
|
||||
<CardHeader
|
||||
class="flex flex-col gap-3 border-b py-3 sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<div class="min-w-0 flex-1">
|
||||
<CardTitle class="text-base">Сообщества BGP</CardTitle>
|
||||
<CardDescription>Используются для тегирования префиксов в AS- и CDN-модулях</CardDescription>
|
||||
</div>
|
||||
<div class="flex shrink-0 flex-wrap items-center justify-end gap-2">
|
||||
<Button size="sm" onclick={openCreate}><Plus />Добавить</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent class="p-4 pt-0">
|
||||
<AppDataTable
|
||||
columns={[...columns]}
|
||||
rows={items}
|
||||
rowKey={(c) => c.id}
|
||||
loading={initialLoading || loading}
|
||||
{error}
|
||||
emptyTitle="Нет сообществ BGP"
|
||||
emptyDescription="Создайте первое сообщество для тегирования префиксов."
|
||||
>
|
||||
{#snippet cell({ row: c, column })}
|
||||
{#if column.id === 'community'}
|
||||
<span class="font-mono text-sm font-medium">{c.community}</span>
|
||||
{:else if column.id === 'title'}
|
||||
<span>{c.title?.trim() || '—'}</span>
|
||||
{:else if column.id === 'id'}
|
||||
<span class="font-mono text-xs text-muted-foreground">{c.id}</span>
|
||||
{:else if column.id === 'actions'}
|
||||
<div class="flex gap-1">
|
||||
<Button variant="ghost" size="icon-sm" onclick={() => openEdit(c)}>
|
||||
<Pencil class="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
class="text-destructive"
|
||||
onclick={() => requestDelete(c)}
|
||||
>
|
||||
<Trash2 class="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</AppDataTable>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Dialog bind:open={dialogOpen}>
|
||||
<DialogContent class="sm:max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle
|
||||
>{editTarget ? 'Редактировать сообщество BGP' : 'Новое сообщество BGP'}</DialogTitle
|
||||
>
|
||||
</DialogHeader>
|
||||
<div class="flex flex-col gap-4 py-2">
|
||||
<FormField label="Код сообщества" id="c-community" required>
|
||||
<AppInput id="c-community" bind:value={form.community} placeholder="65001:120" />
|
||||
</FormField>
|
||||
<FormField label="Название" id="c-title" description="Человекочитаемое имя для списков">
|
||||
<AppInput id="c-title" bind:value={form.title} placeholder="Название" />
|
||||
</FormField>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onclick={() => (dialogOpen = false)}>Отмена</Button>
|
||||
<Button onclick={save} disabled={saving}>
|
||||
{saving ? 'Сохранение…' : editTarget ? 'Сохранить' : 'Создать'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
@@ -0,0 +1,178 @@
|
||||
<script lang="ts">
|
||||
import { apiMutate } from '$lib/api/client.js';
|
||||
import type { DohProfile, DohProfileCreate } from '$lib/api/types.js';
|
||||
import { Button } from '$lib/ui/core/button/index.js';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription
|
||||
} from '$lib/ui/core/card/index.js';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter
|
||||
} from '$lib/ui/core/dialog/index.js';
|
||||
import FormField from '$lib/ui/patterns/form/form-field.svelte';
|
||||
import AppInput from '$lib/ui/patterns/form/app-input.svelte';
|
||||
import AppDataTable from '$lib/ui/patterns/data-table/app-data-table.svelte';
|
||||
import { confirm } from '$lib/ui/patterns/confirm/confirm-state.svelte.js';
|
||||
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
|
||||
import Plus from '@lucide/svelte/icons/plus';
|
||||
import Pencil from '@lucide/svelte/icons/pencil';
|
||||
import Trash2 from '@lucide/svelte/icons/trash-2';
|
||||
|
||||
type Props = {
|
||||
items: DohProfile[];
|
||||
loading?: boolean;
|
||||
initialLoading?: boolean;
|
||||
error?: string | null;
|
||||
onRefresh: () => void | Promise<void>;
|
||||
};
|
||||
|
||||
let { items, loading = false, initialLoading = false, error = null, onRefresh }: Props = $props();
|
||||
|
||||
let dialogOpen = $state(false);
|
||||
let editTarget = $state<DohProfile | null>(null);
|
||||
let form = $state<DohProfileCreate & { timeout_ms?: number | null }>({
|
||||
url: '',
|
||||
timeout_ms: null,
|
||||
vault_secret_ref: null
|
||||
});
|
||||
let saving = $state(false);
|
||||
|
||||
const columns = [
|
||||
{ id: 'url', label: 'URL', sortable: true, sortValue: (d: DohProfile) => d.url },
|
||||
{
|
||||
id: 'timeout_ms',
|
||||
label: 'Таймаут (мс)',
|
||||
sortable: true,
|
||||
sortValue: (d: DohProfile) => d.timeout_ms ?? 0
|
||||
},
|
||||
{ id: 'id', label: 'ID' },
|
||||
{ id: 'actions', label: '', class: 'w-20' }
|
||||
] as const;
|
||||
|
||||
function openCreate() {
|
||||
editTarget = null;
|
||||
form = { url: '', timeout_ms: null, vault_secret_ref: null };
|
||||
dialogOpen = true;
|
||||
}
|
||||
|
||||
function openEdit(d: DohProfile) {
|
||||
editTarget = d;
|
||||
form = { url: d.url, timeout_ms: d.timeout_ms, vault_secret_ref: d.vault_secret_ref };
|
||||
dialogOpen = true;
|
||||
}
|
||||
|
||||
function requestDelete(d: DohProfile) {
|
||||
void confirm({
|
||||
title: 'Удалить DoH профиль?',
|
||||
description: d.url,
|
||||
confirmLabel: 'Удалить',
|
||||
destructive: true,
|
||||
onConfirm: async () => {
|
||||
await apiMutate(`/v1/doh-profiles/${d.id}`, 'DELETE', undefined, { idempotent: false });
|
||||
notify.success('Удалено');
|
||||
await onRefresh();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!form.url.trim()) {
|
||||
notify.error('Укажите URL');
|
||||
return;
|
||||
}
|
||||
saving = true;
|
||||
try {
|
||||
if (editTarget) {
|
||||
await apiMutate(`/v1/doh-profiles/${editTarget.id}`, 'PATCH', form);
|
||||
notify.success('DoH профиль обновлён');
|
||||
} else {
|
||||
await apiMutate('/v1/doh-profiles', 'POST', form);
|
||||
notify.success('DoH профиль создан');
|
||||
}
|
||||
dialogOpen = false;
|
||||
await onRefresh();
|
||||
} catch (e) {
|
||||
notifyApiError(e);
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Card>
|
||||
<CardHeader
|
||||
class="flex flex-col gap-3 border-b py-3 sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<div class="min-w-0 flex-1">
|
||||
<CardTitle class="text-base">DoH профили</CardTitle>
|
||||
<CardDescription>DNS-over-HTTPS серверы для резолвинга доменных модулей</CardDescription>
|
||||
</div>
|
||||
<div class="flex shrink-0 flex-wrap items-center justify-end gap-2">
|
||||
<Button size="sm" onclick={openCreate}><Plus />Добавить</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent class="p-4 pt-0">
|
||||
<AppDataTable
|
||||
columns={[...columns]}
|
||||
rows={items}
|
||||
rowKey={(d) => d.id}
|
||||
loading={initialLoading || loading}
|
||||
{error}
|
||||
emptyTitle="Нет DoH профилей"
|
||||
emptyDescription="Добавьте DNS-over-HTTPS сервер для доменных модулей."
|
||||
>
|
||||
{#snippet cell({ row: d, column })}
|
||||
{#if column.id === 'url'}
|
||||
<span class="font-mono text-sm">{d.url}</span>
|
||||
{:else if column.id === 'timeout_ms'}
|
||||
<span class="text-muted-foreground">{d.timeout_ms ?? '—'}</span>
|
||||
{:else if column.id === 'id'}
|
||||
<span class="font-mono text-xs text-muted-foreground">{d.id}</span>
|
||||
{:else if column.id === 'actions'}
|
||||
<div class="flex gap-1">
|
||||
<Button variant="ghost" size="icon-sm" onclick={() => openEdit(d)}>
|
||||
<Pencil class="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
class="text-destructive"
|
||||
onclick={() => requestDelete(d)}
|
||||
>
|
||||
<Trash2 class="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</AppDataTable>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Dialog bind:open={dialogOpen}>
|
||||
<DialogContent class="sm:max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editTarget ? 'Редактировать' : 'Новый'} DoH профиль</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div class="flex flex-col gap-4 py-2">
|
||||
<FormField label="URL" id="doh-url" required>
|
||||
<AppInput id="doh-url" bind:value={form.url} placeholder="https://dns.google/dns-query" />
|
||||
</FormField>
|
||||
<FormField label="Таймаут (мс)" id="doh-timeout">
|
||||
<AppInput id="doh-timeout" type="number" bind:value={form.timeout_ms} placeholder="5000" />
|
||||
</FormField>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onclick={() => (dialogOpen = false)}>Отмена</Button>
|
||||
<Button onclick={save} disabled={saving}>
|
||||
{saving ? 'Сохранение…' : editTarget ? 'Сохранить' : 'Создать'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
@@ -46,8 +46,13 @@
|
||||
let csvExporting = $state(false);
|
||||
let csvFileInput = $state<HTMLInputElement | null>(null);
|
||||
|
||||
const selectedCount = $derived(selectedIds.size);
|
||||
const allSelected = $derived(entries.length > 0 && selectedIds.size === entries.length);
|
||||
const activeSelected = $derived.by(() => {
|
||||
const allowed = new Set(entries.map((e) => e.id));
|
||||
return [...selectedIds].filter((id) => allowed.has(id));
|
||||
});
|
||||
const selectedCount = $derived(activeSelected.length);
|
||||
|
||||
const allSelected = $derived(entries.length > 0 && entries.every((e) => selectedIds.has(e.id)));
|
||||
|
||||
const columns = [
|
||||
{ id: 'select', label: '', class: 'w-10' },
|
||||
@@ -75,11 +80,6 @@
|
||||
{ id: 'actions', label: '', class: 'w-20' }
|
||||
] as const;
|
||||
|
||||
$effect(() => {
|
||||
const validIds = new Set(entries.map((e) => e.id));
|
||||
selectedIds = new Set([...selectedIds].filter((id) => validIds.has(id)));
|
||||
});
|
||||
|
||||
function toggleSelection(id: string) {
|
||||
const next = new Set(selectedIds);
|
||||
if (next.has(id)) next.delete(id);
|
||||
@@ -133,7 +133,7 @@
|
||||
deletingBulk = true;
|
||||
let deleted = 0;
|
||||
try {
|
||||
for (const id of selectedIds) {
|
||||
for (const id of activeSelected) {
|
||||
try {
|
||||
await apiMutate(`/v1/modules/${moduleId}/as-entries/${id}`, 'DELETE', undefined, {
|
||||
idempotent: false
|
||||
|
||||
@@ -35,12 +35,23 @@
|
||||
|
||||
let saving = $state(false);
|
||||
let form = $state<AsEntryCreate>({ asn: 0, community_id: null });
|
||||
let initKey = $state('');
|
||||
|
||||
function resetForm() {
|
||||
form = edit
|
||||
? { asn: edit.asn, community_id: edit.community_id }
|
||||
: { asn: 0, community_id: null };
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (open) {
|
||||
form = edit
|
||||
? { asn: edit.asn, community_id: edit.community_id }
|
||||
: { asn: 0, community_id: null };
|
||||
if (!open) {
|
||||
initKey = '';
|
||||
return;
|
||||
}
|
||||
const nextKey = edit?.id ?? 'new';
|
||||
if (nextKey !== initKey) {
|
||||
initKey = nextKey;
|
||||
resetForm();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -75,7 +86,7 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog {open} onOpenChange={handleOpenChange}>
|
||||
<Dialog bind:open onOpenChange={handleOpenChange}>
|
||||
<DialogContent class="sm:max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{edit ? 'Редактировать запись' : 'Новая AS-запись'}</DialogTitle>
|
||||
|
||||
@@ -51,6 +51,7 @@
|
||||
prefix_path: '',
|
||||
community_id: null
|
||||
});
|
||||
let initKey = $state('');
|
||||
|
||||
function clearPreview() {
|
||||
previewLoading = false;
|
||||
@@ -61,18 +62,28 @@
|
||||
previewOk = false;
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
clearPreview();
|
||||
form = edit
|
||||
? {
|
||||
url: edit.url,
|
||||
source_kind: normalizeCdnSourceKind(edit.source_kind),
|
||||
prefix_path: edit.prefix_path ?? '',
|
||||
community_id: edit.community_id,
|
||||
refresh_interval_sec: edit.refresh_interval_sec
|
||||
}
|
||||
: { url: '', source_kind: 'plaintext', prefix_path: '', community_id: null };
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (open) {
|
||||
clearPreview();
|
||||
form = edit
|
||||
? {
|
||||
url: edit.url,
|
||||
source_kind: normalizeCdnSourceKind(edit.source_kind),
|
||||
prefix_path: edit.prefix_path ?? '',
|
||||
community_id: edit.community_id,
|
||||
refresh_interval_sec: edit.refresh_interval_sec
|
||||
}
|
||||
: { url: '', source_kind: 'plaintext', prefix_path: '', community_id: null };
|
||||
if (!open) {
|
||||
initKey = '';
|
||||
return;
|
||||
}
|
||||
const nextKey = edit?.id ?? 'new';
|
||||
if (nextKey !== initKey) {
|
||||
initKey = nextKey;
|
||||
resetForm();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -150,7 +161,7 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog {open} onOpenChange={handleOpenChange}>
|
||||
<Dialog bind:open onOpenChange={handleOpenChange}>
|
||||
<DialogContent class="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{edit ? 'Редактировать источник' : 'Новый CDN-источник'}</DialogTitle>
|
||||
|
||||
@@ -39,8 +39,12 @@
|
||||
let selectedIds = $state(new Set<string>());
|
||||
let deletingBulk = $state(false);
|
||||
|
||||
const selectedCount = $derived(selectedIds.size);
|
||||
const allSelected = $derived(sources.length > 0 && selectedIds.size === sources.length);
|
||||
const activeSelected = $derived.by(() => {
|
||||
const allowed = new Set(sources.map((s) => s.id));
|
||||
return [...selectedIds].filter((id) => allowed.has(id));
|
||||
});
|
||||
const selectedCount = $derived(activeSelected.length);
|
||||
const allSelected = $derived(sources.length > 0 && sources.every((s) => selectedIds.has(s.id)));
|
||||
|
||||
const columns = [
|
||||
{ id: 'select', label: '', class: 'w-10' },
|
||||
@@ -62,11 +66,6 @@
|
||||
{ id: 'actions', label: '', class: 'w-20' }
|
||||
] as const;
|
||||
|
||||
$effect(() => {
|
||||
const validIds = new Set(sources.map((s) => s.id));
|
||||
selectedIds = new Set([...selectedIds].filter((id) => validIds.has(id)));
|
||||
});
|
||||
|
||||
function toggleSelection(id: string) {
|
||||
const next = new Set(selectedIds);
|
||||
if (next.has(id)) next.delete(id);
|
||||
@@ -120,7 +119,7 @@
|
||||
deletingBulk = true;
|
||||
let deleted = 0;
|
||||
try {
|
||||
for (const id of selectedIds) {
|
||||
for (const id of activeSelected) {
|
||||
try {
|
||||
await apiMutate(`/v1/modules/${moduleId}/cdn-sources/${id}`, 'DELETE', undefined, {
|
||||
idempotent: false
|
||||
|
||||
@@ -72,7 +72,7 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog {open} onOpenChange={handleOpenChange}>
|
||||
<Dialog bind:open onOpenChange={handleOpenChange}>
|
||||
<DialogContent class="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Новый модуль</DialogTitle>
|
||||
|
||||
@@ -39,15 +39,15 @@
|
||||
<Badge variant={moduleEnabledBadgeVariant(!!mod.enabled)} class="text-xs">
|
||||
{moduleEnabledRu(!!mod.enabled)}
|
||||
</Badge>
|
||||
<Button variant="outline" size="sm" onclick={onRefresh} disabled={refreshing}>
|
||||
<Button variant="outline" size="sm" onclick={() => onRefresh()} disabled={refreshing}>
|
||||
<RefreshCw class={refreshing ? 'animate-spin' : ''} />
|
||||
Обновить
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onclick={onEdit}>
|
||||
<Button variant="outline" size="sm" onclick={() => onEdit()}>
|
||||
<Pencil />
|
||||
Редактировать
|
||||
</Button>
|
||||
<Button variant="destructive" size="sm" onclick={onDelete}>
|
||||
<Button variant="destructive" size="sm" onclick={() => onDelete()}>
|
||||
<Trash2 />
|
||||
Удалить
|
||||
</Button>
|
||||
|
||||
@@ -44,8 +44,12 @@
|
||||
let csvExporting = $state(false);
|
||||
let csvFileInput = $state<HTMLInputElement | null>(null);
|
||||
|
||||
const selectedCount = $derived(selectedIds.size);
|
||||
const allSelected = $derived(entries.length > 0 && selectedIds.size === entries.length);
|
||||
const activeSelected = $derived.by(() => {
|
||||
const allowed = new Set(entries.map((e) => e.id));
|
||||
return [...selectedIds].filter((id) => allowed.has(id));
|
||||
});
|
||||
const selectedCount = $derived(activeSelected.length);
|
||||
const allSelected = $derived(entries.length > 0 && entries.every((e) => selectedIds.has(e.id)));
|
||||
|
||||
const columns = [
|
||||
{ id: 'select', label: '', class: 'w-10' },
|
||||
@@ -54,11 +58,6 @@
|
||||
{ id: 'actions', label: '', class: 'w-20' }
|
||||
] as const;
|
||||
|
||||
$effect(() => {
|
||||
const validIds = new Set(entries.map((e) => e.id));
|
||||
selectedIds = new Set([...selectedIds].filter((id) => validIds.has(id)));
|
||||
});
|
||||
|
||||
function toggleSelection(id: string) {
|
||||
const next = new Set(selectedIds);
|
||||
if (next.has(id)) next.delete(id);
|
||||
@@ -112,7 +111,7 @@
|
||||
deletingBulk = true;
|
||||
let deleted = 0;
|
||||
try {
|
||||
for (const id of selectedIds) {
|
||||
for (const id of activeSelected) {
|
||||
try {
|
||||
await apiMutate(`/v1/modules/${moduleId}/domain-entries/${id}`, 'DELETE', undefined, {
|
||||
idempotent: false
|
||||
|
||||
@@ -34,12 +34,23 @@
|
||||
|
||||
let saving = $state(false);
|
||||
let form = $state<DomainEntryCreate>({ fqdn: '', community_id: null });
|
||||
let initKey = $state('');
|
||||
|
||||
function resetForm() {
|
||||
form = edit
|
||||
? { fqdn: edit.fqdn, community_id: edit.community_id }
|
||||
: { fqdn: '', community_id: null };
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (open) {
|
||||
form = edit
|
||||
? { fqdn: edit.fqdn, community_id: edit.community_id }
|
||||
: { fqdn: '', community_id: null };
|
||||
if (!open) {
|
||||
initKey = '';
|
||||
return;
|
||||
}
|
||||
const nextKey = edit?.id ?? 'new';
|
||||
if (nextKey !== initKey) {
|
||||
initKey = nextKey;
|
||||
resetForm();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -68,7 +79,7 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog {open} onOpenChange={handleOpenChange}>
|
||||
<Dialog bind:open onOpenChange={handleOpenChange}>
|
||||
<DialogContent class="sm:max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{edit ? 'Редактировать домен' : 'Новый домен'}</DialogTitle>
|
||||
|
||||
@@ -71,19 +71,30 @@
|
||||
|
||||
let editForm = $state<ModulePatch>({});
|
||||
let editSaving = $state(false);
|
||||
let initKey = $state('');
|
||||
|
||||
function resetEditForm() {
|
||||
editForm = {
|
||||
name: mod.name,
|
||||
enabled: mod.enabled,
|
||||
priority: mod.priority,
|
||||
refresh_interval_sec: mod.refresh_interval_sec,
|
||||
cron_expr: mod.cron_expr,
|
||||
default_community_id: mod.default_community_id,
|
||||
doh_profile_ids: moduleDohProfileIds(mod),
|
||||
doh_resolver_policy: mod.doh_resolver_policy ?? 'primary_only'
|
||||
};
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (open && mod) {
|
||||
editForm = {
|
||||
name: mod.name,
|
||||
enabled: mod.enabled,
|
||||
priority: mod.priority,
|
||||
refresh_interval_sec: mod.refresh_interval_sec,
|
||||
cron_expr: mod.cron_expr,
|
||||
default_community_id: mod.default_community_id,
|
||||
doh_profile_ids: moduleDohProfileIds(mod),
|
||||
doh_resolver_policy: mod.doh_resolver_policy ?? 'primary_only'
|
||||
};
|
||||
if (!open) {
|
||||
initKey = '';
|
||||
return;
|
||||
}
|
||||
const nextKey = mod.id;
|
||||
if (nextKey !== initKey) {
|
||||
initKey = nextKey;
|
||||
resetEditForm();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -136,7 +147,7 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog {open} onOpenChange={handleOpenChange}>
|
||||
<Dialog bind:open onOpenChange={handleOpenChange}>
|
||||
<DialogContent class="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Редактировать модуль</DialogTitle>
|
||||
|
||||
@@ -28,12 +28,23 @@
|
||||
|
||||
let saving = $state(false);
|
||||
let form = $state<IpRangeEntryCreate>({ prefix: '', community_id: '' });
|
||||
let initKey = $state('');
|
||||
|
||||
function resetForm() {
|
||||
form = edit
|
||||
? { prefix: edit.prefix, community_id: edit.community_id }
|
||||
: { prefix: '', community_id: '' };
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (open) {
|
||||
form = edit
|
||||
? { prefix: edit.prefix, community_id: edit.community_id }
|
||||
: { prefix: '', community_id: '' };
|
||||
if (!open) {
|
||||
initKey = '';
|
||||
return;
|
||||
}
|
||||
const nextKey = edit?.id ?? 'new';
|
||||
if (nextKey !== initKey) {
|
||||
initKey = nextKey;
|
||||
resetForm();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -62,7 +73,7 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog {open} onOpenChange={handleOpenChange}>
|
||||
<Dialog bind:open onOpenChange={handleOpenChange}>
|
||||
<DialogContent class="sm:max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{edit ? 'Редактировать диапазон' : 'Новый IP-диапазон'}</DialogTitle>
|
||||
|
||||
@@ -44,8 +44,12 @@
|
||||
let csvExporting = $state(false);
|
||||
let csvFileInput = $state<HTMLInputElement | null>(null);
|
||||
|
||||
const selectedCount = $derived(selectedIds.size);
|
||||
const allSelected = $derived(entries.length > 0 && selectedIds.size === entries.length);
|
||||
const activeSelected = $derived.by(() => {
|
||||
const allowed = new Set(entries.map((e) => e.id));
|
||||
return [...selectedIds].filter((id) => allowed.has(id));
|
||||
});
|
||||
const selectedCount = $derived(activeSelected.length);
|
||||
const allSelected = $derived(entries.length > 0 && entries.every((e) => selectedIds.has(e.id)));
|
||||
|
||||
const columns = [
|
||||
{ id: 'select', label: '', class: 'w-10' },
|
||||
@@ -59,11 +63,6 @@
|
||||
{ id: 'actions', label: '', class: 'w-20' }
|
||||
] as const;
|
||||
|
||||
$effect(() => {
|
||||
const validIds = new Set(entries.map((e) => e.id));
|
||||
selectedIds = new Set([...selectedIds].filter((id) => validIds.has(id)));
|
||||
});
|
||||
|
||||
function toggleSelection(id: string) {
|
||||
const next = new Set(selectedIds);
|
||||
if (next.has(id)) next.delete(id);
|
||||
@@ -120,7 +119,7 @@
|
||||
deletingBulk = true;
|
||||
let deleted = 0;
|
||||
try {
|
||||
for (const id of selectedIds) {
|
||||
for (const id of activeSelected) {
|
||||
try {
|
||||
await apiMutate(`/v1/modules/${moduleId}/ip-range-entries/${id}`, 'DELETE', undefined, {
|
||||
idempotent: false
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { defaults, superForm } from 'sveltekit-superforms';
|
||||
import { zod4 } from 'sveltekit-superforms/adapters';
|
||||
import {
|
||||
birdSettingsSchema,
|
||||
emptyBirdSettingsForm,
|
||||
type BirdSettingsForm
|
||||
} from '$lib/settings/bird-settings.schema.js';
|
||||
import {
|
||||
buildPayloadFromFormFields,
|
||||
loadSettings,
|
||||
partitionSettings,
|
||||
patchSettings
|
||||
} from '$lib/settings/settings-api.js';
|
||||
import { BIRD_SETTING_KEYS } from '$lib/settings/settings-known-keys.js';
|
||||
import { Button } from '$lib/ui/core/button/index.js';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle
|
||||
} from '$lib/ui/core/card/index.js';
|
||||
import { Input } from '$lib/ui/core/input/index.js';
|
||||
import { Alert, AlertDescription, AlertTitle } from '$lib/ui/core/alert/index.js';
|
||||
import FormField from '$lib/ui/patterns/form/form-field.svelte';
|
||||
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
|
||||
import Save from '@lucide/svelte/icons/save';
|
||||
import Info from '@lucide/svelte/icons/info';
|
||||
|
||||
let loading = $state(false);
|
||||
let saving = $state(false);
|
||||
let loaded = $state(false);
|
||||
|
||||
const { form, errors, reset, validateForm } = superForm(
|
||||
defaults(emptyBirdSettingsForm(), zod4(birdSettingsSchema)),
|
||||
{
|
||||
validators: zod4(birdSettingsSchema),
|
||||
SPA: true,
|
||||
dataType: 'json'
|
||||
}
|
||||
);
|
||||
|
||||
let hasValidationErrors = $derived(
|
||||
BIRD_SETTING_KEYS.some((key) => Boolean($errors[key as keyof BirdSettingsForm]?.length))
|
||||
);
|
||||
|
||||
let canSave = $derived.by(() => {
|
||||
if (loading || saving || hasValidationErrors || !loaded) return false;
|
||||
return BIRD_SETTING_KEYS.some((key) => {
|
||||
const value = String($form[key as keyof BirdSettingsForm] ?? '').trim();
|
||||
return value !== '' && !$errors[key as keyof BirdSettingsForm]?.length;
|
||||
});
|
||||
});
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
try {
|
||||
const settings = await loadSettings();
|
||||
const { partitioned } = partitionSettings(settings);
|
||||
reset({ data: partitioned.bird });
|
||||
loaded = true;
|
||||
} catch (e) {
|
||||
notifyApiError(e);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
const validation = await validateForm({ update: true });
|
||||
if (!validation.valid) {
|
||||
notify.error('Исправьте ошибки в полях настроек');
|
||||
return;
|
||||
}
|
||||
if (!canSave) {
|
||||
notify.error('Нечего сохранять или есть ошибки в полях');
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = buildPayloadFromFormFields(
|
||||
BIRD_SETTING_KEYS,
|
||||
$form as Record<string, string>,
|
||||
$errors as Partial<Record<string, string[]>>
|
||||
);
|
||||
|
||||
saving = true;
|
||||
try {
|
||||
await patchSettings(payload);
|
||||
notify.success('Параметры BIRD сохранены');
|
||||
await load();
|
||||
} catch (e) {
|
||||
notifyApiError(e);
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
void load();
|
||||
});
|
||||
</script>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Control plane</CardTitle>
|
||||
<CardDescription>
|
||||
Глобальные параметры BIRD для pipeline refresh/apply. Сохранение через
|
||||
<code class="text-xs">PATCH /v1/settings</code> (роль operator).
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-5">
|
||||
<Alert class="border-info/30 bg-info/5">
|
||||
<Info class="text-info" />
|
||||
<AlertTitle>Подстановка в конфиг</AlertTitle>
|
||||
<AlertDescription>
|
||||
Значения используются при генерации BIRD-конфигурации в pipeline (router id, local AS,
|
||||
адреса). Пиры и спикеры настраиваются на соседних вкладках.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
{#if loading && !loaded}
|
||||
<p class="text-sm text-muted-foreground">Загрузка…</p>
|
||||
{:else if !loaded}
|
||||
<Button variant="outline" onclick={load}>Загрузить параметры</Button>
|
||||
{:else}
|
||||
<div class="space-y-3">
|
||||
<FormField
|
||||
id="bird-router-id"
|
||||
label="Router ID (bird_router_id)"
|
||||
error={$errors.bird_router_id?.[0]}
|
||||
>
|
||||
<Input id="bird-router-id" bind:value={$form.bird_router_id} placeholder="203.0.113.1" />
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
id="bird-local-ipv4"
|
||||
label="Локальный IPv4 (bird_local_ipv4)"
|
||||
error={$errors.bird_local_ipv4?.[0]}
|
||||
>
|
||||
<Input
|
||||
id="bird-local-ipv4"
|
||||
bind:value={$form.bird_local_ipv4}
|
||||
placeholder="198.51.100.10"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
id="bird-local-ipv6"
|
||||
label="Локальный IPv6 (bird_local_ipv6)"
|
||||
error={$errors.bird_local_ipv6?.[0]}
|
||||
>
|
||||
<Input
|
||||
id="bird-local-ipv6"
|
||||
bind:value={$form.bird_local_ipv6}
|
||||
placeholder="2001:db8::10"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
id="bird-local-asn"
|
||||
label="Локальный ASN (bird_local_asn)"
|
||||
error={$errors.bird_local_asn?.[0]}
|
||||
>
|
||||
<Input
|
||||
id="bird-local-asn"
|
||||
type="number"
|
||||
min="1"
|
||||
bind:value={$form.bird_local_asn}
|
||||
placeholder="65001"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
id="bird-bgp-source-ipv4"
|
||||
label="BGP source IPv4 (bird_bgp_source_ipv4)"
|
||||
error={$errors.bird_bgp_source_ipv4?.[0]}
|
||||
>
|
||||
<Input
|
||||
id="bird-bgp-source-ipv4"
|
||||
bind:value={$form.bird_bgp_source_ipv4}
|
||||
placeholder="198.51.100.11"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
id="bird-bgp-source-ipv6"
|
||||
label="BGP source IPv6 (bird_bgp_source_ipv6)"
|
||||
error={$errors.bird_bgp_source_ipv6?.[0]}
|
||||
>
|
||||
<Input
|
||||
id="bird-bgp-source-ipv6"
|
||||
bind:value={$form.bird_bgp_source_ipv6}
|
||||
placeholder="2001:db8::11"
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
{#if hasValidationErrors}
|
||||
<p class="text-sm text-destructive">
|
||||
Есть ошибки в полях. Исправьте их, чтобы сохранить изменения.
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<Button onclick={save} disabled={!canSave}>
|
||||
<Save />
|
||||
{saving ? 'Сохранение…' : 'Применить параметры'}
|
||||
</Button>
|
||||
{/if}
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -0,0 +1,290 @@
|
||||
<script lang="ts">
|
||||
import { apiMutate } from '$lib/api/client.js';
|
||||
import type { PeerRow, BgpPeerCreate, SpeakerRow } from '$lib/api/types.js';
|
||||
import { Badge } from '$lib/ui/core/badge/index.js';
|
||||
import { Button } from '$lib/ui/core/button/index.js';
|
||||
import { Label } from '$lib/ui/core/label/index.js';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription
|
||||
} from '$lib/ui/core/card/index.js';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
DialogDescription
|
||||
} from '$lib/ui/core/dialog/index.js';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger } from '$lib/ui/core/select/index.js';
|
||||
import { Switch } from '$lib/ui/core/switch/index.js';
|
||||
import FormField from '$lib/ui/patterns/form/form-field.svelte';
|
||||
import AppInput from '$lib/ui/patterns/form/app-input.svelte';
|
||||
import AppDataTable from '$lib/ui/patterns/data-table/app-data-table.svelte';
|
||||
import { confirm } from '$lib/ui/patterns/confirm/confirm-state.svelte.js';
|
||||
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
|
||||
import Plus from '@lucide/svelte/icons/plus';
|
||||
import Pencil from '@lucide/svelte/icons/pencil';
|
||||
import Trash2 from '@lucide/svelte/icons/trash-2';
|
||||
|
||||
type Props = {
|
||||
items: PeerRow[];
|
||||
speakers: SpeakerRow[];
|
||||
loading?: boolean;
|
||||
initialLoading?: boolean;
|
||||
error?: string | null;
|
||||
onRefresh: () => void | Promise<void>;
|
||||
};
|
||||
|
||||
let {
|
||||
items,
|
||||
speakers,
|
||||
loading = false,
|
||||
initialLoading = false,
|
||||
error = null,
|
||||
onRefresh
|
||||
}: Props = $props();
|
||||
|
||||
let dialogOpen = $state(false);
|
||||
let editTarget = $state<PeerRow | null>(null);
|
||||
let form = $state<BgpPeerCreate & { bgp_speaker_id?: string | null }>({
|
||||
name: '',
|
||||
neighbor: '',
|
||||
remote_asn: 0,
|
||||
bgp_speaker_id: null,
|
||||
enabled: true
|
||||
});
|
||||
let saving = $state(false);
|
||||
let toggleId = $state<string | null>(null);
|
||||
|
||||
const speakerById = $derived.by(() => new Map(speakers.map((s) => [s.id, s])));
|
||||
|
||||
const columns = [
|
||||
{ id: 'name', label: 'Имя', sortable: true, sortValue: (p: PeerRow) => p.name ?? '' },
|
||||
{ id: 'neighbor', label: 'Адрес', sortable: true, sortValue: (p: PeerRow) => p.neighbor },
|
||||
{
|
||||
id: 'remote_asn',
|
||||
label: 'Remote ASN',
|
||||
sortable: true,
|
||||
sortValue: (p: PeerRow) => p.remote_asn ?? 0
|
||||
},
|
||||
{ id: 'enabled', label: 'Вкл.', class: 'w-[4.5rem] text-center' },
|
||||
{ id: 'session_state', label: 'Состояние сессии' },
|
||||
{ id: 'speaker', label: 'Спикер' },
|
||||
{ id: 'actions', label: '', class: 'w-20' }
|
||||
] as const;
|
||||
|
||||
function speakerLabelById(id: string | null | undefined) {
|
||||
if (!id) return '—';
|
||||
return speakerById.get(id)?.endpoint ?? id;
|
||||
}
|
||||
|
||||
function sessionBadge(state: string) {
|
||||
if (state === 'Established') return 'default';
|
||||
if (state === 'Active' || state === 'Connect') return 'secondary';
|
||||
return 'outline';
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
editTarget = null;
|
||||
form = { name: '', neighbor: '', remote_asn: 0, bgp_speaker_id: null, enabled: true };
|
||||
dialogOpen = true;
|
||||
}
|
||||
|
||||
function openEdit(p: PeerRow) {
|
||||
editTarget = p;
|
||||
form = {
|
||||
name: p.name ?? '',
|
||||
neighbor: p.neighbor,
|
||||
remote_asn: p.remote_asn ?? 0,
|
||||
bgp_speaker_id: p.bgp_speaker_id,
|
||||
enabled: p.enabled !== false
|
||||
};
|
||||
dialogOpen = true;
|
||||
}
|
||||
|
||||
function requestDelete(p: PeerRow) {
|
||||
void confirm({
|
||||
title: 'Удалить пира?',
|
||||
description: p.neighbor,
|
||||
confirmLabel: 'Удалить',
|
||||
destructive: true,
|
||||
onConfirm: async () => {
|
||||
await apiMutate(`/v1/peers/${p.id}`, 'DELETE', undefined, { idempotent: false });
|
||||
notify.success('Пир удалён');
|
||||
await onRefresh();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function setEnabled(p: PeerRow, enabled: boolean) {
|
||||
toggleId = p.id;
|
||||
try {
|
||||
await apiMutate(`/v1/peers/${p.id}`, 'PATCH', { enabled });
|
||||
notify.success(enabled ? 'Пир включён' : 'Пир отключён');
|
||||
await onRefresh();
|
||||
} catch (e) {
|
||||
notifyApiError(e);
|
||||
} finally {
|
||||
toggleId = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!form.neighbor.trim()) {
|
||||
notify.error('Укажите адрес соседа');
|
||||
return;
|
||||
}
|
||||
if (!form.remote_asn || form.remote_asn <= 0) {
|
||||
notify.error('Remote ASN должен быть больше 0');
|
||||
return;
|
||||
}
|
||||
saving = true;
|
||||
try {
|
||||
if (editTarget) {
|
||||
await apiMutate(`/v1/peers/${editTarget.id}`, 'PATCH', form);
|
||||
notify.success('Пир обновлён');
|
||||
} else {
|
||||
await apiMutate('/v1/peers', 'POST', {
|
||||
...form,
|
||||
enabled: form.enabled !== false
|
||||
});
|
||||
notify.success('Пир создан');
|
||||
}
|
||||
dialogOpen = false;
|
||||
await onRefresh();
|
||||
} catch (e) {
|
||||
notifyApiError(e);
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Card>
|
||||
<CardHeader
|
||||
class="flex flex-col gap-3 border-b py-3 sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<div class="min-w-0 flex-1">
|
||||
<CardTitle class="text-base">BGP-пиры</CardTitle>
|
||||
<CardDescription>Настройка BGP-соседей и привязка к спикерам</CardDescription>
|
||||
</div>
|
||||
<div class="flex shrink-0 flex-wrap items-center justify-end gap-2">
|
||||
<Button size="sm" onclick={openCreate}><Plus />Добавить</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent class="p-4 pt-0">
|
||||
<AppDataTable
|
||||
columns={[...columns]}
|
||||
rows={items}
|
||||
rowKey={(p) => p.id}
|
||||
loading={initialLoading || loading}
|
||||
{error}
|
||||
emptyTitle="Нет BGP-пиров"
|
||||
emptyDescription="Добавьте первого BGP-соседа для установки сессии."
|
||||
>
|
||||
{#snippet cell({ row: p, column })}
|
||||
{#if column.id === 'name'}
|
||||
<span>{p.name?.trim() || '—'}</span>
|
||||
{:else if column.id === 'neighbor'}
|
||||
<span class="font-mono text-sm">{p.neighbor}</span>
|
||||
{:else if column.id === 'remote_asn'}
|
||||
<span class="font-mono text-sm">{p.remote_asn ?? '—'}</span>
|
||||
{:else if column.id === 'enabled'}
|
||||
<div class="flex justify-center">
|
||||
<Switch
|
||||
size="sm"
|
||||
checked={p.enabled !== false}
|
||||
disabled={loading || toggleId === p.id}
|
||||
onCheckedChange={(v) => setEnabled(p, v)}
|
||||
/>
|
||||
</div>
|
||||
{:else if column.id === 'session_state'}
|
||||
<Badge variant={sessionBadge(p.session_state)}>{p.session_state || '—'}</Badge>
|
||||
{:else if column.id === 'speaker'}
|
||||
<span class="text-xs text-muted-foreground">{speakerLabelById(p.bgp_speaker_id)}</span>
|
||||
{:else if column.id === 'actions'}
|
||||
<div class="flex gap-1">
|
||||
<Button variant="ghost" size="icon-sm" onclick={() => openEdit(p)}>
|
||||
<Pencil class="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
class="text-destructive"
|
||||
onclick={() => requestDelete(p)}
|
||||
>
|
||||
<Trash2 class="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</AppDataTable>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Dialog bind:open={dialogOpen}>
|
||||
<DialogContent class="sm:max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editTarget ? 'Редактировать пира' : 'Новый пир'}</DialogTitle>
|
||||
<DialogDescription>BGP-сосед для установки сессии</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div class="space-y-4 py-2">
|
||||
<FormField label="Имя пира (опционально)" id="p-name">
|
||||
<AppInput id="p-name" placeholder="Core-RTR-1" bind:value={form.name} />
|
||||
</FormField>
|
||||
<FormField label="Адрес соседа" id="p-neighbor" required>
|
||||
<AppInput id="p-neighbor" placeholder="192.0.2.1" bind:value={form.neighbor} />
|
||||
</FormField>
|
||||
<FormField label="Remote ASN" id="p-asn" required>
|
||||
<AppInput id="p-asn" type="number" placeholder="65000" bind:value={form.remote_asn} />
|
||||
</FormField>
|
||||
<FormField label="Спикер (опционально)" id="p-speaker">
|
||||
<Select
|
||||
type="single"
|
||||
value={form.bgp_speaker_id ?? ''}
|
||||
onValueChange={(v) => {
|
||||
form = { ...form, bgp_speaker_id: v || null };
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id="p-speaker" class="w-full">
|
||||
{form.bgp_speaker_id ? speakerLabelById(form.bgp_speaker_id) : 'Не выбрано'}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">Не выбрано</SelectItem>
|
||||
{#each speakers as s (s.id)}
|
||||
<SelectItem value={s.id}>{s.endpoint} ({s.id.slice(0, 8)}…)</SelectItem>
|
||||
{/each}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormField>
|
||||
<div
|
||||
class="flex flex-row items-center justify-between gap-4 rounded-lg border border-border bg-muted/30 p-3"
|
||||
>
|
||||
<div class="grid min-w-0 flex-1 gap-1 pr-2">
|
||||
<Label for="p-enabled" class="leading-snug text-foreground">Включён</Label>
|
||||
<p class="text-xs leading-snug text-muted-foreground">
|
||||
Выключенный пир не попадает в конфиг BIRD до следующей ревизии.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="p-enabled"
|
||||
class="shrink-0"
|
||||
checked={form.enabled !== false}
|
||||
onCheckedChange={(v) => {
|
||||
form = { ...form, enabled: v };
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onclick={() => (dialogOpen = false)}>Отмена</Button>
|
||||
<Button onclick={save} disabled={saving}>
|
||||
{saving ? 'Сохранение…' : editTarget ? 'Сохранить' : 'Создать'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
@@ -0,0 +1,177 @@
|
||||
<script lang="ts">
|
||||
import { apiMutate } from '$lib/api/client.js';
|
||||
import type { SpeakerRow, BgpSpeakerCreate } from '$lib/api/types.js';
|
||||
import { Badge } from '$lib/ui/core/badge/index.js';
|
||||
import { Button } from '$lib/ui/core/button/index.js';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription
|
||||
} from '$lib/ui/core/card/index.js';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter
|
||||
} from '$lib/ui/core/dialog/index.js';
|
||||
import FormField from '$lib/ui/patterns/form/form-field.svelte';
|
||||
import AppInput from '$lib/ui/patterns/form/app-input.svelte';
|
||||
import AppDataTable from '$lib/ui/patterns/data-table/app-data-table.svelte';
|
||||
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
|
||||
import Plus from '@lucide/svelte/icons/plus';
|
||||
import Pencil from '@lucide/svelte/icons/pencil';
|
||||
import Play from '@lucide/svelte/icons/play';
|
||||
|
||||
type Props = {
|
||||
items: SpeakerRow[];
|
||||
loading?: boolean;
|
||||
initialLoading?: boolean;
|
||||
error?: string | null;
|
||||
onRefresh: () => void | Promise<void>;
|
||||
};
|
||||
|
||||
let { items, loading = false, initialLoading = false, error = null, onRefresh }: Props = $props();
|
||||
|
||||
let dialogOpen = $state(false);
|
||||
let editTarget = $state<SpeakerRow | null>(null);
|
||||
let form = $state<BgpSpeakerCreate>({ endpoint: '', role: 'operator' });
|
||||
let saving = $state(false);
|
||||
let applyingId = $state<string | null>(null);
|
||||
|
||||
const columns = [
|
||||
{
|
||||
id: 'endpoint',
|
||||
label: 'Endpoint',
|
||||
sortable: true,
|
||||
sortValue: (s: SpeakerRow) => s.endpoint
|
||||
},
|
||||
{ id: 'role', label: 'Роль', sortable: true, sortValue: (s: SpeakerRow) => s.role },
|
||||
{ id: 'last_applied_revision_id', label: 'Последняя ревизия' },
|
||||
{ id: 'actions', label: '', class: 'w-32' }
|
||||
] as const;
|
||||
|
||||
function openCreate() {
|
||||
editTarget = null;
|
||||
form = { endpoint: '', role: 'operator' };
|
||||
dialogOpen = true;
|
||||
}
|
||||
|
||||
function openEdit(s: SpeakerRow) {
|
||||
editTarget = s;
|
||||
form = { endpoint: s.endpoint, role: s.role };
|
||||
dialogOpen = true;
|
||||
}
|
||||
|
||||
async function applySpeaker(id: string) {
|
||||
applyingId = id;
|
||||
try {
|
||||
await apiMutate(`/v1/speakers/${id}/apply`, 'POST', {});
|
||||
notify.success('Apply запущен');
|
||||
} catch (e) {
|
||||
notifyApiError(e);
|
||||
} finally {
|
||||
applyingId = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!form.endpoint.trim()) {
|
||||
notify.error('Укажите endpoint');
|
||||
return;
|
||||
}
|
||||
saving = true;
|
||||
try {
|
||||
if (editTarget) {
|
||||
await apiMutate(`/v1/speakers/${editTarget.id}`, 'PATCH', form);
|
||||
notify.success('Спикер обновлён');
|
||||
} else {
|
||||
await apiMutate('/v1/speakers', 'POST', form);
|
||||
notify.success('Спикер создан');
|
||||
}
|
||||
dialogOpen = false;
|
||||
await onRefresh();
|
||||
} catch (e) {
|
||||
notifyApiError(e);
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Card>
|
||||
<CardHeader
|
||||
class="flex flex-col gap-3 border-b py-3 sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<div class="min-w-0 flex-1">
|
||||
<CardTitle class="text-base">Спикеры</CardTitle>
|
||||
<CardDescription>BIRD-агенты, применяющие конфигурацию на нодах</CardDescription>
|
||||
</div>
|
||||
<div class="flex shrink-0 flex-wrap items-center justify-end gap-2">
|
||||
<Button size="sm" onclick={openCreate}><Plus />Добавить</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent class="p-4 pt-0">
|
||||
<AppDataTable
|
||||
columns={[...columns]}
|
||||
rows={items}
|
||||
rowKey={(s) => s.id}
|
||||
loading={initialLoading || loading}
|
||||
{error}
|
||||
emptyTitle="Нет спикеров"
|
||||
emptyDescription="Добавьте BIRD-агент для применения конфигурации."
|
||||
>
|
||||
{#snippet cell({ row: s, column })}
|
||||
{#if column.id === 'endpoint'}
|
||||
<span class="font-mono text-sm">{s.endpoint}</span>
|
||||
{:else if column.id === 'role'}
|
||||
<Badge variant="outline">{s.role}</Badge>
|
||||
{:else if column.id === 'last_applied_revision_id'}
|
||||
<span class="font-mono text-xs text-muted-foreground">
|
||||
{s.last_applied_revision_id ? s.last_applied_revision_id.slice(0, 8) + '…' : '—'}
|
||||
</span>
|
||||
{:else if column.id === 'actions'}
|
||||
<div class="flex gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
title="Запустить применение ревизии на спикере"
|
||||
onclick={() => applySpeaker(s.id)}
|
||||
disabled={applyingId === s.id}
|
||||
>
|
||||
<Play class="size-3" />
|
||||
{applyingId === s.id ? 'Apply…' : 'Apply'}
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon-sm" onclick={() => openEdit(s)}>
|
||||
<Pencil class="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</AppDataTable>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Dialog bind:open={dialogOpen}>
|
||||
<DialogContent class="sm:max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editTarget ? 'Редактировать спикера' : 'Новый спикер'}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div class="space-y-4 py-2">
|
||||
<FormField label="Endpoint" id="s-endpoint" required>
|
||||
<AppInput id="s-endpoint" placeholder="http://bird-agent:8081" bind:value={form.endpoint} />
|
||||
</FormField>
|
||||
<FormField label="Роль" id="s-role">
|
||||
<AppInput id="s-role" placeholder="operator" bind:value={form.role} />
|
||||
</FormField>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onclick={() => (dialogOpen = false)}>Отмена</Button>
|
||||
<Button onclick={save} disabled={saving}>
|
||||
{saving ? 'Сохранение…' : editTarget ? 'Сохранить' : 'Создать'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
@@ -0,0 +1,223 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { defaults, superForm } from 'sveltekit-superforms';
|
||||
import { zod4 } from 'sveltekit-superforms/adapters';
|
||||
import {
|
||||
emptyRevisionSettingsForm,
|
||||
revisionSettingsSchema
|
||||
} from '$lib/settings/revision-settings.schema.js';
|
||||
import {
|
||||
buildPayloadFromFormFields,
|
||||
loadSettings,
|
||||
partitionSettings,
|
||||
patchSettings,
|
||||
type AdditionalSettingEntry
|
||||
} from '$lib/settings/settings-api.js';
|
||||
import { REVISION_SETTING_KEYS } from '$lib/settings/settings-known-keys.js';
|
||||
import { Button } from '$lib/ui/core/button/index.js';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle
|
||||
} from '$lib/ui/core/card/index.js';
|
||||
import { Input } from '$lib/ui/core/input/index.js';
|
||||
import { Alert, AlertDescription, AlertTitle } from '$lib/ui/core/alert/index.js';
|
||||
import FormField from '$lib/ui/patterns/form/form-field.svelte';
|
||||
import EmptyState from '$lib/ui/patterns/empty-state/empty-state.svelte';
|
||||
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
|
||||
import Save from '@lucide/svelte/icons/save';
|
||||
import Plus from '@lucide/svelte/icons/plus';
|
||||
import Trash2 from '@lucide/svelte/icons/trash-2';
|
||||
import Info from '@lucide/svelte/icons/info';
|
||||
|
||||
let loading = $state(false);
|
||||
let saving = $state(false);
|
||||
let loaded = $state(false);
|
||||
let additionalSettings = $state<AdditionalSettingEntry[]>([]);
|
||||
let additionalIdCounter = $state(1);
|
||||
|
||||
const { form, errors, reset, validateForm } = superForm(
|
||||
defaults(emptyRevisionSettingsForm(), zod4(revisionSettingsSchema)),
|
||||
{
|
||||
validators: zod4(revisionSettingsSchema),
|
||||
SPA: true,
|
||||
dataType: 'json'
|
||||
}
|
||||
);
|
||||
|
||||
let hasValidationErrors = $derived(Boolean($errors.revision_retention_minutes?.length));
|
||||
|
||||
function addAdditionalSetting() {
|
||||
additionalSettings.push({ id: additionalIdCounter++, key: '', value: '' });
|
||||
}
|
||||
|
||||
function removeAdditionalSetting(id: number) {
|
||||
additionalSettings = additionalSettings.filter((entry) => entry.id !== id);
|
||||
}
|
||||
|
||||
let canSave = $derived.by(() => {
|
||||
if (loading || saving || hasValidationErrors || !loaded) return false;
|
||||
|
||||
const hasRetention = String($form.revision_retention_minutes ?? '').trim() !== '';
|
||||
const hasAdditional = additionalSettings.some((entry) => entry.key.trim() !== '');
|
||||
|
||||
return hasRetention || hasAdditional;
|
||||
});
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
try {
|
||||
const settings = await loadSettings();
|
||||
const { partitioned, nextId } = partitionSettings(settings, additionalIdCounter);
|
||||
reset({ data: partitioned.revision });
|
||||
additionalSettings = partitioned.additional;
|
||||
additionalIdCounter = nextId;
|
||||
loaded = true;
|
||||
} catch (e) {
|
||||
notifyApiError(e);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
const validation = await validateForm({ update: true });
|
||||
if (!validation.valid) {
|
||||
notify.error('Исправьте ошибки в полях настроек');
|
||||
return;
|
||||
}
|
||||
if (!canSave) {
|
||||
notify.error('Нечего сохранять или есть ошибки в полях');
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = buildPayloadFromFormFields(
|
||||
REVISION_SETTING_KEYS,
|
||||
$form as Record<string, string>,
|
||||
$errors as Partial<Record<string, string[]>>
|
||||
);
|
||||
|
||||
for (const entry of additionalSettings) {
|
||||
const key = entry.key.trim();
|
||||
if (!key) continue;
|
||||
payload[key] = entry.value;
|
||||
}
|
||||
|
||||
saving = true;
|
||||
try {
|
||||
await patchSettings(payload);
|
||||
notify.success('Системные настройки сохранены');
|
||||
await load();
|
||||
} catch (e) {
|
||||
notifyApiError(e);
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
void load();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-6">
|
||||
<Alert class="border-info/30 bg-info/5">
|
||||
<Info class="text-info" />
|
||||
<AlertTitle>Operator-only</AlertTitle>
|
||||
<AlertDescription>
|
||||
Изменение параметров через <code class="text-xs">PATCH /v1/settings</code> требует роли operator.
|
||||
При отсутствии прав API вернёт 403.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Хранение ревизий</CardTitle>
|
||||
<CardDescription>
|
||||
Автоматическая очистка старых ревизий. Последняя раскатанная ревизия не удаляется.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{#if loading && !loaded}
|
||||
<p class="text-sm text-muted-foreground">Загрузка…</p>
|
||||
{:else if !loaded}
|
||||
<Button variant="outline" onclick={load}>Загрузить настройки</Button>
|
||||
{:else}
|
||||
<FormField
|
||||
id="revision-retention-minutes"
|
||||
label="Время жизни ревизий, мин (revision_retention_minutes)"
|
||||
error={$errors.revision_retention_minutes?.[0]}
|
||||
description="Допустимый диапазон: 15–43200 минут."
|
||||
>
|
||||
<Input
|
||||
id="revision-retention-minutes"
|
||||
type="number"
|
||||
min="15"
|
||||
max="43200"
|
||||
bind:value={$form.revision_retention_minutes}
|
||||
placeholder="43200"
|
||||
/>
|
||||
</FormField>
|
||||
{/if}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div class="space-y-1">
|
||||
<CardTitle>Дополнительные параметры</CardTitle>
|
||||
<CardDescription>Произвольные KV-пары в global_settings.</CardDescription>
|
||||
</div>
|
||||
{#if loaded}
|
||||
<Button variant="outline" size="sm" onclick={addAdditionalSetting}>
|
||||
<Plus class="size-4" />
|
||||
Добавить строку
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{#if !loaded}
|
||||
<p class="text-sm text-muted-foreground">Загрузите настройки выше.</p>
|
||||
{:else if additionalSettings.length === 0}
|
||||
<EmptyState
|
||||
title="Нет дополнительных параметров"
|
||||
description="Добавьте KV-пару при необходимости."
|
||||
/>
|
||||
{:else}
|
||||
<div class="space-y-2">
|
||||
{#each additionalSettings as entry (entry.id)}
|
||||
<div class="grid grid-cols-1 gap-2 md:grid-cols-[1fr_1fr_auto]">
|
||||
<Input bind:value={entry.key} placeholder="Ключ (например, bird_log_level)" />
|
||||
<Input bind:value={entry.value} placeholder="Значение (строка)" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="Удалить строку"
|
||||
onclick={() => removeAdditionalSetting(entry.id)}
|
||||
>
|
||||
<Trash2 class="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{#if loaded}
|
||||
{#if hasValidationErrors}
|
||||
<p class="text-sm text-destructive">
|
||||
Есть ошибки в полях. Исправьте их, чтобы сохранить изменения.
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<Button onclick={save} disabled={!canSave}>
|
||||
<Save />
|
||||
{saving ? 'Сохранение…' : 'Применить настройки'}
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,94 @@
|
||||
<script lang="ts">
|
||||
import { resolve } from '$app/paths';
|
||||
import type { JobRow } from '$lib/api/types.js';
|
||||
import { formatDateTime } from '$lib/modules/display.js';
|
||||
import { jobKindTitle } from '$lib/operations/job-kind-label.js';
|
||||
import { jobStatusRu, jobStatusBadgeVariant } from '$lib/ui-labels.js';
|
||||
import { Badge } from '$lib/ui/core/badge/index.js';
|
||||
import { Button } from '$lib/ui/core/button/index.js';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription
|
||||
} from '$lib/ui/core/card/index.js';
|
||||
import AppDataTable from '$lib/ui/patterns/data-table/app-data-table.svelte';
|
||||
import ArrowRight from '@lucide/svelte/icons/arrow-right';
|
||||
import ExternalLink from '@lucide/svelte/icons/external-link';
|
||||
|
||||
type Props = {
|
||||
items: JobRow[];
|
||||
moduleNameById: ReadonlyMap<string, string>;
|
||||
loading?: boolean;
|
||||
initialLoading?: boolean;
|
||||
error?: string | null;
|
||||
};
|
||||
|
||||
let {
|
||||
items,
|
||||
moduleNameById,
|
||||
loading = false,
|
||||
initialLoading = false,
|
||||
error = null
|
||||
}: Props = $props();
|
||||
|
||||
const columns = [
|
||||
{ id: 'kind', label: 'Вид', sortable: true, sortValue: (j: JobRow) => j.kind },
|
||||
{
|
||||
id: 'status',
|
||||
label: 'Статус',
|
||||
sortable: true,
|
||||
sortValue: (j: JobRow) => j.status
|
||||
},
|
||||
{
|
||||
id: 'created',
|
||||
label: 'Создана',
|
||||
sortable: true,
|
||||
sortValue: (j: JobRow) => j.created_at ?? ''
|
||||
},
|
||||
{ id: 'actions', label: '', class: 'w-10' }
|
||||
] as const;
|
||||
</script>
|
||||
|
||||
<Card>
|
||||
<CardHeader
|
||||
class="flex flex-col gap-3 border-b py-3 sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<div class="min-w-0 flex-1">
|
||||
<CardTitle class="text-base">Последние задачи</CardTitle>
|
||||
<CardDescription>Фоновые задачи ingest, refresh и apply</CardDescription>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" href={resolve('/operations?tab=jobs')}>
|
||||
Все
|
||||
<ArrowRight class="size-3.5" />
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent class="p-4 pt-0">
|
||||
<AppDataTable
|
||||
columns={[...columns]}
|
||||
rows={items}
|
||||
rowKey={(j) => j.job_id}
|
||||
loading={initialLoading || loading}
|
||||
{error}
|
||||
emptyTitle="Нет задач"
|
||||
emptyDescription="Задачи появятся после refresh или деплоя."
|
||||
>
|
||||
{#snippet cell({ row: j, column })}
|
||||
{#if column.id === 'kind'}
|
||||
<span class="font-medium">{jobKindTitle(j, moduleNameById)}</span>
|
||||
{:else if column.id === 'status'}
|
||||
<Badge variant={jobStatusBadgeVariant(j.status)}>{jobStatusRu(j.status)}</Badge>
|
||||
{:else if column.id === 'created'}
|
||||
<span class="text-xs whitespace-nowrap text-muted-foreground">
|
||||
{formatDateTime(j.created_at)}
|
||||
</span>
|
||||
{:else if column.id === 'actions'}
|
||||
<Button variant="ghost" size="icon-sm" href={resolve('/operations?tab=jobs')}>
|
||||
<ExternalLink class="size-3.5" />
|
||||
</Button>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</AppDataTable>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -0,0 +1,89 @@
|
||||
<script lang="ts">
|
||||
import { resolve } from '$app/paths';
|
||||
import type { RevisionRow } from '$lib/api/types.js';
|
||||
import { formatDateTime } from '$lib/modules/display.js';
|
||||
import { Button } from '$lib/ui/core/button/index.js';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription
|
||||
} from '$lib/ui/core/card/index.js';
|
||||
import AppDataTable from '$lib/ui/patterns/data-table/app-data-table.svelte';
|
||||
import ArrowRight from '@lucide/svelte/icons/arrow-right';
|
||||
import ExternalLink from '@lucide/svelte/icons/external-link';
|
||||
|
||||
type Props = {
|
||||
items: RevisionRow[];
|
||||
loading?: boolean;
|
||||
initialLoading?: boolean;
|
||||
error?: string | null;
|
||||
};
|
||||
|
||||
let { items, loading = false, initialLoading = false, error = null }: Props = $props();
|
||||
|
||||
const columns = [
|
||||
{
|
||||
id: 'id',
|
||||
label: 'ID',
|
||||
sortable: true,
|
||||
sortValue: (rev: RevisionRow) => rev.id
|
||||
},
|
||||
{
|
||||
id: 'created',
|
||||
label: 'Создана',
|
||||
sortable: true,
|
||||
sortValue: (rev: RevisionRow) => rev.created_at ?? ''
|
||||
},
|
||||
{
|
||||
id: 'prefixes',
|
||||
label: 'Префиксов',
|
||||
sortable: true,
|
||||
sortValue: (rev: RevisionRow) => rev.materialized_prefix_count ?? 0
|
||||
},
|
||||
{ id: 'actions', label: '', class: 'w-10' }
|
||||
] as const;
|
||||
</script>
|
||||
|
||||
<Card>
|
||||
<CardHeader
|
||||
class="flex flex-col gap-3 border-b py-3 sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<div class="min-w-0 flex-1">
|
||||
<CardTitle class="text-base">Последние ревизии</CardTitle>
|
||||
<CardDescription>Снимки конфигурации BIRD после обновления модулей</CardDescription>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" href={resolve('/operations')}>
|
||||
Все
|
||||
<ArrowRight class="size-3.5" />
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent class="p-4 pt-0">
|
||||
<AppDataTable
|
||||
columns={[...columns]}
|
||||
rows={items}
|
||||
rowKey={(rev) => rev.id}
|
||||
loading={initialLoading || loading}
|
||||
{error}
|
||||
emptyTitle="Нет ревизий"
|
||||
emptyDescription="Ревизии появятся после обновления модулей."
|
||||
>
|
||||
{#snippet cell({ row: rev, column })}
|
||||
{#if column.id === 'id'}
|
||||
<span class="font-mono text-xs">{rev.id.slice(0, 8)}…</span>
|
||||
{:else if column.id === 'created'}
|
||||
<span class="text-sm whitespace-nowrap text-muted-foreground">
|
||||
{formatDateTime(rev.created_at)}
|
||||
</span>
|
||||
{:else if column.id === 'prefixes'}
|
||||
<span class="tabular-nums">{rev.materialized_prefix_count}</span>
|
||||
{:else if column.id === 'actions'}
|
||||
<Button variant="ghost" size="icon-sm" href={resolve('/operations')}>
|
||||
<ExternalLink class="size-3.5" />
|
||||
</Button>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</AppDataTable>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -0,0 +1,24 @@
|
||||
import { z } from 'zod';
|
||||
import { optionalIPv4, optionalIPv6 } from './ip-validation.js';
|
||||
|
||||
export const birdSettingsSchema = z.object({
|
||||
bird_router_id: optionalIPv4('router id'),
|
||||
bird_local_ipv4: optionalIPv4('local IPv4'),
|
||||
bird_local_ipv6: optionalIPv6('local IPv6'),
|
||||
bird_local_asn: z.string().refine((v) => v.trim() === '' || /^[1-9]\d*$/.test(v.trim()), {
|
||||
message: 'ASN должен быть целым числом больше 0'
|
||||
}),
|
||||
bird_bgp_source_ipv4: optionalIPv4('BGP source IPv4'),
|
||||
bird_bgp_source_ipv6: optionalIPv6('BGP source IPv6')
|
||||
});
|
||||
|
||||
export type BirdSettingsForm = z.infer<typeof birdSettingsSchema>;
|
||||
|
||||
export const emptyBirdSettingsForm = (): BirdSettingsForm => ({
|
||||
bird_router_id: '',
|
||||
bird_local_ipv4: '',
|
||||
bird_local_ipv6: '',
|
||||
bird_local_asn: '',
|
||||
bird_bgp_source_ipv4: '',
|
||||
bird_bgp_source_ipv6: ''
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
function isValidIPv4(value: string): boolean {
|
||||
const parts = value.split('.');
|
||||
if (parts.length !== 4) return false;
|
||||
for (const part of parts) {
|
||||
if (!/^\d{1,3}$/.test(part)) return false;
|
||||
if (part.length > 1 && part.startsWith('0')) return false;
|
||||
const n = Number(part);
|
||||
if (!Number.isInteger(n) || n < 0 || n > 255) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function isValidIPv6(value: string): boolean {
|
||||
if (!/^[0-9A-Fa-f:.]+$/.test(value)) return false;
|
||||
if ((value.match(/::/g) ?? []).length > 1) return false;
|
||||
const hasCompression = value.includes('::');
|
||||
const [leftRaw, rightRaw = ''] = value.split('::');
|
||||
const left = leftRaw === '' ? [] : leftRaw.split(':');
|
||||
const right = rightRaw === '' ? [] : rightRaw.split(':');
|
||||
if (left.some((part) => part === '') || right.some((part) => part === '')) return false;
|
||||
let segments = [...left, ...right];
|
||||
let ipv4TailSegments = 0;
|
||||
const lastSegment = segments.at(-1);
|
||||
if (lastSegment && lastSegment.includes('.')) {
|
||||
if (!isValidIPv4(lastSegment)) return false;
|
||||
segments = segments.slice(0, -1);
|
||||
ipv4TailSegments = 2;
|
||||
}
|
||||
for (const segment of segments) {
|
||||
if (!/^[0-9A-Fa-f]{1,4}$/.test(segment)) return false;
|
||||
}
|
||||
const totalSegments = segments.length + ipv4TailSegments;
|
||||
if (hasCompression) return totalSegments < 8;
|
||||
return totalSegments === 8;
|
||||
}
|
||||
|
||||
export const optionalIPv4 = (label: string) =>
|
||||
z.string().refine((v) => v.trim() === '' || isValidIPv4(v.trim()), {
|
||||
message: `Введите корректный IPv4 адрес (${label})`
|
||||
});
|
||||
|
||||
export const optionalIPv6 = (label: string) =>
|
||||
z.string().refine((v) => v.trim() === '' || isValidIPv6(v.trim()), {
|
||||
message: `Введите корректный IPv6 адрес (${label})`
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const revisionSettingsSchema = z.object({
|
||||
revision_retention_minutes: z.string().refine(
|
||||
(v) => {
|
||||
const s = v.trim();
|
||||
if (s === '') return true;
|
||||
const ttl = Number(s);
|
||||
return /^\d+$/.test(s) && Number.isInteger(ttl) && ttl >= 15 && ttl <= 43200;
|
||||
},
|
||||
{ message: 'TTL ревизий должен быть целым числом от 15 до 43200 минут' }
|
||||
)
|
||||
});
|
||||
|
||||
export type RevisionSettingsForm = z.infer<typeof revisionSettingsSchema>;
|
||||
|
||||
export const emptyRevisionSettingsForm = (): RevisionSettingsForm => ({
|
||||
revision_retention_minutes: ''
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import { apiJSON, apiMutate } from '$lib/api/client.js';
|
||||
import type { AppSettings } from '$lib/api/types.js';
|
||||
import { emptyBirdSettingsForm, type BirdSettingsForm } from './bird-settings.schema.js';
|
||||
import {
|
||||
emptyRevisionSettingsForm,
|
||||
type RevisionSettingsForm
|
||||
} from './revision-settings.schema.js';
|
||||
import {
|
||||
BIRD_SETTING_KEYS,
|
||||
KNOWN_SETTING_KEYS,
|
||||
NUMERIC_SETTING_KEYS,
|
||||
type BirdSettingKey,
|
||||
type KnownSettingKey,
|
||||
type RevisionSettingKey
|
||||
} from './settings-known-keys.js';
|
||||
|
||||
export type AdditionalSettingEntry = { id: number; key: string; value: string };
|
||||
|
||||
export type PartitionedSettings = {
|
||||
bird: BirdSettingsForm;
|
||||
revision: RevisionSettingsForm;
|
||||
additional: AdditionalSettingEntry[];
|
||||
};
|
||||
|
||||
function parseKnownValue(key: KnownSettingKey, value: unknown): string {
|
||||
if (NUMERIC_SETTING_KEYS.has(key)) {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return String(value);
|
||||
if (typeof value === 'string') return value;
|
||||
return '';
|
||||
}
|
||||
if (typeof value === 'string') return value;
|
||||
return '';
|
||||
}
|
||||
|
||||
export function partitionSettings(
|
||||
settings: AppSettings,
|
||||
nextId = 1
|
||||
): { partitioned: PartitionedSettings; nextId: number } {
|
||||
const bird = emptyBirdSettingsForm();
|
||||
const revision = emptyRevisionSettingsForm();
|
||||
const additional: AdditionalSettingEntry[] = [];
|
||||
let idCounter = nextId;
|
||||
|
||||
for (const [key, value] of Object.entries(settings as Record<string, unknown>)) {
|
||||
if ((BIRD_SETTING_KEYS as readonly string[]).includes(key)) {
|
||||
bird[key as BirdSettingKey] = parseKnownValue(key as KnownSettingKey, value);
|
||||
} else if (key === 'revision_retention_minutes') {
|
||||
revision.revision_retention_minutes = parseKnownValue(key as RevisionSettingKey, value);
|
||||
} else {
|
||||
additional.push({
|
||||
id: idCounter++,
|
||||
key,
|
||||
value: typeof value === 'string' ? value : String(value)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
partitioned: { bird, revision, additional },
|
||||
nextId: idCounter
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadSettings(): Promise<AppSettings> {
|
||||
return apiJSON<AppSettings>('/v1/settings');
|
||||
}
|
||||
|
||||
export async function patchSettings(payload: Record<string, string | number>): Promise<void> {
|
||||
await apiMutate('/v1/settings', 'PATCH', payload);
|
||||
}
|
||||
|
||||
export function buildPayloadFromFormFields(
|
||||
keys: readonly KnownSettingKey[],
|
||||
form: Record<string, string>,
|
||||
errors: Partial<Record<string, string[]>>
|
||||
): Record<string, string | number> {
|
||||
const payload: Record<string, string | number> = {};
|
||||
for (const key of keys) {
|
||||
const value = String(form[key] ?? '').trim();
|
||||
if (!value || errors[key]?.length) continue;
|
||||
if (NUMERIC_SETTING_KEYS.has(key)) payload[key] = Number(value);
|
||||
else payload[key] = value;
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
export function isKnownSettingKey(key: string): key is KnownSettingKey {
|
||||
return (KNOWN_SETTING_KEYS as readonly string[]).includes(key);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
export const BIRD_SETTING_KEYS = [
|
||||
'bird_router_id',
|
||||
'bird_local_ipv4',
|
||||
'bird_local_ipv6',
|
||||
'bird_local_asn',
|
||||
'bird_bgp_source_ipv4',
|
||||
'bird_bgp_source_ipv6'
|
||||
] as const;
|
||||
|
||||
export const REVISION_SETTING_KEYS = ['revision_retention_minutes'] as const;
|
||||
|
||||
export const KNOWN_SETTING_KEYS = [...BIRD_SETTING_KEYS, ...REVISION_SETTING_KEYS] as const;
|
||||
|
||||
export type BirdSettingKey = (typeof BIRD_SETTING_KEYS)[number];
|
||||
export type RevisionSettingKey = (typeof REVISION_SETTING_KEYS)[number];
|
||||
export type KnownSettingKey = (typeof KNOWN_SETTING_KEYS)[number];
|
||||
|
||||
export const NUMERIC_SETTING_KEYS = new Set<KnownSettingKey>([
|
||||
'bird_local_asn',
|
||||
'revision_retention_minutes'
|
||||
]);
|
||||
@@ -0,0 +1,15 @@
|
||||
import { z } from 'zod';
|
||||
import { birdSettingsSchema, emptyBirdSettingsForm } from './bird-settings.schema.js';
|
||||
import { emptyRevisionSettingsForm, revisionSettingsSchema } from './revision-settings.schema.js';
|
||||
|
||||
/** @deprecated Используйте birdSettingsSchema и revisionSettingsSchema отдельно. */
|
||||
export const settingsKnownSchema = birdSettingsSchema.merge(revisionSettingsSchema);
|
||||
|
||||
/** @deprecated Используйте BirdSettingsForm и RevisionSettingsForm. */
|
||||
export type SettingsKnownForm = z.infer<typeof settingsKnownSchema>;
|
||||
|
||||
/** @deprecated Используйте emptyBirdSettingsForm и emptyRevisionSettingsForm. */
|
||||
export const emptySettingsKnownForm = (): SettingsKnownForm => ({
|
||||
...emptyBirdSettingsForm(),
|
||||
...emptyRevisionSettingsForm()
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { browser } from '$app/environment';
|
||||
import { applyTheme, readTheme, THEME_STORAGE_KEY, type ThemePreference } from './theme.js';
|
||||
|
||||
class ThemePreferencesState {
|
||||
pref = $state<ThemePreference>('system');
|
||||
|
||||
init(): void {
|
||||
if (!browser) return;
|
||||
this.pref = readTheme();
|
||||
applyTheme(this.pref);
|
||||
}
|
||||
|
||||
set(next: ThemePreference): void {
|
||||
this.pref = next;
|
||||
}
|
||||
|
||||
persist(): void {
|
||||
if (!browser) return;
|
||||
applyTheme(this.pref);
|
||||
try {
|
||||
localStorage.setItem(THEME_STORAGE_KEY, this.pref);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const themeState = new ThemePreferencesState();
|
||||
@@ -15,7 +15,12 @@
|
||||
const open = $derived(!!state?.open);
|
||||
|
||||
function onOpenChange(v: boolean) {
|
||||
if (!v) closeConfirm();
|
||||
if (!v && state && !state.loading) closeConfirm();
|
||||
}
|
||||
|
||||
async function handleConfirm() {
|
||||
if (!state || state.loading) return;
|
||||
await state.onConfirm();
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -37,7 +42,10 @@
|
||||
? 'text-destructive-foreground bg-destructive hover:bg-destructive/90'
|
||||
: ''}
|
||||
disabled={state.loading}
|
||||
onclick={state.onConfirm}
|
||||
onclick={(e) => {
|
||||
e.preventDefault();
|
||||
void handleConfirm();
|
||||
}}
|
||||
>
|
||||
{state.loading ? '…' : (state.confirmLabel ?? 'Подтвердить')}
|
||||
</AlertDialogAction>
|
||||
|
||||
@@ -21,8 +21,9 @@ export function confirm(options: ConfirmOptions) {
|
||||
open: true,
|
||||
loading: false,
|
||||
onConfirm: async () => {
|
||||
if (!confirmState.current) return;
|
||||
confirmState.current = { ...confirmState.current, loading: true };
|
||||
const current = confirmState.current;
|
||||
if (!current || current.loading) return;
|
||||
confirmState.current = { ...current, loading: true };
|
||||
try {
|
||||
await options.onConfirm();
|
||||
resolve(true);
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
<script lang="ts">
|
||||
import type { Component } from 'svelte';
|
||||
import { Badge } from '$lib/ui/core/badge/index.js';
|
||||
import { Button } from '$lib/ui/core/button/index.js';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription
|
||||
} from '$lib/ui/core/card/index.js';
|
||||
import CardSkeleton from '$lib/ui/patterns/feedback/card-skeleton.svelte';
|
||||
import { cn } from '$lib/utils.js';
|
||||
import ArrowRight from '@lucide/svelte/icons/arrow-right';
|
||||
|
||||
export type KpiAccent = {
|
||||
border: string;
|
||||
bg: string;
|
||||
iconBg: string;
|
||||
iconText: string;
|
||||
};
|
||||
|
||||
export type KpiCardItem = {
|
||||
id: string;
|
||||
label: string;
|
||||
value: string;
|
||||
description: string;
|
||||
icon: Component;
|
||||
accent: KpiAccent;
|
||||
badge: string;
|
||||
badgeClass?: string;
|
||||
badgeVariant?: 'default' | 'secondary' | 'destructive' | 'outline';
|
||||
valueClass?: string;
|
||||
error?: string | null;
|
||||
href?: string;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
cards: KpiCardItem[];
|
||||
loading?: boolean;
|
||||
skeletonCount?: number;
|
||||
class?: string;
|
||||
};
|
||||
|
||||
let { cards, loading = false, skeletonCount = 3, class: className }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class={cn('grid gap-4', className)}>
|
||||
{#if loading}
|
||||
{#each Array(skeletonCount) as _, i (i)}
|
||||
<CardSkeleton />
|
||||
{/each}
|
||||
{:else}
|
||||
{#each cards as card (card.id)}
|
||||
{@const Icon = card.icon}
|
||||
{@const a = card.accent}
|
||||
<Card
|
||||
class={cn(
|
||||
'overflow-hidden border-l-4 shadow-sm',
|
||||
card.href ? 'transition-colors hover:border-primary/35' : '',
|
||||
a.border,
|
||||
a.bg
|
||||
)}
|
||||
>
|
||||
<CardHeader class="pb-2">
|
||||
{#if card.href}
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<CardDescription class="flex min-w-0 items-center gap-2">
|
||||
<span
|
||||
class={cn(
|
||||
'flex size-9 shrink-0 items-center justify-center rounded-lg',
|
||||
a.iconBg
|
||||
)}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Icon class={cn('size-4', a.iconText)} />
|
||||
</span>
|
||||
<span class="truncate">{card.label}</span>
|
||||
</CardDescription>
|
||||
<Button variant="ghost" size="icon-sm" href={card.href}>
|
||||
<ArrowRight class="size-3.5" aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
{:else}
|
||||
<CardDescription class="flex min-w-0 items-center gap-2">
|
||||
<span
|
||||
class={cn('flex size-9 shrink-0 items-center justify-center rounded-lg', a.iconBg)}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Icon class={cn('size-4', a.iconText)} />
|
||||
</span>
|
||||
<span class="truncate">{card.label}</span>
|
||||
</CardDescription>
|
||||
{/if}
|
||||
<CardTitle class={cn('font-bold tabular-nums', card.valueClass ?? 'text-3xl')}
|
||||
>{card.value}</CardTitle
|
||||
>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-2">
|
||||
<Badge variant={card.badgeVariant ?? 'outline'} class={card.badgeClass}
|
||||
>{card.badge}</Badge
|
||||
>
|
||||
{#if card.error}
|
||||
<p class="text-xs text-destructive">{card.error}</p>
|
||||
{:else}
|
||||
<p class="text-xs text-muted-foreground">{card.description}</p>
|
||||
{/if}
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,38 @@
|
||||
<script lang="ts">
|
||||
type Props = {
|
||||
items: readonly string[];
|
||||
rowHeight?: number;
|
||||
viewportHeight?: number;
|
||||
class?: string;
|
||||
};
|
||||
|
||||
let { items, rowHeight = 20, viewportHeight = 320, class: className = '' }: Props = $props();
|
||||
|
||||
let scrollTop = $state(0);
|
||||
const totalHeight = $derived(items.length * rowHeight);
|
||||
const startIndex = $derived(Math.max(0, Math.floor(scrollTop / rowHeight) - 2));
|
||||
const visibleCount = $derived(Math.ceil(viewportHeight / rowHeight) + 4);
|
||||
const visibleItems = $derived(items.slice(startIndex, startIndex + visibleCount));
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="overflow-auto rounded-md border {className}"
|
||||
style="height: {viewportHeight}px"
|
||||
onscroll={(e) => {
|
||||
scrollTop = e.currentTarget.scrollTop;
|
||||
}}
|
||||
>
|
||||
<div style="height: {totalHeight}px; position: relative">
|
||||
{#each visibleItems as pfx, i (`${startIndex + i}-${pfx}`)}
|
||||
<p
|
||||
class="truncate px-2 font-mono text-xs leading-5"
|
||||
style="position: absolute; top: {(startIndex + i) *
|
||||
rowHeight}px; left: 0; right: 0; height: {rowHeight}px"
|
||||
>
|
||||
{pfx}
|
||||
</p>
|
||||
{:else}
|
||||
<p class="text-muted-foreground p-2 text-sm">Нет префиксов</p>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
@@ -5,19 +5,17 @@
|
||||
import favicon from '$lib/assets/favicon.svg';
|
||||
import AppLayout from '$lib/ui/app/layout/app-layout.svelte';
|
||||
import ConfirmDialog from '$lib/ui/patterns/confirm/confirm-dialog.svelte';
|
||||
import { applyTheme, readTheme, THEME_STORAGE_KEY, type ThemePreference } from '$lib/theme.js';
|
||||
import { applyTheme } from '$lib/theme.js';
|
||||
import { themeState } from '$lib/theme-preferences.svelte.js';
|
||||
import { Toaster } from 'svelte-sonner';
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
let themePref = $state<ThemePreference>('system');
|
||||
|
||||
onMount(() => {
|
||||
themePref = readTheme();
|
||||
applyTheme(themePref);
|
||||
themeState.init();
|
||||
const mq = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
const onOs = () => {
|
||||
if (themePref === 'system') applyTheme('system');
|
||||
if (themeState.pref === 'system') applyTheme('system');
|
||||
};
|
||||
mq.addEventListener('change', onOs);
|
||||
return () => mq.removeEventListener('change', onOs);
|
||||
@@ -25,16 +23,11 @@
|
||||
|
||||
$effect(() => {
|
||||
if (!browser) return;
|
||||
applyTheme(themePref);
|
||||
try {
|
||||
localStorage.setItem(THEME_STORAGE_KEY, themePref);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
themeState.persist();
|
||||
});
|
||||
|
||||
const sonnerTheme = $derived(
|
||||
themePref === 'system' ? 'system' : themePref === 'dark' ? 'dark' : 'light'
|
||||
themeState.pref === 'system' ? 'system' : themeState.pref === 'dark' ? 'dark' : 'light'
|
||||
);
|
||||
</script>
|
||||
|
||||
@@ -44,4 +37,4 @@
|
||||
</svelte:head>
|
||||
<Toaster richColors theme={sonnerTheme} position="top-right" />
|
||||
<ConfirmDialog />
|
||||
<AppLayout bind:theme={themePref}>{@render children()}</AppLayout>
|
||||
<AppLayout bind:theme={themeState.pref}>{@render children()}</AppLayout>
|
||||
|
||||
+244
-124
@@ -1,25 +1,37 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { apiJSON, apiFetch, apiPageAll } from '$lib/api/client.js';
|
||||
import { resolve } from '$app/paths';
|
||||
import { apiJSON, apiFetch } from '$lib/api/client.js';
|
||||
import type {
|
||||
ModuleRow,
|
||||
ModulesResponse,
|
||||
RevisionRow,
|
||||
RevisionsResponse,
|
||||
PeerRow,
|
||||
PeersResponse,
|
||||
SpeakerRow,
|
||||
SpeakersResponse,
|
||||
JobRow,
|
||||
JobsResponse
|
||||
} from '$lib/api/types.js';
|
||||
import { Badge } from '$lib/components/ui/badge/index.js';
|
||||
import { Button } from '$lib/components/ui/button/index.js';
|
||||
import { Badge } from '$lib/ui/core/badge/index.js';
|
||||
import { Button } from '$lib/ui/core/button/index.js';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription
|
||||
} from '$lib/components/ui/card/index.js';
|
||||
} from '$lib/ui/core/card/index.js';
|
||||
import { Alert, AlertDescription, AlertTitle } from '$lib/ui/core/alert/index.js';
|
||||
import { Skeleton } from '$lib/ui/core/skeleton/index.js';
|
||||
import CardSkeleton from '$lib/ui/patterns/feedback/card-skeleton.svelte';
|
||||
import KpiMetricsGrid from '$lib/ui/patterns/kpi/kpi-metrics-grid.svelte';
|
||||
import PageHeader from '$lib/ui/app/page-header/page-header.svelte';
|
||||
import { notifyApiError } from '$lib/ui/app/toast.js';
|
||||
import OverviewRecentJobsCard from '$lib/components/overview/OverviewRecentJobsCard.svelte';
|
||||
import OverviewRecentRevisionsCard from '$lib/components/overview/OverviewRecentRevisionsCard.svelte';
|
||||
import { cn } from '$lib/utils.js';
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const resolve = (path: string) => path as any;
|
||||
import CheckCircle from '@lucide/svelte/icons/check-circle';
|
||||
import XCircle from '@lucide/svelte/icons/x-circle';
|
||||
import Boxes from '@lucide/svelte/icons/boxes';
|
||||
@@ -27,51 +39,34 @@
|
||||
import Radio from '@lucide/svelte/icons/radio';
|
||||
import Activity from '@lucide/svelte/icons/activity';
|
||||
import Clock from '@lucide/svelte/icons/clock';
|
||||
import ArrowRight from '@lucide/svelte/icons/arrow-right';
|
||||
import LayoutDashboard from '@lucide/svelte/icons/layout-dashboard';
|
||||
import PageHeader from '$lib/ui/app/page-header/page-header.svelte';
|
||||
import Info from '@lucide/svelte/icons/info';
|
||||
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
|
||||
import Plus from '@lucide/svelte/icons/plus';
|
||||
import Tags from '@lucide/svelte/icons/tags';
|
||||
import Share2 from '@lucide/svelte/icons/share-2';
|
||||
import Play from '@lucide/svelte/icons/play';
|
||||
import Gauge from '@lucide/svelte/icons/gauge';
|
||||
|
||||
let healthy = $state<boolean | null>(null);
|
||||
let modules = $state(0);
|
||||
let revisions = $state(0);
|
||||
let peers = $state(0);
|
||||
let speakers = $state(0);
|
||||
let moduleItems = $state<ModuleRow[]>([]);
|
||||
let modulesHasMore = $state(false);
|
||||
let revisionItems = $state<RevisionRow[]>([]);
|
||||
let revisionsHasMore = $state(false);
|
||||
let peerItems = $state<PeerRow[]>([]);
|
||||
let peersHasMore = $state(false);
|
||||
let speakerItems = $state<SpeakerRow[]>([]);
|
||||
let speakersHasMore = $state(false);
|
||||
let jobItems = $state<JobRow[]>([]);
|
||||
let recentJobs = $state<JobRow[]>([]);
|
||||
let recentRevisions = $state<RevisionRow[]>([]);
|
||||
let runningJobs = $state(0);
|
||||
let loading = $state(true);
|
||||
let initialLoading = $state(true);
|
||||
let refreshing = $state(false);
|
||||
let loadError = $state<string | null>(null);
|
||||
let lastUpdated = $state<Date | null>(null);
|
||||
|
||||
async function countAll(path: string): Promise<number> {
|
||||
const items = await apiPageAll<unknown>(path, 500);
|
||||
return items.length;
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
loading = true;
|
||||
try {
|
||||
const h = await apiFetch('/v1/health');
|
||||
healthy = h.ok;
|
||||
} catch {
|
||||
healthy = false;
|
||||
}
|
||||
try {
|
||||
const [mCount, rCount, pCount, sCount, j] = await Promise.all([
|
||||
countAll('/v1/modules'),
|
||||
countAll('/v1/revisions'),
|
||||
countAll('/v1/peers'),
|
||||
countAll('/v1/speakers'),
|
||||
apiJSON<JobsResponse>('/v1/jobs?limit=100')
|
||||
]);
|
||||
modules = mCount;
|
||||
revisions = rCount;
|
||||
peers = pCount;
|
||||
speakers = sCount;
|
||||
runningJobs = (j.items ?? []).filter(
|
||||
(i) => i.status === 'running' || i.status === 'queued'
|
||||
).length;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
loading = false;
|
||||
});
|
||||
const moduleNameById = $derived(new Map(moduleItems.map((m) => [m.id, m.name])));
|
||||
|
||||
const statAccents = [
|
||||
{
|
||||
@@ -106,123 +101,248 @@
|
||||
}
|
||||
] as const;
|
||||
|
||||
const stats = $derived([
|
||||
function countBadge(count: number, hasMore: boolean, suffix: string) {
|
||||
if (hasMore) return '200+';
|
||||
return suffix;
|
||||
}
|
||||
|
||||
const kpiCards = $derived.by(() => [
|
||||
{
|
||||
id: 'modules',
|
||||
label: 'Модули',
|
||||
value: modules,
|
||||
href: '/modules',
|
||||
value: initialLoading ? '—' : String(moduleItems.length),
|
||||
href: '/modules' as const,
|
||||
icon: Boxes,
|
||||
description: 'AS, CDN, домены, IP',
|
||||
accent: statAccents[0]
|
||||
accent: statAccents[0],
|
||||
badge: countBadge(moduleItems.length, modulesHasMore, 'в системе')
|
||||
},
|
||||
{
|
||||
id: 'peers',
|
||||
label: 'Пиры',
|
||||
value: peers,
|
||||
href: '/network',
|
||||
value: initialLoading ? '—' : String(peerItems.length),
|
||||
href: '/network' as const,
|
||||
icon: GitBranch,
|
||||
description: 'BGP-соседи',
|
||||
accent: statAccents[1]
|
||||
accent: statAccents[1],
|
||||
badge: countBadge(peerItems.length, peersHasMore, 'peers')
|
||||
},
|
||||
{
|
||||
id: 'speakers',
|
||||
label: 'Спикеры',
|
||||
value: speakers,
|
||||
href: '/network',
|
||||
value: initialLoading ? '—' : String(speakerItems.length),
|
||||
href: '/network' as const,
|
||||
icon: Radio,
|
||||
description: 'BIRD-агенты',
|
||||
accent: statAccents[2]
|
||||
accent: statAccents[2],
|
||||
badge: countBadge(speakerItems.length, speakersHasMore, 'agents')
|
||||
},
|
||||
{
|
||||
id: 'revisions',
|
||||
label: 'Ревизии',
|
||||
value: revisions,
|
||||
href: '/operations',
|
||||
value: initialLoading ? '—' : String(revisionItems.length),
|
||||
href: '/operations' as const,
|
||||
icon: Activity,
|
||||
description: 'История конфигураций',
|
||||
accent: statAccents[3]
|
||||
accent: statAccents[3],
|
||||
badge: countBadge(revisionItems.length, revisionsHasMore, 'configs')
|
||||
},
|
||||
{
|
||||
id: 'jobs',
|
||||
label: 'Активных задач',
|
||||
value: runningJobs,
|
||||
href: '/operations',
|
||||
value: initialLoading ? '—' : String(runningJobs),
|
||||
href: '/operations?tab=jobs' as const,
|
||||
icon: Clock,
|
||||
description: 'Выполняются сейчас',
|
||||
accent: statAccents[4]
|
||||
description: 'queued и running в выборке',
|
||||
accent: statAccents[4],
|
||||
badge: 'running'
|
||||
}
|
||||
]);
|
||||
|
||||
function toErrorMessage(e: unknown): string {
|
||||
return e instanceof Error ? e.message : String(e);
|
||||
}
|
||||
|
||||
async function load() {
|
||||
if (!initialLoading) refreshing = true;
|
||||
loadError = null;
|
||||
try {
|
||||
try {
|
||||
const h = await apiFetch('/v1/health');
|
||||
healthy = h.ok;
|
||||
if (!h.ok) {
|
||||
loadError = `GET /v1/health: HTTP ${h.status}`;
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
healthy = false;
|
||||
loadError = toErrorMessage(e);
|
||||
notifyApiError(e);
|
||||
return;
|
||||
}
|
||||
|
||||
const [m, p, s, r, j] = await Promise.allSettled([
|
||||
apiJSON<ModulesResponse>('/v1/modules?limit=200'),
|
||||
apiJSON<PeersResponse>('/v1/peers?limit=200'),
|
||||
apiJSON<SpeakersResponse>('/v1/speakers?limit=200'),
|
||||
apiJSON<RevisionsResponse>('/v1/revisions?limit=10'),
|
||||
apiJSON<JobsResponse>('/v1/jobs?limit=10')
|
||||
]);
|
||||
|
||||
const firstReject = [m, p, s, r, j].find((x) => x.status === 'rejected');
|
||||
if (firstReject?.status === 'rejected') {
|
||||
loadError = toErrorMessage(firstReject.reason);
|
||||
notifyApiError(firstReject.reason);
|
||||
}
|
||||
|
||||
if (m.status === 'fulfilled') {
|
||||
moduleItems = m.value.items ?? [];
|
||||
modulesHasMore = m.value.has_more;
|
||||
}
|
||||
if (p.status === 'fulfilled') {
|
||||
peerItems = p.value.items ?? [];
|
||||
peersHasMore = p.value.has_more;
|
||||
}
|
||||
if (s.status === 'fulfilled') {
|
||||
speakerItems = s.value.items ?? [];
|
||||
speakersHasMore = s.value.has_more;
|
||||
}
|
||||
if (r.status === 'fulfilled') {
|
||||
revisionItems = r.value.items ?? [];
|
||||
revisionsHasMore = r.value.has_more;
|
||||
}
|
||||
if (j.status === 'fulfilled') {
|
||||
jobItems = j.value.items ?? [];
|
||||
recentJobs = jobItems.slice(0, 10);
|
||||
recentRevisions = revisionItems.slice(0, 10);
|
||||
runningJobs = jobItems.filter(
|
||||
(i) => i.status === 'running' || i.status === 'queued'
|
||||
).length;
|
||||
}
|
||||
|
||||
if (!loadError) lastUpdated = new Date();
|
||||
} finally {
|
||||
initialLoading = false;
|
||||
refreshing = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(load);
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-6">
|
||||
<PageHeader
|
||||
title="Обзор"
|
||||
description="Состояние панели управления EvoBGP."
|
||||
description={lastUpdated
|
||||
? `Состояние панели управления EvoBGP. Обновлено: ${lastUpdated.toLocaleTimeString('ru-RU')}`
|
||||
: 'Состояние панели управления EvoBGP.'}
|
||||
icon={LayoutDashboard}
|
||||
iconClass="bg-primary/10 text-primary"
|
||||
>
|
||||
{#snippet actions()}
|
||||
<Button variant="outline" size="sm" onclick={load} disabled={refreshing}>
|
||||
<RefreshCw class={refreshing ? 'animate-spin' : ''} />
|
||||
Обновить
|
||||
</Button>
|
||||
{/snippet}
|
||||
</PageHeader>
|
||||
|
||||
<Alert class="border-info/30 bg-info/5">
|
||||
<Info class="text-info" />
|
||||
<AlertTitle>Панель управления EvoBGP</AlertTitle>
|
||||
<AlertDescription>
|
||||
Сводка по модулям, сети и фоновым задачам. Настройка префиксов — на странице
|
||||
<Button variant="link" class="h-auto p-0" href={resolve('/modules')}>Модули</Button>, деплой и
|
||||
ревизии —
|
||||
<Button variant="link" class="h-auto p-0" href={resolve('/operations')}>Операции</Button>,
|
||||
здоровье системы —
|
||||
<Button variant="link" class="h-auto p-0" href={resolve('/monitoring')}>Мониторинг</Button>.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
{#if healthy === null}
|
||||
<Alert>
|
||||
<Skeleton class="size-5 rounded-full" />
|
||||
<AlertTitle>Проверка API…</AlertTitle>
|
||||
<AlertDescription>Запрос к <code class="text-xs">/v1/health</code></AlertDescription>
|
||||
</Alert>
|
||||
{:else if healthy && !loadError}
|
||||
<Alert class="border-success/30 bg-success/5">
|
||||
<CheckCircle class="text-success" />
|
||||
<AlertTitle>API работает</AlertTitle>
|
||||
<AlertDescription>Сервер отвечает на запросы health-check.</AlertDescription>
|
||||
</Alert>
|
||||
{:else if healthy && loadError}
|
||||
<Alert class="border-warning/30 bg-warning/5">
|
||||
<Info class="text-warning" />
|
||||
<AlertTitle>API доступен, данные не загружены</AlertTitle>
|
||||
<AlertDescription>
|
||||
{loadError}. Для локального демо укажите Bearer-токен
|
||||
<code class="text-xs">dev</code> в
|
||||
<Button variant="link" class="h-auto p-0" href={resolve('/settings')}>Настройках</Button>
|
||||
(нужен <code class="text-xs">EVOBGP_DEV_INSECURE=1</code> на API).
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
{:else}
|
||||
<Alert variant="destructive" class="border-destructive/30 bg-destructive/5">
|
||||
<XCircle class="text-destructive" />
|
||||
<AlertTitle>API недоступен</AlertTitle>
|
||||
<AlertDescription>
|
||||
{loadError ??
|
||||
'Не удалось получить ответ от сервера. Проверьте, что API запущен (порт 8080), в dev — `npm run dev` с прокси Vite, в Docker — контейнер evobgp-api / evobgp-all и nginx в evobgp-web.'}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
{/if}
|
||||
|
||||
<KpiMetricsGrid
|
||||
cards={kpiCards}
|
||||
loading={initialLoading}
|
||||
skeletonCount={5}
|
||||
class="sm:grid-cols-2 lg:grid-cols-3"
|
||||
/>
|
||||
|
||||
<!-- Health -->
|
||||
<Card>
|
||||
<CardContent class="flex items-center gap-3 py-4">
|
||||
{#if healthy === null}
|
||||
<div class="size-3 animate-pulse rounded-full bg-muted"></div>
|
||||
<span class="text-sm text-muted-foreground">Проверка…</span>
|
||||
{:else if healthy}
|
||||
<CheckCircle class="size-5 text-green-500" />
|
||||
<span class="font-medium text-green-700 dark:text-green-400">API работает</span>
|
||||
{:else}
|
||||
<XCircle class="size-5 text-red-500" />
|
||||
<span class="font-medium text-red-700 dark:text-red-400">API недоступен</span>
|
||||
{/if}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- Stats grid -->
|
||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{#each stats as stat (stat.label)}
|
||||
{@const Icon = stat.icon}
|
||||
{@const a = stat.accent}
|
||||
<Card
|
||||
class={cn(
|
||||
'overflow-hidden border-l-4 shadow-sm transition-colors hover:border-primary/35',
|
||||
a.border,
|
||||
a.bg
|
||||
)}
|
||||
>
|
||||
<CardHeader class="pb-2">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<CardDescription class="flex min-w-0 items-center gap-2">
|
||||
<span
|
||||
class={cn('flex size-9 shrink-0 items-center justify-center rounded-lg', a.iconBg)}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Icon class={cn('size-4', a.iconText)} />
|
||||
</span>
|
||||
<span class="truncate">{stat.label}</span>
|
||||
</CardDescription>
|
||||
<Button variant="ghost" size="icon-sm" href={resolve(stat.href)}>
|
||||
<ArrowRight class="size-3.5" aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
<CardTitle class="text-3xl font-bold tabular-nums">
|
||||
{loading ? '—' : stat.value}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p class="text-xs text-muted-foreground">{stat.description}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/each}
|
||||
<div class="grid gap-4 lg:grid-cols-2">
|
||||
<OverviewRecentJobsCard
|
||||
items={recentJobs}
|
||||
{moduleNameById}
|
||||
loading={refreshing}
|
||||
{initialLoading}
|
||||
error={loadError}
|
||||
/>
|
||||
<OverviewRecentRevisionsCard
|
||||
items={recentRevisions}
|
||||
loading={refreshing}
|
||||
{initialLoading}
|
||||
error={loadError}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Quick links -->
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardHeader class="border-b py-3">
|
||||
<CardTitle class="text-base">Быстрые действия</CardTitle>
|
||||
<CardDescription>Частые переходы к настройке и деплою</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent class="flex flex-wrap gap-2">
|
||||
<Button variant="outline" size="sm" href={resolve('/modules')}>Создать модуль</Button>
|
||||
<Button variant="outline" size="sm" href={resolve('/directories')}>Добавить community</Button>
|
||||
<Button variant="outline" size="sm" href={resolve('/network')}>Добавить пира</Button>
|
||||
<Button variant="outline" size="sm" href={resolve('/operations')}>Деплой (Apply)</Button>
|
||||
<Button variant="outline" size="sm" href={resolve('/monitoring')}>Мониторинг</Button>
|
||||
<CardContent class="flex flex-wrap gap-2 p-4 pt-4">
|
||||
<Button variant="outline" size="sm" href={resolve('/modules')}>
|
||||
<Plus class="size-4" />
|
||||
Создать модуль
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" href={resolve('/directories')}>
|
||||
<Tags class="size-4" />
|
||||
Добавить community
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" href={resolve('/network')}>
|
||||
<Share2 class="size-4" />
|
||||
Добавить пира
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" href={resolve('/operations')}>
|
||||
<Play class="size-4" />
|
||||
Деплой (Apply)
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" href={resolve('/monitoring')}>
|
||||
<Gauge class="size-4" />
|
||||
Мониторинг
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -1,218 +1,180 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { apiJSON, apiMutate } from '$lib/api/client.js';
|
||||
import { resolve } from '$app/paths';
|
||||
import { apiJSON } from '$lib/api/client.js';
|
||||
import type {
|
||||
BgpCommunity,
|
||||
BgpCommunityCreate,
|
||||
CommunitiesResponse,
|
||||
DohProfile,
|
||||
DohProfileCreate,
|
||||
DohProfilesResponse
|
||||
} from '$lib/api/types.js';
|
||||
import { Button } from '$lib/components/ui/button/index.js';
|
||||
import { Badge } from '$lib/ui/core/badge/index.js';
|
||||
import { Button } from '$lib/ui/core/button/index.js';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription
|
||||
} from '$lib/components/ui/card/index.js';
|
||||
import FormField from '$lib/ui/patterns/form/form-field.svelte';
|
||||
import AppInput from '$lib/ui/patterns/form/app-input.svelte';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '$lib/components/ui/tabs/index.js';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
DialogDescription
|
||||
} from '$lib/components/ui/dialog/index.js';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogContent,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogCancel,
|
||||
AlertDialogAction
|
||||
} from '$lib/components/ui/alert-dialog/index.js';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow
|
||||
} from '$lib/components/ui/table/index.js';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import Plus from '@lucide/svelte/icons/plus';
|
||||
import Pencil from '@lucide/svelte/icons/pencil';
|
||||
import Trash2 from '@lucide/svelte/icons/trash-2';
|
||||
} from '$lib/ui/core/card/index.js';
|
||||
import { Alert, AlertDescription, AlertTitle } from '$lib/ui/core/alert/index.js';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '$lib/ui/core/tabs/index.js';
|
||||
import CardSkeleton from '$lib/ui/patterns/feedback/card-skeleton.svelte';
|
||||
import KpiMetricsGrid from '$lib/ui/patterns/kpi/kpi-metrics-grid.svelte';
|
||||
import PageHeader from '$lib/ui/app/page-header/page-header.svelte';
|
||||
import { notifyApiError } from '$lib/ui/app/toast.js';
|
||||
import DirectoriesCommunitiesCard from '$lib/components/directories/DirectoriesCommunitiesCard.svelte';
|
||||
import DirectoriesDohProfilesCard from '$lib/components/directories/DirectoriesDohProfilesCard.svelte';
|
||||
import { cn } from '$lib/utils.js';
|
||||
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
|
||||
import BookOpen from '@lucide/svelte/icons/book-open';
|
||||
import PageHeader from '$lib/ui/app/page-header/page-header.svelte';
|
||||
import Info from '@lucide/svelte/icons/info';
|
||||
import Tags from '@lucide/svelte/icons/tags';
|
||||
import Globe from '@lucide/svelte/icons/globe';
|
||||
import Library from '@lucide/svelte/icons/library';
|
||||
import ArrowRight from '@lucide/svelte/icons/arrow-right';
|
||||
|
||||
// --- Communities ---
|
||||
let communities = $state<BgpCommunity[]>([]);
|
||||
let commLoading = $state(false);
|
||||
let commDialog = $state(false);
|
||||
let commEdit = $state<BgpCommunity | null>(null);
|
||||
let commForm = $state<BgpCommunityCreate>({ community: '', title: '' });
|
||||
let commSaving = $state(false);
|
||||
let commDeleteTarget = $state<BgpCommunity | null>(null);
|
||||
|
||||
// --- DoH Profiles ---
|
||||
let dohProfiles = $state<DohProfile[]>([]);
|
||||
let dohLoading = $state(false);
|
||||
let dohDialog = $state(false);
|
||||
let dohEdit = $state<DohProfile | null>(null);
|
||||
let dohForm = $state<DohProfileCreate & { timeout_ms?: number | null }>({
|
||||
url: '',
|
||||
timeout_ms: null,
|
||||
vault_secret_ref: null
|
||||
});
|
||||
let dohSaving = $state(false);
|
||||
let dohDeleteTarget = $state<DohProfile | null>(null);
|
||||
let loading = $state(false);
|
||||
let initialLoading = $state(true);
|
||||
let loadError = $state<string | null>(null);
|
||||
let lastUpdated = $state<Date | null>(null);
|
||||
|
||||
async function loadComm() {
|
||||
commLoading = true;
|
||||
try {
|
||||
const r = await apiJSON<CommunitiesResponse>('/v1/communities?limit=200');
|
||||
communities = r.items;
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
commLoading = false;
|
||||
const statAccents = [
|
||||
{
|
||||
border: 'border-l-chart-2',
|
||||
bg: 'bg-chart-2/5',
|
||||
iconBg: 'bg-chart-2/15',
|
||||
iconText: 'text-chart-2'
|
||||
},
|
||||
{
|
||||
border: 'border-l-chart-3',
|
||||
bg: 'bg-chart-3/5',
|
||||
iconBg: 'bg-chart-3/15',
|
||||
iconText: 'text-chart-3'
|
||||
},
|
||||
{
|
||||
border: 'border-l-chart-1',
|
||||
bg: 'bg-chart-1/5',
|
||||
iconBg: 'bg-chart-1/15',
|
||||
iconText: 'text-chart-1'
|
||||
}
|
||||
] as const;
|
||||
|
||||
const kpiCards = $derived.by(() => [
|
||||
{
|
||||
id: 'communities',
|
||||
label: 'Сообщества BGP',
|
||||
value: initialLoading ? '—' : String(communities.length),
|
||||
description: 'теги префиксов в AS- и CDN-модулях',
|
||||
icon: Tags,
|
||||
accent: statAccents[0],
|
||||
badge: 'community'
|
||||
},
|
||||
{
|
||||
id: 'doh',
|
||||
label: 'DoH профили',
|
||||
value: initialLoading ? '—' : String(dohProfiles.length),
|
||||
description: 'резолвинг доменных модулей',
|
||||
icon: Globe,
|
||||
accent: statAccents[1],
|
||||
badge: 'DNS-over-HTTPS'
|
||||
},
|
||||
{
|
||||
id: 'shared',
|
||||
label: 'Справочники',
|
||||
value: 'Общие',
|
||||
description: 'используются всеми модулями tenant',
|
||||
icon: Library,
|
||||
accent: statAccents[2],
|
||||
badge: 'tenant-wide',
|
||||
href: '/modules' as const
|
||||
}
|
||||
]);
|
||||
|
||||
async function loadCommunities() {
|
||||
const r = await apiJSON<CommunitiesResponse>('/v1/communities?limit=200');
|
||||
communities = r.items;
|
||||
}
|
||||
|
||||
async function loadDoh() {
|
||||
dohLoading = true;
|
||||
const r = await apiJSON<DohProfilesResponse>('/v1/doh-profiles?limit=200');
|
||||
dohProfiles = r.items;
|
||||
}
|
||||
|
||||
async function load() {
|
||||
if (!initialLoading) loading = true;
|
||||
loadError = null;
|
||||
try {
|
||||
const r = await apiJSON<DohProfilesResponse>('/v1/doh-profiles?limit=200');
|
||||
dohProfiles = r.items;
|
||||
await Promise.all([loadCommunities(), loadDoh()]);
|
||||
lastUpdated = new Date();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : String(e));
|
||||
loadError = e instanceof Error ? e.message : String(e);
|
||||
notifyApiError(e);
|
||||
} finally {
|
||||
dohLoading = false;
|
||||
loading = false;
|
||||
initialLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
loadComm();
|
||||
loadDoh();
|
||||
});
|
||||
|
||||
// --- Community actions ---
|
||||
function commDisplay(c: BgpCommunity | null) {
|
||||
if (!c) return '';
|
||||
const t = c.title?.trim();
|
||||
return t || c.community;
|
||||
}
|
||||
|
||||
function openCommCreate() {
|
||||
commEdit = null;
|
||||
commForm = { community: '', title: '' };
|
||||
commDialog = true;
|
||||
}
|
||||
function openCommEdit(c: BgpCommunity) {
|
||||
commEdit = c;
|
||||
commForm = { community: c.community, title: c.title ?? '' };
|
||||
commDialog = true;
|
||||
}
|
||||
async function saveComm() {
|
||||
if (!commForm.community.trim()) {
|
||||
toast.error('Укажите community');
|
||||
return;
|
||||
}
|
||||
commSaving = true;
|
||||
async function refreshCommunities() {
|
||||
try {
|
||||
const body = { ...commForm, title: commForm.title?.trim() || undefined };
|
||||
if (commEdit) {
|
||||
await apiMutate(`/v1/communities/${commEdit.id}`, 'PATCH', body);
|
||||
toast.success('Запись сообщества обновлена');
|
||||
} else {
|
||||
await apiMutate('/v1/communities', 'POST', body);
|
||||
toast.success('Сообщество создано');
|
||||
}
|
||||
commDialog = false;
|
||||
await loadComm();
|
||||
loadError = null;
|
||||
await loadCommunities();
|
||||
lastUpdated = new Date();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
commSaving = false;
|
||||
}
|
||||
}
|
||||
async function deleteComm() {
|
||||
if (!commDeleteTarget) return;
|
||||
try {
|
||||
await apiMutate(`/v1/communities/${commDeleteTarget.id}`, 'DELETE', undefined, {
|
||||
idempotent: false
|
||||
});
|
||||
toast.success('Удалено');
|
||||
commDeleteTarget = null;
|
||||
await loadComm();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : String(e));
|
||||
loadError = e instanceof Error ? e.message : String(e);
|
||||
notifyApiError(e);
|
||||
}
|
||||
}
|
||||
|
||||
// --- DoH actions ---
|
||||
function openDohCreate() {
|
||||
dohEdit = null;
|
||||
dohForm = { url: '', timeout_ms: null, vault_secret_ref: null };
|
||||
dohDialog = true;
|
||||
}
|
||||
function openDohEdit(d: DohProfile) {
|
||||
dohEdit = d;
|
||||
dohForm = { url: d.url, timeout_ms: d.timeout_ms, vault_secret_ref: d.vault_secret_ref };
|
||||
dohDialog = true;
|
||||
}
|
||||
async function saveDoh() {
|
||||
if (!dohForm.url.trim()) {
|
||||
toast.error('Укажите URL');
|
||||
return;
|
||||
}
|
||||
dohSaving = true;
|
||||
async function refreshDoh() {
|
||||
try {
|
||||
if (dohEdit) {
|
||||
await apiMutate(`/v1/doh-profiles/${dohEdit.id}`, 'PATCH', dohForm);
|
||||
toast.success('DoH профиль обновлён');
|
||||
} else {
|
||||
await apiMutate('/v1/doh-profiles', 'POST', dohForm);
|
||||
toast.success('DoH профиль создан');
|
||||
}
|
||||
dohDialog = false;
|
||||
loadError = null;
|
||||
await loadDoh();
|
||||
lastUpdated = new Date();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
dohSaving = false;
|
||||
}
|
||||
}
|
||||
async function deleteDoh() {
|
||||
if (!dohDeleteTarget) return;
|
||||
try {
|
||||
await apiMutate(`/v1/doh-profiles/${dohDeleteTarget.id}`, 'DELETE', undefined, {
|
||||
idempotent: false
|
||||
});
|
||||
toast.success('Удалено');
|
||||
dohDeleteTarget = null;
|
||||
await loadDoh();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : String(e));
|
||||
loadError = e instanceof Error ? e.message : String(e);
|
||||
notifyApiError(e);
|
||||
}
|
||||
}
|
||||
|
||||
onMount(load);
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-6">
|
||||
<PageHeader
|
||||
title="Справочники"
|
||||
description="Сообщества BGP и DoH-профили для резолвинга доменов."
|
||||
description={lastUpdated
|
||||
? `Сообщества BGP и DoH-профили для резолвинга доменов. Обновлено: ${lastUpdated.toLocaleTimeString('ru-RU')}`
|
||||
: 'Сообщества BGP и DoH-профили для резолвинга доменов.'}
|
||||
icon={BookOpen}
|
||||
iconClass="bg-chart-2/15 text-chart-2"
|
||||
>
|
||||
{#snippet actions()}
|
||||
<Button variant="outline" size="sm" onclick={load} disabled={loading}>
|
||||
<RefreshCw class={loading ? 'animate-spin' : ''} />
|
||||
Обновить
|
||||
</Button>
|
||||
{/snippet}
|
||||
</PageHeader>
|
||||
|
||||
<Alert class="border-info/30 bg-info/5">
|
||||
<Info class="text-info" />
|
||||
<AlertTitle>О справочниках</AlertTitle>
|
||||
<AlertDescription>
|
||||
Сообщества BGP используются в AS- и CDN-модулях для тегирования префиксов. DoH-профили — в
|
||||
доменных модулях для DNS-over-HTTPS резолвинга. Настройка модулей — на странице
|
||||
<Button variant="link" class="h-auto p-0" href={resolve('/modules')}>Модули</Button>.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<KpiMetricsGrid
|
||||
cards={kpiCards}
|
||||
loading={initialLoading}
|
||||
skeletonCount={3}
|
||||
class="sm:grid-cols-3"
|
||||
/>
|
||||
|
||||
<Tabs value="communities">
|
||||
@@ -221,215 +183,24 @@
|
||||
<TabsTrigger value="doh">DoH профили</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<!-- Communities -->
|
||||
<TabsContent value="communities" class="mt-4">
|
||||
<Card>
|
||||
<CardHeader class="flex flex-row items-center justify-between pb-2">
|
||||
<div>
|
||||
<CardTitle class="text-base">Сообщества BGP</CardTitle>
|
||||
<CardDescription>Используются для тегирования префиксов</CardDescription>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<Button variant="outline" size="sm" onclick={loadComm} disabled={commLoading}>
|
||||
<RefreshCw class={commLoading ? 'animate-spin' : ''} />
|
||||
</Button>
|
||||
<Button size="sm" onclick={openCommCreate}><Plus />Добавить</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent class="p-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Код сообщества</TableHead>
|
||||
<TableHead>Название</TableHead>
|
||||
<TableHead>ID</TableHead>
|
||||
<TableHead class="w-20"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{#each communities as c (c.id)}
|
||||
<TableRow>
|
||||
<TableCell class="font-mono text-sm font-medium">{c.community}</TableCell>
|
||||
<TableCell>{c.title?.trim() || '—'}</TableCell>
|
||||
<TableCell class="font-mono text-xs text-muted-foreground">{c.id}</TableCell>
|
||||
<TableCell>
|
||||
<div class="flex gap-1">
|
||||
<Button variant="ghost" size="icon-sm" onclick={() => openCommEdit(c)}
|
||||
><Pencil class="size-3.5" /></Button
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
class="text-destructive"
|
||||
onclick={() => (commDeleteTarget = c)}><Trash2 class="size-3.5" /></Button
|
||||
>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{:else}
|
||||
<TableRow>
|
||||
<TableCell colspan={4} class="text-muted-foreground text-center py-8">
|
||||
{commLoading ? 'Загрузка…' : 'Нет записей. Создайте первую.'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{/each}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<DirectoriesCommunitiesCard
|
||||
items={communities}
|
||||
{loading}
|
||||
{initialLoading}
|
||||
error={loadError}
|
||||
onRefresh={refreshCommunities}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<!-- DoH Profiles -->
|
||||
<TabsContent value="doh" class="mt-4">
|
||||
<Card>
|
||||
<CardHeader class="flex flex-row items-center justify-between pb-2">
|
||||
<div>
|
||||
<CardTitle class="text-base">DoH профили</CardTitle>
|
||||
<CardDescription>DNS-over-HTTPS серверы для резолвинга доменных модулей</CardDescription
|
||||
>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<Button variant="outline" size="sm" onclick={loadDoh} disabled={dohLoading}>
|
||||
<RefreshCw class={dohLoading ? 'animate-spin' : ''} />
|
||||
</Button>
|
||||
<Button size="sm" onclick={openDohCreate}><Plus />Добавить</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent class="p-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>URL</TableHead>
|
||||
<TableHead>Таймаут (мс)</TableHead>
|
||||
<TableHead>ID</TableHead>
|
||||
<TableHead class="w-20"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{#each dohProfiles as d (d.id)}
|
||||
<TableRow>
|
||||
<TableCell class="font-mono text-sm">{d.url}</TableCell>
|
||||
<TableCell class="text-muted-foreground">{d.timeout_ms ?? '—'}</TableCell>
|
||||
<TableCell class="font-mono text-xs text-muted-foreground">{d.id}</TableCell>
|
||||
<TableCell>
|
||||
<div class="flex gap-1">
|
||||
<Button variant="ghost" size="icon-sm" onclick={() => openDohEdit(d)}
|
||||
><Pencil class="size-3.5" /></Button
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
class="text-destructive"
|
||||
onclick={() => (dohDeleteTarget = d)}><Trash2 class="size-3.5" /></Button
|
||||
>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{:else}
|
||||
<TableRow>
|
||||
<TableCell colspan={4} class="text-muted-foreground text-center py-8">
|
||||
{dohLoading ? 'Загрузка…' : 'Нет DoH профилей.'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{/each}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<DirectoriesDohProfilesCard
|
||||
items={dohProfiles}
|
||||
{loading}
|
||||
{initialLoading}
|
||||
error={loadError}
|
||||
onRefresh={refreshDoh}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
<!-- Community Dialog -->
|
||||
<Dialog bind:open={commDialog}>
|
||||
<DialogContent class="sm:max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{commEdit ? 'Редактировать сообщество BGP' : 'Новое сообщество BGP'}</DialogTitle
|
||||
>
|
||||
</DialogHeader>
|
||||
<div class="flex flex-col gap-4 py-2">
|
||||
<FormField label="Код сообщества" id="c-community" required>
|
||||
<AppInput id="c-community" bind:value={commForm.community} placeholder="65001:120" />
|
||||
</FormField>
|
||||
<FormField label="Название" id="c-title" description="Человекочитаемое имя для списков">
|
||||
<AppInput id="c-title" bind:value={commForm.title} placeholder="Название" />
|
||||
</FormField>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onclick={() => (commDialog = false)}>Отмена</Button>
|
||||
<Button onclick={saveComm} disabled={commSaving}
|
||||
>{commSaving ? 'Сохранение…' : commEdit ? 'Сохранить' : 'Создать'}</Button
|
||||
>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<AlertDialog
|
||||
open={!!commDeleteTarget}
|
||||
onOpenChange={(v) => {
|
||||
if (!v) commDeleteTarget = null;
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Удалить сообщество «{commDisplay(commDeleteTarget)}»?</AlertDialogTitle>
|
||||
<AlertDialogDescription
|
||||
>Это приведёт к удалению привязки во всех модулях.</AlertDialogDescription
|
||||
>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel onclick={() => (commDeleteTarget = null)}>Отмена</AlertDialogCancel>
|
||||
<AlertDialogAction onclick={deleteComm}>Удалить</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
<!-- DoH Dialog -->
|
||||
<Dialog bind:open={dohDialog}>
|
||||
<DialogContent class="sm:max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{dohEdit ? 'Редактировать' : 'Новый'} DoH профиль</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div class="flex flex-col gap-4 py-2">
|
||||
<FormField label="URL" id="doh-url" required>
|
||||
<AppInput
|
||||
id="doh-url"
|
||||
bind:value={dohForm.url}
|
||||
placeholder="https://dns.google/dns-query"
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Таймаут (мс)" id="doh-timeout">
|
||||
<AppInput
|
||||
id="doh-timeout"
|
||||
type="number"
|
||||
bind:value={dohForm.timeout_ms}
|
||||
placeholder="5000"
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onclick={() => (dohDialog = false)}>Отмена</Button>
|
||||
<Button onclick={saveDoh} disabled={dohSaving}
|
||||
>{dohSaving ? 'Сохранение…' : dohEdit ? 'Сохранить' : 'Создать'}</Button
|
||||
>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<AlertDialog
|
||||
open={!!dohDeleteTarget}
|
||||
onOpenChange={(v) => {
|
||||
if (!v) dohDeleteTarget = null;
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Удалить DoH профиль?</AlertDialogTitle>
|
||||
<AlertDialogDescription>{dohDeleteTarget?.url}</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel onclick={() => (dohDeleteTarget = null)}>Отмена</AlertDialogCancel>
|
||||
<AlertDialogAction onclick={deleteDoh}>Удалить</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
import { Checkbox } from '$lib/ui/core/checkbox/index.js';
|
||||
import AppDataTable from '$lib/ui/patterns/data-table/app-data-table.svelte';
|
||||
import CardSkeleton from '$lib/ui/patterns/feedback/card-skeleton.svelte';
|
||||
import KpiMetricsGrid from '$lib/ui/patterns/kpi/kpi-metrics-grid.svelte';
|
||||
import PageHeader from '$lib/ui/app/page-header/page-header.svelte';
|
||||
import { confirm } from '$lib/ui/patterns/confirm/confirm-state.svelte.js';
|
||||
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
|
||||
@@ -217,36 +218,12 @@
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-3">
|
||||
{#if initialLoading}
|
||||
{#each Array(3) as _, i (i)}
|
||||
<CardSkeleton />
|
||||
{/each}
|
||||
{:else}
|
||||
{#each kpiCards as card (card.id)}
|
||||
{@const Icon = card.icon}
|
||||
{@const a = card.accent}
|
||||
<Card class={cn('overflow-hidden border-l-4 shadow-sm', a.border, a.bg)}>
|
||||
<CardHeader class="pb-2">
|
||||
<CardDescription class="flex min-w-0 items-center gap-2">
|
||||
<span
|
||||
class={cn('flex size-9 shrink-0 items-center justify-center rounded-lg', a.iconBg)}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Icon class={cn('size-4', a.iconText)} />
|
||||
</span>
|
||||
<span class="truncate">{card.label}</span>
|
||||
</CardDescription>
|
||||
<CardTitle class="text-3xl font-bold tabular-nums">{card.value}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-2">
|
||||
<Badge variant="outline">{card.badge}</Badge>
|
||||
<p class="text-xs text-muted-foreground">{card.description}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
<KpiMetricsGrid
|
||||
cards={kpiCards}
|
||||
loading={initialLoading}
|
||||
skeletonCount={3}
|
||||
class="sm:grid-cols-3"
|
||||
/>
|
||||
|
||||
<Card>
|
||||
<CardHeader class="flex flex-wrap items-center justify-between gap-2 border-b py-3">
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user