Compare commits

...
17 Commits
Author SHA1 Message Date
DenozordecandCursor b51a9ae3b3 ci: add ci type to release configuration and update documentation
CI / changes (push) Successful in 7s
CI / commitlint (push) Has been skipped
CI / openapi (push) Successful in 31s
CI / web (push) Successful in 41s
CI / go (push) Successful in 47s
CI / bird2 (push) Successful in 19s
CI / release (push) Successful in 3m47s
- Introduced `ci` type in `.releaserc.json` for patch releases.
- Updated conventional commits documentation to reflect the new `ci` type and its implications for versioning.
- Clarified the role of `ci` in the context of patch releases in the releasing guide.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-20 15:24:21 +07:00
DenozordecandCursor 4d4cd2301f ci: update golangci-lint version and installation mode
CI / changes (push) Successful in 7s
CI / commitlint (push) Has been skipped
CI / openapi (push) Successful in 23s
CI / web (push) Successful in 30s
CI / go (push) Successful in 1m15s
CI / bird2 (push) Successful in 15s
CI / release (push) Successful in 18s
- Upgraded golangci-lint from v1.62 to v1.64.8 to ensure compatibility with Go 1.24.
- Changed installation mode to 'goinstall' for improved setup.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-20 15:18:53 +07:00
DenozordecandCursor d687881eaa chore: update golangci configuration and improve resource cleanup
CI / changes (push) Successful in 7s
CI / commitlint (push) Has been skipped
CI / openapi (push) Successful in 23s
CI / web (push) Successful in 31s
CI / go (push) Failing after 19s
CI / bird2 (push) Has been skipped
CI / release (push) Has been skipped
- Disabled all linters in .golangci.yml to streamline linting process.
- Updated resource cleanup in multiple files to use deferred functions for closing response bodies, ensuring proper error handling and resource management.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-20 15:13:02 +07:00
DenozordecandCursor 87e756f34f ci: expand changes detection to rewire all pipeline nodes
CI / changes (push) Successful in 7s
CI / commitlint (push) Has been skipped
CI / openapi (push) Successful in 28s
CI / web (push) Successful in 40s
CI / go (push) Failing after 21s
CI / bird2 (push) Has been skipped
CI / release (push) Has been skipped
Полный прогон при scripts/*, workflows, golangci, pre-commit; openapi в full_pipeline; миграции и deploy-пути; исправлен fallback пустого diff.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-20 15:08:28 +07:00
DenozordecandCursor 27864fad58 fix(ci): make check-migrations-pair.sh POSIX sh compatible
CI / changes (push) Successful in 8s
CI / commitlint (push) Has been skipped
CI / openapi (push) Has been skipped
CI / web (push) Has been skipped
CI / go (push) Has been skipped
CI / bird2 (push) Has been skipped
CI / release (push) Has been cancelled
Убрана bash process substitution; скрипт работает под sh в Gitea runner (DEP-03).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-20 14:57:18 +07:00
DenozordecandCursor dc5e777d07 style(web): format settings-known schema for prettier
CI / changes (push) Successful in 7s
CI / openapi (push) Has been skipped
CI / commitlint (push) Has been skipped
CI / go (push) Has been cancelled
CI / bird2 (push) Has been cancelled
CI / release (push) Has been cancelled
CI / web (push) Has been cancelled
Исправление падения CI job web: prettier --check на settings-known.schema.ts.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-20 14:56:20 +07:00
DenozordecandCursor 23910c3393 docs(go): add godoc for pipeline and store exports
CI / changes (push) Successful in 7s
CI / commitlint (push) Has been skipped
CI / openapi (push) Successful in 23s
CI / web (push) Failing after 34s
CI / go (push) Failing after 41s
CI / bird2 (push) Has been skipped
CI / release (push) Has been skipped
Уточнение godoc ExtractCIDRs; тесты repository (interface + optional integration).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-20 14:53:50 +07:00
DenozordecandCursor 0e90bbee4e docs: clarify dev-only flags and openapi implementation gaps
DOC-03: X-Tenant-Id не реализован в handlers; лимиты CDN preview; SEC-02 пометки в compose; sync endpoints в access.md.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-20 14:53:20 +07:00
DenozordecandCursor 55bc87cbc4 ci: add golangci, migration checks, and pre-commit
DEP-03: check-migrations-pair.sh; migrations/* в path filter; golangci-lint в CI; pre-commit; обновление engineering.mdc (TEST-04, enforcement).
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-20 14:53:01 +07:00
DenozordecandCursor e68f034966 refactor(web): extract shared kpi metrics grid pattern
WEB-18: KpiMetricsGrid в ui/patterns; рефакторинг overview, network, modules, directories, operations, schedule, monitoring.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-20 14:52:44 +07:00
DenozordecandCursor e06ee880b2 fix(web): align settings with shadcn notify and superforms
WEB-07: notify вместо svelte-sonner; ui/core imports; WEB-05: zod + superforms + FormField для полей настроек.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-20 14:50:58 +07:00
DenozordecandCursor 33fe8fdd18 refactor(httpapi): readiness ping via store backend
ARCH-09: Ping на store.Backend; readiness без прямого pgxpool.Ping в handler.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-20 14:49:26 +07:00
DenozordecandCursor 6fa693156d fix(httpapi): stable problem details for client errors
Стабильные detail для 4xx (store, CDN preview, CSV); логирование на сервере. Расширен lint-httpapi для ERR-01 в 4xx.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-20 14:48:57 +07:00
DenozordecandCursor 5858d0889e fix(httpapi): paginate module nested list endpoints
Пагинация cursor/limit для CDN, AS, domain и IP range list; ответы с next_cursor и has_more по OpenAPI. Добавлен writePaginatedListJSON и тест.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-20 14:48:32 +07:00
Denozordec bfe20c1fe0 feat(web): enhance overview page with improved state management and UI components
CI / changes (push) Successful in 9s
CI / openapi (push) Has been skipped
CI / commitlint (push) Has been skipped
CI / web (push) Successful in 42s
CI / go (push) Has been skipped
CI / bird2 (push) Has been skipped
CI / release (push) Successful in 3m57s
- Refactored state management for modules, revisions, peers, speakers, and jobs, enhancing loading and error handling.
- Updated imports to utilize core UI components for better maintainability and consistency.
- Introduced derived states for KPI cards, providing real-time insights into system metrics.
- Improved loading function to fetch data efficiently and handle errors gracefully.
- Enhanced UI layout with new icons and dynamic descriptions based on last updated timestamps.
2026-05-20 14:27:21 +07:00
Denozordec c66cc9317d feat(web): refactor directories and network pages with improved state management and UI components
CI / changes (push) Successful in 6s
CI / commitlint (push) Has been skipped
CI / openapi (push) Has been skipped
CI / web (push) Successful in 31s
CI / go (push) Has been skipped
CI / bird2 (push) Has been skipped
CI / release (push) Successful in 3m42s
- Updated imports to utilize core UI components for better maintainability.
- Refactored state management for communities and DoH profiles, enhancing loading and error handling.
- Introduced derived states for KPI cards, providing better insights into community and DoH profile counts.
- Streamlined loading functions to improve data fetching efficiency and user experience.
- Enhanced UI layout with new icons and improved component structure for clarity.
2026-05-20 14:13:32 +07:00
Denozordec 9ea4a68ccf docs: update AGENTS, rules, and skills documentation for Conventional Commits
CI / changes (push) Successful in 7s
CI / commitlint (push) Has been skipped
CI / openapi (push) Has been skipped
CI / web (push) Has been skipped
CI / go (push) Has been skipped
CI / bird2 (push) Has been skipped
CI / release (push) Successful in 17s
- Enhanced AGENTS.md to clarify the use of `/commit-message` and the exclusion of the Source Control button for generating commit messages.
- Updated conventional-commits.mdc to include new triggers and guidelines for commit message types, emphasizing the importance of semver impact.
- Revised commit-message SKILL.md to specify the mandatory execution of scripts and the correct usage of types and scopes in commit messages.
- Improved README documentation to reflect the updated guidelines and workflows for generating commit messages.
2026-05-20 12:52:31 +07:00
60 changed files with 2478 additions and 1482 deletions
+37
View File
@@ -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` (none)
Заголовок — EN, императив, ≤72 символов. Тело — RU.
+71 -6
View File
@@ -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` | реструктуризация **без** новой возможности и **без** исправления бага | — |
| `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`
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
Единый паттерн таблиц; поведение списка модулей без изменений.
```
```
# Хорошо 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.
## Примеры
+6 -4
View File
@@ -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.
+24 -5
View File
@@ -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` (none) |
**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
View File
@@ -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
View File
@@ -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
+1
View File
@@ -2,6 +2,7 @@ run:
timeout: 5m
linters:
disable-all: true
enable:
- gofmt
- govet
+19
View File
@@ -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
+1
View File
@@ -10,6 +10,7 @@
{ "type": "feat", "release": "minor" },
{ "type": "fix", "release": "patch" },
{ "type": "perf", "release": "patch" },
{ "type": "ci", "release": "patch" },
{ "breaking": true, "release": "major" }
]
}
+3 -1
View File
@@ -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`.
+1 -1
View File
@@ -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
+1
View File
@@ -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
+10 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
+6 -2
View File
@@ -7,12 +7,16 @@ 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` | 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` | без релиза |
Отдельного суффикса `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)
+4 -4
View File
@@ -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
+4 -4
View File
@@ -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
+6 -6
View File
@@ -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.
+1 -1
View File
@@ -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
+14
View File
@@ -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 -5
View File
@@ -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) {
+7 -4
View File
@@ -84,12 +84,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"
+15 -26
View File
@@ -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") {
+3 -3
View File
@@ -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)
+87
View File
@@ -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))
}
}
+4 -4
View File
@@ -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
}
+14 -14
View File
@@ -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)
+2 -2
View File
@@ -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)))
+1 -1
View File
@@ -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)
+1 -1
View File
@@ -126,7 +126,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)
@@ -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)
}
}
+1 -1
View File
@@ -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) {
+10 -10
View File
@@ -306,7 +306,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 +378,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 +725,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})
}
}
+70
View File
@@ -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)
}
}
+6 -1
View File
@@ -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 в память.
+1 -1
View File
@@ -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
}
+7 -1
View File
@@ -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 {
@@ -90,6 +93,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.
+14 -7
View File
@@ -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()
+3 -3
View File
@@ -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},
+74
View File
@@ -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)"
+8
View File
@@ -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
+2 -3
View File
@@ -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
View File
@@ -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>
@@ -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,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,79 @@
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;
}
const optionalIPv4 = (label: string) =>
z.string().refine((v) => v.trim() === '' || isValidIPv4(v.trim()), {
message: `Введите корректный IPv4 адрес (${label})`
});
const optionalIPv6 = (label: string) =>
z.string().refine((v) => v.trim() === '' || isValidIPv6(v.trim()), {
message: `Введите корректный IPv6 адрес (${label})`
});
export const settingsKnownSchema = 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'),
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 SettingsKnownForm = z.infer<typeof settingsKnownSchema>;
export const emptySettingsKnownForm = (): SettingsKnownForm => ({
bird_router_id: '',
bird_local_ipv4: '',
bird_local_ipv6: '',
bird_local_asn: '',
bird_bgp_source_ipv4: '',
bird_bgp_source_ipv6: '',
revision_retention_minutes: ''
});
@@ -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>
+201 -124
View File
@@ -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,205 @@
}
] 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'
}
]);
async function load() {
if (!initialLoading) refreshing = true;
loadError = null;
try {
const [h, m, p, s, r, j] = await Promise.all([
apiFetch('/v1/health'),
apiJSON<ModulesResponse>('/v1/modules?limit=200'),
apiJSON<PeersResponse>('/v1/peers?limit=200'),
apiJSON<SpeakersResponse>('/v1/speakers?limit=200'),
apiJSON<RevisionsResponse>('/v1/revisions?limit=200'),
apiJSON<JobsResponse>('/v1/jobs?limit=20')
]);
healthy = h.ok;
moduleItems = m.items ?? [];
modulesHasMore = m.has_more;
peerItems = p.items ?? [];
peersHasMore = p.has_more;
speakerItems = s.items ?? [];
speakersHasMore = s.has_more;
revisionItems = r.items ?? [];
revisionsHasMore = r.has_more;
jobItems = j.items ?? [];
recentJobs = jobItems.slice(0, 10);
recentRevisions = revisionItems.slice(0, 10);
runningJobs = jobItems.filter((i) => i.status === 'running' || i.status === 'queued').length;
lastUpdated = new Date();
} catch (e) {
healthy = false;
loadError = e instanceof Error ? e.message : String(e);
notifyApiError(e);
} 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}
<Alert class="border-success/30 bg-success/5">
<CheckCircle class="text-success" />
<AlertTitle>API работает</AlertTitle>
<AlertDescription>Сервер отвечает на запросы health-check.</AlertDescription>
</Alert>
{:else}
<Alert variant="destructive" class="border-destructive/30 bg-destructive/5">
<XCircle class="text-destructive" />
<AlertTitle>API недоступен</AlertTitle>
<AlertDescription>
Не удалось получить ответ от сервера. Проверьте подключение и статус API.
</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>
+144 -373
View File
@@ -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>
+7 -30
View File
@@ -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">
+8 -60
View File
@@ -46,6 +46,7 @@
TableRow
} from '$lib/ui/core/table/index.js';
import CardSkeleton from '$lib/ui/patterns/feedback/card-skeleton.svelte';
import KpiMetricsGrid from '$lib/ui/patterns/kpi/kpi-metrics-grid.svelte';
import ScrollPreBlock from '$lib/components/app/scroll-pre-block.svelte';
import PageHeader from '$lib/ui/app/page-header/page-header.svelte';
import { cn } from '$lib/utils.js';
@@ -223,6 +224,7 @@
badge: version ? 'Загружена' : '—',
badgeVariant: version ? ('outline' as const) : ('secondary' as const),
badgeClass: undefined,
valueClass: 'font-mono text-xl',
error: versionError,
href: undefined
}
@@ -342,66 +344,12 @@
{/if}
{/if}
<div class="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
{#if initialLoading}
{#each Array(4) 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 transition-colors',
a.border,
a.bg,
card.href ? 'hover:border-primary/35' : ''
)}
>
<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">{card.label}</span>
</CardDescription>
{#if card.href}
<Button variant="ghost" size="icon-sm" href={resolve(card.href)}>
<ArrowRight class="size-3.5" aria-hidden="true" />
</Button>
{/if}
</div>
<CardTitle
class={cn(
'font-bold tabular-nums',
card.id === 'version' ? 'font-mono text-xl' : 'text-3xl'
)}
>
{card.value}
</CardTitle>
</CardHeader>
<CardContent class="space-y-2">
<div class="flex items-center justify-between gap-2">
<Badge variant={card.badgeVariant} class={card.badgeClass}>{card.badge}</Badge>
</div>
{#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>
<KpiMetricsGrid
cards={kpiCards}
loading={initialLoading}
skeletonCount={4}
class="sm:grid-cols-2 xl:grid-cols-4"
/>
<div class="grid gap-4 lg:grid-cols-2">
{#if initialLoading}
+161 -455
View File
@@ -1,249 +1,190 @@
<script lang="ts">
import { onMount } from 'svelte';
import { apiJSON, apiMutate } from '$lib/api/client.js';
import type {
PeerRow,
BgpPeerCreate,
PeersResponse,
SpeakerRow,
BgpSpeakerCreate,
SpeakersResponse
} from '$lib/api/types.js';
import { Badge } from '$lib/components/ui/badge/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import { resolve } from '$app/paths';
import { apiJSON } from '$lib/api/client.js';
import type { PeerRow, PeersResponse, SpeakerRow, SpeakersResponse } 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/components/ui/card/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import { Label } from '$lib/components/ui/label/index.js';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger
} from '$lib/components/ui/select/index.js';
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 { Switch } from '$lib/components/ui/switch/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';
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
import Play from '@lucide/svelte/icons/play';
import NetworkIcon from '@lucide/svelte/icons/network';
} 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';
// --- Peers ---
import { notifyApiError } from '$lib/ui/app/toast.js';
import NetworkPeersCard from '$lib/components/network/NetworkPeersCard.svelte';
import NetworkSpeakersCard from '$lib/components/network/NetworkSpeakersCard.svelte';
import { cn } from '$lib/utils.js';
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
import NetworkIcon from '@lucide/svelte/icons/network';
import Info from '@lucide/svelte/icons/info';
import Share2 from '@lucide/svelte/icons/share-2';
import CheckCircle2 from '@lucide/svelte/icons/check-circle-2';
import Server from '@lucide/svelte/icons/server';
let peers = $state<PeerRow[]>([]);
let peersLoading = $state(false);
let peerDialog = $state(false);
let peerEdit = $state<PeerRow | null>(null);
let peerForm = $state<BgpPeerCreate & { bgp_speaker_id?: string | null }>({
name: '',
neighbor: '',
remote_asn: 0,
bgp_speaker_id: null,
enabled: true
});
let peerSaving = $state(false);
let peerToggleId = $state<string | null>(null);
let peerDeleteTarget = $state<PeerRow | null>(null);
// --- Speakers ---
let speakers = $state<SpeakerRow[]>([]);
let peersLoading = $state(false);
let speakersLoading = $state(false);
let speakerDialog = $state(false);
let speakerEdit = $state<SpeakerRow | null>(null);
let speakerForm = $state<BgpSpeakerCreate>({ endpoint: '', role: 'operator' });
let speakerSaving = $state(false);
let applyingId = $state<string | null>(null);
const speakerById = $derived.by(() => new Map(speakers.map((s) => [s.id, s])));
let initialLoading = $state(true);
let loadError = $state<string | null>(null);
let lastUpdated = $state<Date | null>(null);
function speakerLabelById(id: string | null | undefined) {
if (!id) return '—';
return speakerById.get(id)?.endpoint ?? id;
}
const establishedCount = $derived(peers.filter((p) => p.session_state === 'Established').length);
async function loadAll() {
const statAccents = [
{
border: 'border-l-chart-3',
bg: 'bg-chart-3/5',
iconBg: 'bg-chart-3/15',
iconText: 'text-chart-3'
},
{
border: 'border-l-chart-2',
bg: 'bg-chart-2/5',
iconBg: 'bg-chart-2/15',
iconText: 'text-chart-2'
},
{
border: 'border-l-chart-4',
bg: 'bg-chart-4/5',
iconBg: 'bg-chart-4/15',
iconText: 'text-chart-4'
}
] as const;
const kpiCards = $derived.by(() => [
{
id: 'peers',
label: 'BGP-пиры',
value: initialLoading ? '—' : String(peers.length),
description: 'настроенные BGP-соседи',
icon: Share2,
accent: statAccents[0],
badge: 'peers'
},
{
id: 'established',
label: 'Активные сессии',
value: initialLoading ? '—' : String(establishedCount),
description:
establishedCount > 0 ? 'Established из текущей выборки' : 'нет установленных сессий',
icon: CheckCircle2,
accent: statAccents[1],
badge: establishedCount > 0 ? 'Established' : 'нет сессий',
badgeClass: establishedCount > 0 ? 'border-success/30 bg-success/15 text-success' : undefined
},
{
id: 'speakers',
label: 'Спикеры',
value: initialLoading ? '—' : String(speakers.length),
description: 'BIRD-агенты на нодах',
icon: Server,
accent: statAccents[2],
badge: 'agents'
}
]);
const refreshing = $derived(peersLoading || speakersLoading);
async function loadPeers() {
peersLoading = true;
speakersLoading = true;
try {
const [pr, sr] = await Promise.all([
apiJSON<PeersResponse>('/v1/peers?limit=200'),
apiJSON<SpeakersResponse>('/v1/speakers?limit=200')
]);
const pr = await apiJSON<PeersResponse>('/v1/peers?limit=200');
peers = pr.items;
speakers = sr.items;
} catch (e) {
toast.error(e instanceof Error ? e.message : String(e));
loadError = e instanceof Error ? e.message : String(e);
notifyApiError(e);
throw e;
} finally {
peersLoading = false;
}
}
async function loadSpeakers() {
speakersLoading = true;
try {
const sr = await apiJSON<SpeakersResponse>('/v1/speakers?limit=200');
speakers = sr.items;
} catch (e) {
loadError = e instanceof Error ? e.message : String(e);
notifyApiError(e);
throw e;
} finally {
speakersLoading = false;
}
}
onMount(loadAll);
// --- Peer actions ---
function openPeerCreate() {
peerEdit = null;
peerForm = { name: '', neighbor: '', remote_asn: 0, bgp_speaker_id: null, enabled: true };
peerDialog = true;
}
function openPeerEdit(p: PeerRow) {
peerEdit = p;
peerForm = {
name: p.name ?? '',
neighbor: p.neighbor,
remote_asn: p.remote_asn ?? 0,
bgp_speaker_id: p.bgp_speaker_id,
enabled: p.enabled !== false
};
peerDialog = true;
}
async function setPeerEnabled(p: PeerRow, enabled: boolean) {
peerToggleId = p.id;
async function load() {
loadError = null;
try {
await apiMutate(`/v1/peers/${p.id}`, 'PATCH', { enabled });
toast.success(enabled ? 'Пир включён' : 'Пир отключён');
await loadAll();
} catch (e) {
toast.error(e instanceof Error ? e.message : String(e));
await Promise.all([loadPeers(), loadSpeakers()]);
lastUpdated = new Date();
} catch {
// errors handled in loaders
} finally {
peerToggleId = null;
}
}
async function savePeer() {
if (!peerForm.neighbor.trim()) {
toast.error('Укажите адрес соседа');
return;
}
if (!peerForm.remote_asn || peerForm.remote_asn <= 0) {
toast.error('Remote ASN должен быть больше 0');
return;
}
peerSaving = true;
try {
if (peerEdit) {
await apiMutate(`/v1/peers/${peerEdit.id}`, 'PATCH', peerForm);
toast.success('Пир обновлён');
} else {
await apiMutate('/v1/peers', 'POST', {
...peerForm,
enabled: peerForm.enabled !== false
});
toast.success('Пир создан');
}
peerDialog = false;
await loadAll();
} catch (e) {
toast.error(e instanceof Error ? e.message : String(e));
} finally {
peerSaving = false;
}
}
async function deletePeer() {
if (!peerDeleteTarget) return;
try {
await apiMutate(`/v1/peers/${peerDeleteTarget.id}`, 'DELETE', undefined, {
idempotent: false
});
toast.success('Пир удалён');
peerDeleteTarget = null;
await loadAll();
} catch (e) {
toast.error(e instanceof Error ? e.message : String(e));
initialLoading = false;
}
}
// --- Speaker actions ---
function openSpeakerCreate() {
speakerEdit = null;
speakerForm = { endpoint: '', role: 'operator' };
speakerDialog = true;
}
function openSpeakerEdit(s: SpeakerRow) {
speakerEdit = s;
speakerForm = { endpoint: s.endpoint, role: s.role };
speakerDialog = true;
}
async function saveSpeaker() {
if (!speakerForm.endpoint) {
toast.error('Укажите endpoint');
return;
}
speakerSaving = true;
async function refreshPeers() {
try {
if (speakerEdit) {
await apiMutate(`/v1/speakers/${speakerEdit.id}`, 'PATCH', speakerForm);
toast.success('Спикер обновлён');
} else {
await apiMutate('/v1/speakers', 'POST', speakerForm);
toast.success('Спикер создан');
}
speakerDialog = false;
await loadAll();
} catch (e) {
toast.error(e instanceof Error ? e.message : String(e));
} finally {
speakerSaving = false;
}
}
async function applySpeaker(id: string) {
applyingId = id;
try {
await apiMutate(`/v1/speakers/${id}/apply`, 'POST', {});
toast.success('Apply запущен');
} catch (e) {
toast.error(e instanceof Error ? e.message : String(e));
} finally {
applyingId = null;
await loadPeers();
lastUpdated = new Date();
} catch {
// notifyApiError in loadPeers
}
}
function sessionBadge(state: string) {
if (state === 'Established') return 'default';
if (state === 'Active' || state === 'Connect') return 'secondary';
return 'outline';
async function refreshSpeakers() {
try {
await loadSpeakers();
lastUpdated = new Date();
} catch {
// notifyApiError in loadSpeakers
}
}
onMount(load);
</script>
<div class="flex flex-col gap-6">
<PageHeader
title="Сеть"
description="BGP-пиры и спикеры (BIRD-агенты)."
description={lastUpdated
? `BGP-пиры и спикеры (BIRD-агенты). Обновлено: ${lastUpdated.toLocaleTimeString('ru-RU')}`
: 'BGP-пиры и спикеры (BIRD-агенты).'}
icon={NetworkIcon}
iconClass="bg-chart-3/15 text-chart-3"
>
{#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>О сетевой конфигурации</AlertTitle>
<AlertDescription>
Пиры привязаны к спикерам (BIRD-агентам). Apply запускает применение ревизии на ноде. Полный
список ревизий и задач — на странице
<Button variant="link" class="h-auto p-0" href={resolve('/operations')}>Операции</Button>.
</AlertDescription>
</Alert>
<KpiMetricsGrid
cards={kpiCards}
loading={initialLoading}
skeletonCount={3}
class="sm:grid-cols-3"
/>
<Tabs value="peers">
@@ -252,260 +193,25 @@
<TabsTrigger value="speakers">Спикеры</TabsTrigger>
</TabsList>
<!-- Peers -->
<TabsContent value="peers" class="mt-4">
<Card>
<CardHeader class="flex flex-row items-center justify-between pb-2">
<div>
<CardTitle class="text-base">BGP-пиры</CardTitle>
<CardDescription>Настройка BGP-соседей</CardDescription>
</div>
<div class="flex gap-2">
<Button variant="outline" size="sm" onclick={loadAll} disabled={peersLoading}>
<RefreshCw class={peersLoading ? 'animate-spin' : ''} />
</Button>
<Button size="sm" onclick={openPeerCreate}><Plus />Добавить</Button>
</div>
</CardHeader>
<CardContent class="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>Имя</TableHead>
<TableHead>Адрес</TableHead>
<TableHead>Remote ASN</TableHead>
<TableHead class="w-[4.5rem] text-center">Вкл.</TableHead>
<TableHead>Состояние сессии</TableHead>
<TableHead>Спикер</TableHead>
<TableHead class="w-20"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{#each peers as p (p.id)}
<TableRow>
<TableCell>{p.name?.trim() || '—'}</TableCell>
<TableCell class="font-mono">{p.neighbor}</TableCell>
<TableCell class="font-mono">{p.remote_asn ?? '—'}</TableCell>
<TableCell class="text-center">
<Switch
size="sm"
checked={p.enabled !== false}
disabled={peersLoading || peerToggleId === p.id}
onCheckedChange={(v) => setPeerEnabled(p, v)}
/>
</TableCell>
<TableCell>
<Badge variant={sessionBadge(p.session_state)}>{p.session_state || '—'}</Badge>
</TableCell>
<TableCell class="text-xs text-muted-foreground"
>{speakerLabelById(p.bgp_speaker_id)}</TableCell
>
<TableCell>
<div class="flex gap-1">
<Button variant="ghost" size="icon-sm" onclick={() => openPeerEdit(p)}
><Pencil class="size-3.5" /></Button
>
<Button
variant="ghost"
size="icon-sm"
class="text-destructive"
onclick={() => (peerDeleteTarget = p)}><Trash2 class="size-3.5" /></Button
>
</div>
</TableCell>
</TableRow>
{:else}
<TableRow>
<TableCell colspan={7} class="text-muted-foreground text-center py-8">
{peersLoading ? 'Загрузка…' : 'Нет пиров.'}
</TableCell>
</TableRow>
{/each}
</TableBody>
</Table>
</CardContent>
</Card>
<NetworkPeersCard
items={peers}
{speakers}
loading={peersLoading}
{initialLoading}
error={loadError}
onRefresh={refreshPeers}
/>
</TabsContent>
<!-- Speakers -->
<TabsContent value="speakers" class="mt-4">
<Card>
<CardHeader class="flex flex-row items-center justify-between pb-2">
<div>
<CardTitle class="text-base">Спикеры</CardTitle>
<CardDescription>BIRD-агенты, применяющие конфигурацию</CardDescription>
</div>
<div class="flex gap-2">
<Button variant="outline" size="sm" onclick={loadAll} disabled={speakersLoading}>
<RefreshCw class={speakersLoading ? 'animate-spin' : ''} />
</Button>
<Button size="sm" onclick={openSpeakerCreate}><Plus />Добавить</Button>
</div>
</CardHeader>
<CardContent class="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>Endpoint</TableHead>
<TableHead>Роль</TableHead>
<TableHead>Последняя ревизия</TableHead>
<TableHead class="w-32"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{#each speakers as s (s.id)}
<TableRow>
<TableCell class="font-mono text-sm">{s.endpoint}</TableCell>
<TableCell><Badge variant="outline">{s.role}</Badge></TableCell>
<TableCell class="font-mono text-xs text-muted-foreground"
>{s.last_applied_revision_id
? s.last_applied_revision_id.slice(0, 8) + '…'
: '—'}</TableCell
>
<TableCell>
<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={() => openSpeakerEdit(s)}
><Pencil class="size-3.5" /></Button
>
</div>
</TableCell>
</TableRow>
{:else}
<TableRow>
<TableCell colspan={4} class="text-muted-foreground text-center py-8">
{speakersLoading ? 'Загрузка…' : 'Нет спикеров.'}
</TableCell>
</TableRow>
{/each}
</TableBody>
</Table>
</CardContent>
</Card>
<NetworkSpeakersCard
items={speakers}
loading={speakersLoading}
{initialLoading}
error={loadError}
onRefresh={refreshSpeakers}
/>
</TabsContent>
</Tabs>
</div>
<!-- Peer Dialog -->
<Dialog bind:open={peerDialog}>
<DialogContent class="sm:max-w-sm">
<DialogHeader>
<DialogTitle>{peerEdit ? 'Редактировать пира' : 'Новый пир'}</DialogTitle>
<DialogDescription>BGP-сосед для установки сессии</DialogDescription>
</DialogHeader>
<div class="space-y-4 py-2">
<div class="space-y-1.5">
<Label for="p-name">Имя пира (опционально)</Label>
<Input id="p-name" placeholder="Core-RTR-1" bind:value={peerForm.name} />
</div>
<div class="space-y-1.5">
<Label for="p-neighbor">Адрес соседа</Label>
<Input id="p-neighbor" placeholder="192.0.2.1" bind:value={peerForm.neighbor} />
</div>
<div class="space-y-1.5">
<Label for="p-asn">Remote ASN</Label>
<Input id="p-asn" type="number" placeholder="65000" bind:value={peerForm.remote_asn} />
</div>
<div class="space-y-1.5">
<Label for="p-speaker">Спикер (опционально)</Label>
<Select
type="single"
value={peerForm.bgp_speaker_id ?? ''}
onValueChange={(v) => {
peerForm = { ...peerForm, bgp_speaker_id: v || null };
}}
>
<SelectTrigger id="p-speaker" class="w-full">
{peerForm.bgp_speaker_id ? speakerLabelById(peerForm.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>
</div>
<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={peerForm.enabled !== false}
onCheckedChange={(v) => {
peerForm = { ...peerForm, enabled: v };
}}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onclick={() => (peerDialog = false)}>Отмена</Button>
<Button onclick={savePeer} disabled={peerSaving}
>{peerSaving ? 'Сохранение…' : peerEdit ? 'Сохранить' : 'Создать'}</Button
>
</DialogFooter>
</DialogContent>
</Dialog>
<AlertDialog
open={!!peerDeleteTarget}
onOpenChange={(v) => {
if (!v) peerDeleteTarget = null;
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Удалить пира?</AlertDialogTitle>
<AlertDialogDescription>{peerDeleteTarget?.neighbor}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onclick={() => (peerDeleteTarget = null)}>Отмена</AlertDialogCancel>
<AlertDialogAction onclick={deletePeer}>Удалить</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<!-- Speaker Dialog -->
<Dialog bind:open={speakerDialog}>
<DialogContent class="sm:max-w-sm">
<DialogHeader>
<DialogTitle>{speakerEdit ? 'Редактировать спикера' : 'Новый спикер'}</DialogTitle>
</DialogHeader>
<div class="space-y-4 py-2">
<div class="space-y-1.5">
<Label for="s-endpoint">Endpoint</Label>
<Input
id="s-endpoint"
placeholder="http://bird-agent:8081"
bind:value={speakerForm.endpoint}
/>
</div>
<div class="space-y-1.5">
<Label for="s-role">Роль</Label>
<Input id="s-role" placeholder="operator" bind:value={speakerForm.role} />
</div>
</div>
<DialogFooter>
<Button variant="outline" onclick={() => (speakerDialog = false)}>Отмена</Button>
<Button onclick={saveSpeaker} disabled={speakerSaving}
>{speakerSaving ? 'Сохранение…' : speakerEdit ? 'Сохранить' : 'Создать'}</Button
>
</DialogFooter>
</DialogContent>
</Dialog>
+7 -47
View File
@@ -60,6 +60,7 @@
} from '$lib/components/operations/types.js';
import ScrollPreBlock from '$lib/components/app/scroll-pre-block.svelte';
import CardSkeleton from '$lib/ui/patterns/feedback/card-skeleton.svelte';
import KpiMetricsGrid from '$lib/ui/patterns/kpi/kpi-metrics-grid.svelte';
import { confirm } from '$lib/ui/patterns/confirm/confirm-state.svelte.js';
import PageHeader from '$lib/ui/app/page-header/page-header.svelte';
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
@@ -891,53 +892,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 transition-colors',
a.border,
a.bg,
card.href ? 'hover:border-primary/35' : ''
)}
>
<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">{card.label}</span>
</CardDescription>
{#if card.href}
<Button variant="ghost" size="icon-sm" href={resolve(card.href)}>
<ArrowRight class="size-3.5" aria-hidden="true" />
</Button>
{/if}
</div>
<CardTitle class="text-3xl font-bold tabular-nums">{card.value}</CardTitle>
</CardHeader>
<CardContent class="space-y-2">
<Badge variant="outline" class={card.badgeClass}>{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"
/>
<OperationsQuickActions
{applying}
+7 -47
View File
@@ -23,6 +23,7 @@
import { Tabs, TabsContent, TabsList, TabsTrigger } from '$lib/ui/core/tabs/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 { notify, notifyApiError } from '$lib/ui/app/toast.js';
import { cn } from '$lib/utils.js';
@@ -234,53 +235,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 transition-colors',
a.border,
a.bg,
card.href ? 'hover:border-primary/35' : ''
)}
>
<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">{card.label}</span>
</CardDescription>
{#if card.href}
<Button variant="ghost" size="icon-sm" href={resolve(card.href)}>
<ArrowRight class="size-3.5" aria-hidden="true" />
</Button>
{/if}
</div>
<CardTitle class="text-3xl font-bold tabular-nums">{card.value}</CardTitle>
</CardHeader>
<CardContent class="space-y-2">
<Badge variant="outline" class={card.badgeClass}>{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>
+89 -181
View File
@@ -1,14 +1,22 @@
<script lang="ts">
import { browser } from '$app/environment';
import { onMount } from 'svelte';
import { defaults, superForm } from 'sveltekit-superforms';
import { zod4 } from 'sveltekit-superforms/adapters';
import { TOKEN_STORAGE_KEY } from '$lib/api/client.js';
import { apiJSON, apiMutate } from '$lib/api/client.js';
import type { AppSettings } from '$lib/api/types.js';
import { Button } from '$lib/components/ui/button/index.js';
import { Card, CardContent, CardHeader, CardDescription } from '$lib/components/ui/card/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import { Label } from '$lib/components/ui/label/index.js';
import { toast } from 'svelte-sonner';
import {
emptySettingsKnownForm,
settingsKnownSchema,
type SettingsKnownForm
} from '$lib/settings/settings-known.schema.js';
import { Button } from '$lib/ui/core/button/index.js';
import { Card, CardContent, CardHeader, CardDescription } from '$lib/ui/core/card/index.js';
import { Input } from '$lib/ui/core/input/index.js';
import { Label } from '$lib/ui/core/label/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 Plus from '@lucide/svelte/icons/plus';
import SettingsIcon from '@lucide/svelte/icons/settings';
@@ -19,26 +27,10 @@
let apiSettings = $state<AppSettings | null>(null);
let loadingSettings = $state(false);
let savingSettings = $state(false);
let knownFields = $state({
bird_router_id: '',
bird_local_ipv4: '',
bird_local_ipv6: '',
bird_local_asn: '',
bird_bgp_source_ipv4: '',
bird_bgp_source_ipv6: '',
revision_retention_minutes: ''
});
let additionalSettings = $state<Array<{ id: number; key: string; value: string }>>([]);
let additionalIdCounter = $state(1);
type KnownFieldKey =
| 'bird_router_id'
| 'bird_local_ipv4'
| 'bird_local_ipv6'
| 'bird_local_asn'
| 'bird_bgp_source_ipv4'
| 'bird_bgp_source_ipv6'
| 'revision_retention_minutes';
type KnownFieldKey = keyof SettingsKnownForm;
type SettingsSection = 'token' | 'bird' | 'revisions' | 'additional';
@@ -52,6 +44,15 @@
'revision_retention_minutes'
];
const { form, errors, reset, validateForm } = superForm(
defaults(emptySettingsKnownForm(), zod4(settingsKnownSchema)),
{
validators: zod4(settingsKnownSchema),
SPA: true,
dataType: 'json'
}
);
let activeSection = $state<SettingsSection>('token');
const sectionItems: Array<{ id: SettingsSection; label: string; description: string }> = [
@@ -61,98 +62,7 @@
{ id: 'additional', label: 'Дополнительно', description: 'Ключ-значение' }
];
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;
}
function isPositiveInt(value: string): boolean {
return /^[1-9]\d*$/.test(value);
}
let knownFieldErrors = $derived.by(() => {
const errors: Record<KnownFieldKey, string> = {
bird_router_id: '',
bird_local_ipv4: '',
bird_local_ipv6: '',
bird_local_asn: '',
bird_bgp_source_ipv4: '',
bird_bgp_source_ipv6: '',
revision_retention_minutes: ''
};
const routerId = String(knownFields.bird_router_id ?? '').trim();
if (routerId && !isValidIPv4(routerId)) errors.bird_router_id = 'Введите корректный IPv4 адрес';
const localV4 = String(knownFields.bird_local_ipv4 ?? '').trim();
if (localV4 && !isValidIPv4(localV4)) errors.bird_local_ipv4 = 'Введите корректный IPv4 адрес';
const localV6 = String(knownFields.bird_local_ipv6 ?? '').trim();
if (localV6 && !isValidIPv6(localV6)) errors.bird_local_ipv6 = 'Введите корректный IPv6 адрес';
const asn = String(knownFields.bird_local_asn ?? '').trim();
if (asn && !isPositiveInt(asn)) errors.bird_local_asn = 'ASN должен быть целым числом больше 0';
const bgpV4 = String(knownFields.bird_bgp_source_ipv4 ?? '').trim();
if (bgpV4 && !isValidIPv4(bgpV4)) errors.bird_bgp_source_ipv4 = 'Введите корректный IPv4 адрес';
const bgpV6 = String(knownFields.bird_bgp_source_ipv6 ?? '').trim();
if (bgpV6 && !isValidIPv6(bgpV6)) errors.bird_bgp_source_ipv6 = 'Введите корректный IPv6 адрес';
const revisionRetentionMinutes = String(knownFields.revision_retention_minutes ?? '').trim();
if (revisionRetentionMinutes) {
const ttl = Number(revisionRetentionMinutes);
if (
!/^\d+$/.test(revisionRetentionMinutes) ||
!Number.isInteger(ttl) ||
ttl < 15 ||
ttl > 43200
) {
errors.revision_retention_minutes =
'TTL ревизий должен быть целым числом от 15 до 43200 минут';
}
}
return errors;
});
let hasValidationErrors = $derived(knownFieldKeys.some((key) => Boolean(knownFieldErrors[key])));
let hasValidationErrors = $derived(knownFieldKeys.some((key) => Boolean($errors[key]?.length)));
function addAdditionalSetting() {
additionalSettings.push({ id: additionalIdCounter++, key: '', value: '' });
@@ -191,7 +101,7 @@
}
}
knownFields = parsedKnown;
reset({ data: parsedKnown });
additionalSettings = parsedAdditional;
}
@@ -200,7 +110,7 @@
const t = token.trim();
if (t) localStorage.setItem(TOKEN_STORAGE_KEY, t);
else localStorage.removeItem(TOKEN_STORAGE_KEY);
toast.success('Токен сохранён');
notify.success('Токен сохранён');
}
async function loadApiSettings() {
@@ -210,7 +120,7 @@
apiSettings = s;
resetFormFromApi(s);
} catch (e) {
toast.error(e instanceof Error ? e.message : String(e));
notifyApiError(e);
} finally {
loadingSettings = false;
}
@@ -220,8 +130,8 @@
if (loadingSettings || savingSettings || hasValidationErrors) return false;
const hasKnownValues = knownFieldKeys.some((key) => {
const value = String(knownFields[key] ?? '').trim();
return value !== '' && !knownFieldErrors[key];
const value = String($form[key] ?? '').trim();
return value !== '' && !$errors[key]?.length;
});
const hasAdditionalValues = additionalSettings.some((entry) => entry.key.trim() !== '');
@@ -229,15 +139,20 @@
});
async function saveApiSettings() {
const validation = await validateForm({ update: true });
if (!validation.valid) {
notify.error('Исправьте ошибки в полях настроек');
return;
}
if (!canSaveSettings) {
toast.error('Нечего сохранять или есть ошибки в полях');
notify.error('Нечего сохранять или есть ошибки в полях');
return;
}
const payload: Record<string, string | number> = {};
for (const key of knownFieldKeys) {
const value = String(knownFields[key] ?? '').trim();
if (!value || knownFieldErrors[key]) continue;
const value = String($form[key] ?? '').trim();
if (!value || $errors[key]?.length) continue;
if (key === 'bird_local_asn' || key === 'revision_retention_minutes')
payload[key] = Number(value);
else payload[key] = value;
@@ -251,10 +166,10 @@
savingSettings = true;
try {
await apiMutate('/v1/settings', 'PATCH', payload);
toast.success('Настройки сохранены');
notify.success('Настройки сохранены');
await loadApiSettings();
} catch (e) {
toast.error(e instanceof Error ? e.message : String(e));
notifyApiError(e);
} finally {
savingSettings = false;
}
@@ -335,106 +250,99 @@
<div class="space-y-3">
<h2 class="text-base font-semibold">Параметры BIRD</h2>
<div class="space-y-2">
<Label for="bird-router-id">Router ID (bird_router_id)</Label>
<FormField
id="bird-router-id"
label="Router ID (bird_router_id)"
error={$errors.bird_router_id?.[0]}
>
<Input
id="bird-router-id"
bind:value={knownFields.bird_router_id}
bind:value={$form.bird_router_id}
placeholder="203.0.113.1"
/>
{#if knownFieldErrors.bird_router_id}
<p class="text-sm text-red-600">{knownFieldErrors.bird_router_id}</p>
{/if}
</div>
</FormField>
<div class="space-y-2">
<Label for="bird-local-ipv4">Локальный IPv4 (bird_local_ipv4)</Label>
<FormField
id="bird-local-ipv4"
label="Локальный IPv4 (bird_local_ipv4)"
error={$errors.bird_local_ipv4?.[0]}
>
<Input
id="bird-local-ipv4"
bind:value={knownFields.bird_local_ipv4}
bind:value={$form.bird_local_ipv4}
placeholder="198.51.100.10"
/>
{#if knownFieldErrors.bird_local_ipv4}
<p class="text-sm text-red-600">{knownFieldErrors.bird_local_ipv4}</p>
{/if}
</div>
</FormField>
<div class="space-y-2">
<Label for="bird-local-ipv6">Локальный IPv6 (bird_local_ipv6)</Label>
<FormField
id="bird-local-ipv6"
label="Локальный IPv6 (bird_local_ipv6)"
error={$errors.bird_local_ipv6?.[0]}
>
<Input
id="bird-local-ipv6"
bind:value={knownFields.bird_local_ipv6}
bind:value={$form.bird_local_ipv6}
placeholder="2001:db8::10"
/>
{#if knownFieldErrors.bird_local_ipv6}
<p class="text-sm text-red-600">{knownFieldErrors.bird_local_ipv6}</p>
{/if}
</div>
</FormField>
<div class="space-y-2">
<Label for="bird-local-asn">Локальный ASN (bird_local_asn)</Label>
<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={knownFields.bird_local_asn}
bind:value={$form.bird_local_asn}
placeholder="65001"
/>
{#if knownFieldErrors.bird_local_asn}
<p class="text-sm text-red-600">{knownFieldErrors.bird_local_asn}</p>
{/if}
</div>
</FormField>
<div class="space-y-2">
<Label for="bird-bgp-source-ipv4">BGP source IPv4 (bird_bgp_source_ipv4)</Label>
<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={knownFields.bird_bgp_source_ipv4}
bind:value={$form.bird_bgp_source_ipv4}
placeholder="198.51.100.11"
/>
{#if knownFieldErrors.bird_bgp_source_ipv4}
<p class="text-sm text-red-600">{knownFieldErrors.bird_bgp_source_ipv4}</p>
{/if}
</div>
</FormField>
<div class="space-y-2">
<Label for="bird-bgp-source-ipv6">BGP source IPv6 (bird_bgp_source_ipv6)</Label>
<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={knownFields.bird_bgp_source_ipv6}
bind:value={$form.bird_bgp_source_ipv6}
placeholder="2001:db8::11"
/>
{#if knownFieldErrors.bird_bgp_source_ipv6}
<p class="text-sm text-red-600">{knownFieldErrors.bird_bgp_source_ipv6}</p>
{/if}
</div>
</FormField>
</div>
{:else if activeSection === 'revisions'}
<div class="space-y-3">
<h2 class="text-base font-semibold">Управление ревизиями</h2>
<div class="space-y-2">
<Label for="revision-retention-minutes">
Время жизни ревизий, мин (revision_retention_minutes)
</Label>
<FormField
id="revision-retention-minutes"
label="Время жизни ревизий, мин (revision_retention_minutes)"
error={$errors.revision_retention_minutes?.[0]}
description="Старые ревизии удаляются автоматически. Последняя раскатанная ревизия не удаляется."
>
<Input
id="revision-retention-minutes"
type="number"
min="15"
max="43200"
bind:value={knownFields.revision_retention_minutes}
bind:value={$form.revision_retention_minutes}
placeholder="43200"
/>
{#if knownFieldErrors.revision_retention_minutes}
<p class="text-sm text-red-600">
{knownFieldErrors.revision_retention_minutes}
</p>
{/if}
<p class="text-sm text-muted-foreground">
Старые ревизии удаляются автоматически. Последняя раскатанная ревизия не
удаляется.
</p>
</div>
</FormField>
</div>
{:else if activeSection === 'additional'}
<div class="space-y-3">