Compare commits
37
Commits
35ed79bdd6
...
v1.5.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bfe20c1fe0 | ||
|
|
c66cc9317d | ||
|
|
9ea4a68ccf | ||
|
|
8204105fd6 | ||
|
|
0c11ecfa48 | ||
|
|
ab8660ac42 | ||
|
|
ce747673fd | ||
|
|
e558570967 | ||
|
|
b6b081b04f | ||
|
|
13b4c20148 | ||
|
|
700d415422 | ||
|
|
6ca34b9008 | ||
|
|
bb334b10f5 | ||
|
|
4c23232c4e | ||
|
|
c263fd5c7e | ||
|
|
6bee5adc34 | ||
|
|
64516c316d | ||
|
|
ea65451160 | ||
|
|
250cdea35d | ||
|
|
1dd713514c | ||
|
|
aff27e8f7b | ||
|
|
07b97bddc1 | ||
|
|
34ecc5c235 | ||
|
|
a536a2d5eb | ||
|
|
765a8052da | ||
|
|
5e59a1c334 | ||
|
|
92f6de8695 | ||
|
|
87309cfcec | ||
|
|
1f9930d467 | ||
|
|
9808aca44f | ||
|
|
3a3056ba76 | ||
|
|
57dcf306f3 | ||
|
|
b992301c99 | ||
|
|
77a2cfd426 | ||
|
|
ac635e91eb | ||
|
|
2d78c8156f | ||
|
|
00a2a44630 |
No files matched your search
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"extends": ["@commitlint/config-conventional"]
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
---
|
||||||
|
description: >-
|
||||||
|
Conventional Commits EvoBGP. Триггеры commit/коммит/закоммить/staged/commit message —
|
||||||
|
ОБЯЗАТЕЛЬНО сначала Shell scripts/commit/staged-context.ps1, скилл commit-message.
|
||||||
|
alwaysApply: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# Conventional Commits (EvoBGP)
|
||||||
|
|
||||||
|
Полное описание релизного пайплайна: [docs/releasing.md](../../docs/releasing.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)
|
||||||
|
|
||||||
|
1. Прочитать скилл [`.cursor/skills/commit-message/SKILL.md`](../skills/commit-message/SKILL.md).
|
||||||
|
2. **Первым действием** выполнить Shell (из корня репо):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
powershell -NoProfile -File scripts/commit/staged-context.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Сообщение коммита строить **только** по JSON из stdout скрипта (`groups`, `diff_excerpt`, `stat`).
|
||||||
|
4. **Запрещено** генерировать commit message без успешного (exit 0) запуска скрипта; не заменять скрипт одним `git diff --cached`.
|
||||||
|
|
||||||
|
## Формат (строго)
|
||||||
|
|
||||||
|
```
|
||||||
|
<type>(<scope>): <short summary in English>
|
||||||
|
|
||||||
|
<тело на русском: что изменено и зачем>
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Заголовок** — только английский; императив, без точки в конце; ≤72 символов.
|
||||||
|
- **Тело** — только русский; полные предложения; пустая строка после заголовка.
|
||||||
|
- Запрещены vague-сообщения: `fix bug`, `update code`, `wip`, `misc`.
|
||||||
|
|
||||||
|
## Типы (semantic-release)
|
||||||
|
|
||||||
|
| type | Когда | Версия |
|
||||||
|
|------|--------|--------|
|
||||||
|
| `feat` | **новая** пользовательская возможность (раньше нельзя было) | minor |
|
||||||
|
| `fix` | восстановление **ожидаемого** поведения; баг, регрессия, падение UI | patch |
|
||||||
|
| `perf` | ускорение без смены API | patch |
|
||||||
|
| `refactor` | реструктуризация **без** новой возможности и **без** исправления бага | — |
|
||||||
|
| `docs` | только документация | — |
|
||||||
|
| `test` | тесты | — |
|
||||||
|
| `ci` | CI/CD (`.gitea/`, workflows) | — |
|
||||||
|
| `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 **на русском**.
|
||||||
|
|
||||||
|
## Scope (EvoBGP)
|
||||||
|
|
||||||
|
Выбирать по доминирующему пути из staged diff:
|
||||||
|
|
||||||
|
| Префикс | scope |
|
||||||
|
|---------|-------|
|
||||||
|
| `internal/httpapi/` | `httpapi` |
|
||||||
|
| `internal/store/`, `internal/repository/`, `internal/db/` | `store` |
|
||||||
|
| `internal/jobs/` | `jobs` |
|
||||||
|
| `internal/pipeline/` | `pipeline` |
|
||||||
|
| `internal/birdfmt/` | `birdfmt` |
|
||||||
|
| `internal/birddeploy/` | `birddeploy` |
|
||||||
|
| `internal/bundle/`, `internal/signing/` | `bundle` |
|
||||||
|
| `cmd/` | `cmd` |
|
||||||
|
| `web/` | `web` |
|
||||||
|
| `docs/openapi.yaml`, `redocly.yaml` | `openapi` |
|
||||||
|
| `docs/` (остальное) | `docs` |
|
||||||
|
| `migrations/` | `db` |
|
||||||
|
| `.gitea/` | `ci` |
|
||||||
|
| `deploy/` | `deploy` |
|
||||||
|
| `.cursor/` | `chore` |
|
||||||
|
| прочее в корне | `chore` |
|
||||||
|
|
||||||
|
`type` определять по **содержимому diff**, не только по пути.
|
||||||
|
|
||||||
|
## Multi-change
|
||||||
|
|
||||||
|
- Несвязанные области → **отдельные коммиты** (auto-split по скиллу).
|
||||||
|
- Одна логическая фича через несколько scope (OpenAPI + httpapi + web) → **один** коммит, scope по главной области.
|
||||||
|
|
||||||
|
## Обязательный вывод агенту
|
||||||
|
|
||||||
|
Для каждого коммита:
|
||||||
|
|
||||||
|
**1. Готовое сообщение** (копировать в `git commit -m` / HEREDOC):
|
||||||
|
|
||||||
|
```
|
||||||
|
<type>(<scope>): <summary>
|
||||||
|
|
||||||
|
<тело RU>
|
||||||
|
```
|
||||||
|
|
||||||
|
**2. Пояснение (RU):** semver impact (`minor`|`patch`|`none`|`major`); почему выбран type; риск/impact; был ли split.
|
||||||
|
|
||||||
|
## Примеры
|
||||||
|
|
||||||
|
```
|
||||||
|
feat(httpapi): add version endpoint and wire web footer
|
||||||
|
|
||||||
|
Добавлен GET /v1/version. В Web UI версия в footer берётся из API вместо захардкоженного значения.
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
fix(auth): correct token validation edge case
|
||||||
|
|
||||||
|
Исправлена ложная 401 при истёкшем refresh-токене с валидной сессией.
|
||||||
|
```
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
---
|
||||||
|
description: Инженерные правила EvoBGP — Go, API, migrations, общие стандарты
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
|
||||||
|
# Engineering Guidelines — EvoBGP
|
||||||
|
|
||||||
|
Специализированные правила: **Web UI** → `.cursor/rules/web-shadcn.mdc`; **BIRD2 / BGP / IP** → `.cursor/rules/networking-bird.mdc`.
|
||||||
|
|
||||||
|
Карта репозитория: `AGENTS.md`, `docs/architecture.md`. HTTP-контракт: `docs/openapi.yaml`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
**ARCH-01** | MUST | Новая persistence-логика — метод `store.Backend` + реализации в `repository` и `store.Memory`; SQL не в `httpapi`.
|
||||||
|
*Rationale:* единая абстракция данных.
|
||||||
|
*Проверка:* grep SQL в `internal/httpapi` — отсутствие; review.
|
||||||
|
|
||||||
|
**ARCH-02** | MUST | HTTP-маршруты только в `internal/httpapi`; регистрация через `http.ServeMux` с паттернами `METHOD /v1/...`.
|
||||||
|
*Rationale:* один слой REST.
|
||||||
|
*Проверка:* маршруты только в `routes.go`, `routes_crud.go`.
|
||||||
|
|
||||||
|
**ARCH-03** | MUST | Долгие операции (refresh, apply, rollback) — `jobs.Registry`; ответ `202` + `job_id` где задано OpenAPI.
|
||||||
|
*Rationale:* не блокировать HTTP worker.
|
||||||
|
*Проверка:* OpenAPI + handlers.
|
||||||
|
|
||||||
|
**ARCH-04** | MUST | Кросс-процессные воркеры — HTTP к API или общая БД; не shared memory (кроме `evobgp-all`).
|
||||||
|
*Rationale:* `jobs.Registry` in-process only.
|
||||||
|
*Проверка:* `docs/architecture.md`; review.
|
||||||
|
|
||||||
|
**ARCH-05** | MUST | `birdfmt` не импортирует `httpapi` / `store`.
|
||||||
|
*Rationale:* направление зависимостей вниз.
|
||||||
|
*Проверка:* `go list -deps` / review imports.
|
||||||
|
|
||||||
|
**ARCH-06** | MUST | Точки входа `cmd/*` — тонкий `main`: config, wiring, signal/shutdown; без бизнес-логики.
|
||||||
|
*Rationale:* тестируемость `internal/`.
|
||||||
|
*Проверка:* review `main.go`.
|
||||||
|
|
||||||
|
**ARCH-07** | MUST | Инициализация store + jobs для API и воркеров — `httpapi.BootstrapWorkers`.
|
||||||
|
*Rationale:* единый wiring.
|
||||||
|
*Проверка:* `bootstrap.go`.
|
||||||
|
|
||||||
|
**ARCH-08** | MUST | Ingest+render префиксов — `internal/pipeline`; генерация BIRD-текста — `internal/birdfmt`.
|
||||||
|
*Rationale:* разделение data plane / control plane.
|
||||||
|
*Проверка:* review пакетов.
|
||||||
|
|
||||||
|
**ARCH-09** | NEVER | Прямой доступ handler'ов к `pgxpool` для CRUD; только `store.Backend`.
|
||||||
|
*Rationale:* абстракция бэкенда.
|
||||||
|
*Проверка:* review `httpapi`.
|
||||||
|
|
||||||
|
**ARCH-10** | MUST | Подпись бандлов — `internal/bundle` + `internal/signing`; не дублировать crypto в handlers.
|
||||||
|
*Rationale:* единая криптография артефактов.
|
||||||
|
*Проверка:* review.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Code Style
|
||||||
|
|
||||||
|
**STYLE-01** | MUST | Go-код после `gofmt`; перед PR — `go vet ./...`.
|
||||||
|
*Rationale:* единый стиль.
|
||||||
|
*Проверка:* CI job `go`.
|
||||||
|
|
||||||
|
**STYLE-02** | MUST | Экспортируемые типы/функции публичных пакетов — godoc-комментарий.
|
||||||
|
*Rationale:* навигация по API пакетов.
|
||||||
|
*Проверка:* review.
|
||||||
|
|
||||||
|
**STYLE-03** | NEVER | `panic` в `internal/*` вне `init` и тестов.
|
||||||
|
*Rationale:* предсказуемые ошибки.
|
||||||
|
*Проверка:* grep `panic(`.
|
||||||
|
|
||||||
|
**STYLE-04** | MUST | Ошибки пакетов с префиксом (`birdfmt:`, `db:`, `httpapi:`) и `%w` при оборачивании.
|
||||||
|
*Rationale:* трассировка.
|
||||||
|
*Проверка:* review.
|
||||||
|
|
||||||
|
**STYLE-05** | MUST | HTTP-ошибки — `writeProblem` / `writeJSON` (`application/problem+json` для 4xx/5xx).
|
||||||
|
*Rationale:* RFC 9457, OpenAPI.
|
||||||
|
*Проверка:* `problem.go`.
|
||||||
|
|
||||||
|
**STYLE-06** | MUST | JSON полей HTTP DTO согласованы с `docs/openapi.yaml`.
|
||||||
|
*Rationale:* контракт API.
|
||||||
|
*Проверка:* OpenAPI diff + review.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependency Management
|
||||||
|
|
||||||
|
**DEP-01** | MUST | Go-зависимости — через `go get` / `go.mod`; версия Go как в `go.mod` и CI (1.24).
|
||||||
|
*Проверка:* `go.mod`, `.gitea/workflows/ci.yaml`.
|
||||||
|
|
||||||
|
**DEP-02** | NEVER | Vendor-копирование без явного решения в репозитории.
|
||||||
|
*Проверка:* review.
|
||||||
|
|
||||||
|
**DEP-03** | MUST | Миграции схемы — пары `.up.sql`/`.down.sql` для **postgres** и **sqlite**, синхронная нумерация.
|
||||||
|
*Проверка:* `migrations/postgres/`, `migrations/sqlite/`.
|
||||||
|
|
||||||
|
**DEP-04** | MUST | Web UI-библиотеки — только экосистема shadcn-svelte/bits-ui (см. `web-shadcn.mdc`).
|
||||||
|
*Проверка:* `web/package.json` review.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
**ERR-01** | MUST | HTTP 5xx — без сырого `err.Error()` клиенту; detail через `writeProblem`.
|
||||||
|
*Проверка:* review handlers.
|
||||||
|
|
||||||
|
**ERR-02** | MUST | Публичные функции I/O — `(T, error)`; `errors.Is`/`errors.As` для sentinel.
|
||||||
|
*Проверка:* review.
|
||||||
|
|
||||||
|
**ERR-03** | MUST | `context.Context` — первый аргумент для I/O; таймауты на внешние HTTP (CDN, DoH, RIPEstat).
|
||||||
|
*Проверка:* `pipeline`, `bootstrap.go`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
**TEST-01** | MUST | Перед PR: `go test ./... -race -count=1`.
|
||||||
|
*Проверка:* CI job `go`.
|
||||||
|
|
||||||
|
**TEST-02** | MUST | Табличные тесты — эталон для `birdfmt`, `pipeline`, `bundle`.
|
||||||
|
*Проверка:* `*_test.go`.
|
||||||
|
|
||||||
|
**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-05** | MUST | Изменения OpenAPI — `npx @redocly/cli lint docs/openapi.yaml`.
|
||||||
|
*Проверка:* CI job `openapi`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
**DOC-01** | MUST | Пользовательская документация в `docs/` — на русском.
|
||||||
|
*Проверка:* review.
|
||||||
|
|
||||||
|
**DOC-02** | MUST | Изменение HTTP API: сначала `docs/openapi.yaml`, затем handlers/store, затем `docs/api.md` при необходимости.
|
||||||
|
*Проверка:* PR diff order.
|
||||||
|
|
||||||
|
**DOC-03** | MUST | Расхождение spec/код — явно в `docs/access.md` или description операции OpenAPI.
|
||||||
|
*Проверка:* review (напр. `X-Tenant-Id`).
|
||||||
|
|
||||||
|
**DOC-04** | NEVER | Дублировать длинные фрагменты OpenAPI в комментариях; ссылка на путь/operationId.
|
||||||
|
*Проверка:* review.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Naming Conventions
|
||||||
|
|
||||||
|
**NAME-01** | MUST | Бинарники: `cmd/evobgp-<role>/main.go`.
|
||||||
|
*Проверка:* `cmd/`.
|
||||||
|
|
||||||
|
**NAME-02** | MUST | Env-переменные: префикс `EVOBGP_`.
|
||||||
|
*Проверка:* `internal/config`, `docs/access.md`.
|
||||||
|
|
||||||
|
**NAME-03** | MUST | Job kinds — константы в `internal/jobs/worker.go` (`module_refresh`, …).
|
||||||
|
*Проверка:* grep `Kind`.
|
||||||
|
|
||||||
|
**NAME-04** | MUST | ID сущностей — UUID-строки; tenant только из API-ключа (auth context), не из недокументированных заголовков.
|
||||||
|
*Проверка:* `auth.go`, `docs/access.md`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Performance
|
||||||
|
|
||||||
|
**PERF-01** | MUST | Списки API — пагинация `cursor` + `limit`; не unbounded выборки в handlers.
|
||||||
|
*Проверка:* OpenAPI + store methods.
|
||||||
|
|
||||||
|
**PERF-02** | MUST | CDN HTTP — переиспользуемый `http.Client` с таймаутом (45s в bootstrap).
|
||||||
|
*Проверка:* `bootstrap.go`.
|
||||||
|
|
||||||
|
**PERF-03** | MUST | Новые метрики — `internal/observability`, экспорт `/metrics`.
|
||||||
|
*Проверка:* review.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
**SEC-01** | NEVER | Секреты, API-ключи, токены в Git.
|
||||||
|
*Проверка:* review; `docs/access.md`.
|
||||||
|
|
||||||
|
**SEC-02** | NEVER | `EVOBGP_DEV_INSECURE=1` в production.
|
||||||
|
*Проверка:* ops review.
|
||||||
|
|
||||||
|
**SEC-03** | MUST | Роль `node` — только node API; CRUD — `403` для `node`.
|
||||||
|
*Проверка:* `auth.go`, handlers.
|
||||||
|
|
||||||
|
**SEC-04** | MUST | apply/rollback/settings — operator (или выше по `roleLevel`).
|
||||||
|
*Проверка:* review handlers.
|
||||||
|
|
||||||
|
**SEC-05** | MUST | Бандл на ноде — `verify-bundle` перед `apply-bundle`.
|
||||||
|
*Проверка:* `docs/access.md`.
|
||||||
|
|
||||||
|
**SEC-06** | MUST | CORS — явный whitelist `EVOBGP_CORS_ORIGINS`.
|
||||||
|
*Проверка:* `cors.go`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Documentation Sync Rules
|
||||||
|
|
||||||
|
| Область | Источник |
|
||||||
|
|---------|----------|
|
||||||
|
| Go / net/http | https://go.dev/doc/ |
|
||||||
|
| pgx v5 | https://pkg.go.dev/github.com/jackc/pgx/v5 |
|
||||||
|
| OpenAPI / problem+json | `docs/openapi.yaml`, RFC 9457 |
|
||||||
|
| Svelte / Kit | https://svelte.dev/docs , https://kit.svelte.dev/docs |
|
||||||
|
| shadcn-svelte | https://shadcn-svelte.com/docs |
|
||||||
|
| BIRD 2 | https://bird.network.cz/?get_doc |
|
||||||
|
| Prometheus Go | https://pkg.go.dev/github.com/prometheus/client_golang |
|
||||||
|
|
||||||
|
**DOC-SYNC-01** | MUST | Новый API библиотеки — сверка версии в `go.mod`/`package.json` с официальной документацией.
|
||||||
|
**DOC-SYNC-02** | NEVER | Устаревшие примеры (Svelte 4 `export let`, deprecated pgx).
|
||||||
|
**DOC-SYNC-03** | MUST | Конфликт docs: **OpenAPI (HTTP)** → **код** → обзорные `docs/`; `.cursor/plans/` не контракт.
|
||||||
|
**DOC-SYNC-04** | MUST | Сомнения по Svelte — Svelte MCP / `npm run check`.
|
||||||
|
**DOC-SYNC-05** | MUST | BIRD — официальная документация BIRD2 + `networking-bird.mdc` + `go test ./internal/birdfmt/...`.
|
||||||
|
|
||||||
|
Приоритет при сомнениях — **официальные источники**, не блоги и не «память модели».
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Enforcement Strategy
|
||||||
|
|
||||||
|
**CI (Gitea):** OpenAPI lint; `go vet`, `go test -race`, build `cmd/*`; `bird -p` на scenarios; Docker bake на push.
|
||||||
|
|
||||||
|
**Локально перед PR:**
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
go vet ./...
|
||||||
|
go test ./... -race -count=1
|
||||||
|
npx @redocly/cli lint docs/openapi.yaml
|
||||||
|
# web: cd web; npm run check; npm run lint
|
||||||
|
# birdfmt: go test ./internal/birdfmt/... -count=1
|
||||||
|
```
|
||||||
|
|
||||||
|
**Рекомендуется (частично внедрено):** CI job `web` (Gitea); `scripts/lint-httpapi.sh` в job `go`; `.golangci.yml` (локально); pre-commit gofmt/prettier.
|
||||||
|
|
||||||
|
**Только code review:** слои SQL; роли; idempotency; OpenAPI bodies; secrets в compose.
|
||||||
|
|
||||||
|
**Известные ограничения:** `X-Tenant-Id` в OpenAPI не реализован в handlers; `jobs.Registry` не shared между процессами API и отдельными воркерами без HTTP.
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
---
|
||||||
|
description: EvoBGP — BIRD2, BGP policy, IP/CIDR, маршрутизация, валидация конфигов
|
||||||
|
globs:
|
||||||
|
- internal/birdfmt/**
|
||||||
|
- internal/birddeploy/**
|
||||||
|
- internal/pipeline/**
|
||||||
|
- deploy/bird/**
|
||||||
|
- "**/testdata/scenarios/**"
|
||||||
|
alwaysApply: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# Networking & Routing Rules
|
||||||
|
|
||||||
|
Общие правила: `.cursor/rules/engineering.mdc`. Пакет BIRD: `internal/birdfmt/doc.go`.
|
||||||
|
|
||||||
|
## Single source of truth
|
||||||
|
|
||||||
|
| Домен | SSOT |
|
||||||
|
|-------|------|
|
||||||
|
| Префиксы для анонса | Модули tenant → materialized snapshot → ревизия (`store`, `pipeline`, `CreateRenderRevision`) |
|
||||||
|
| Текст BIRD-фрагментов | `internal/birdfmt` — только генератор `evobgp_*.conf` |
|
||||||
|
| Каркас `bird.conf` | Operator skeleton + `include` (`StandardIncludeFragments`) |
|
||||||
|
| Параметры BIRD tenant | Global settings: `bird_router_id`, `bird_local_asn`, … (`docs/manual.md`) |
|
||||||
|
| BGP peers | `BGPPeer` + `ParsePeerNeighbor` |
|
||||||
|
|
||||||
|
**BIRD2 docs:** https://bird.network.cz/?get_doc
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## A. BIRD2 Configuration Rules
|
||||||
|
|
||||||
|
**BIRD-01** | MUST | Имена фрагментов — константы `Fragment*` (`evobgp_prefixes_v4.conf`, …); include `bird.d/<name>`.
|
||||||
|
*Rationale:* совместимость deploy.
|
||||||
|
*Проверка:* `layout.go`; `go test ./internal/birdfmt/...`
|
||||||
|
|
||||||
|
**BIRD-02** | MUST | Порядок include: filters v4/v6 → BGP template → prefixes v4/v6 → peers (`StandardIncludeFragments`).
|
||||||
|
*Rationale:* фильтры до ссылок.
|
||||||
|
*Проверка:* scenario `standard_layout`; `bird -p`
|
||||||
|
|
||||||
|
**BIRD-03** | MUST | `bird.conf` (router id, device, direct, includes) отдельно от `bird.d/evobgp_*` (политика, префиксы).
|
||||||
|
*Rationale:* атомарная подмена фрагментов.
|
||||||
|
*Проверка:* review layout.
|
||||||
|
|
||||||
|
**BIRD-04** | MUST | Новая логика фильтрации — функция в `birdfmt` + unit-тест; NEVER дублировать filter-блок в два `.conf` вручную.
|
||||||
|
*Rationale:* DRY v4/v6.
|
||||||
|
*Проверка:* `filter_test.go`; review.
|
||||||
|
|
||||||
|
**BIRD-05** | MUST | Filter: префикс `evobgp_`, суффикс `_v4`/`_v6`; templates `bgp_template` / `bgp_template_v6`.
|
||||||
|
*Проверка:* `bgp.go`, `layout.go`.
|
||||||
|
|
||||||
|
**BIRD-06** | MUST | `router id` — валидный IPv4 dotted-quad (`RenderMainBirdConf`).
|
||||||
|
*Проверка:* `birdfmt` error; `bird -p`
|
||||||
|
|
||||||
|
**BIRD-07** | MUST | Communities — `RouteCommunityAttrs`; синтаксис `((a,b))` в `add()`, не `add(65000,1)`.
|
||||||
|
*Проверка:* `community_bird_test.go`
|
||||||
|
|
||||||
|
**BIRD-08** | SHOULD | Комментарии `#` в preamble; в фрагментах — revision/module id где уместно.
|
||||||
|
*Проверка:* review.
|
||||||
|
|
||||||
|
**BIRD-09** | NEVER | Ручное редактирование `evobgp_*.conf` на ноде без ревизии/бандла в control plane.
|
||||||
|
*Rationale:* drift.
|
||||||
|
*Проверка:* `evobgp-deploy` logs.
|
||||||
|
|
||||||
|
**BIRD-10** | MUST | Новый scenario: `testdata/scenarios/<name>/bird.conf` (+ `bird.d/`) проходит `bird -p`.
|
||||||
|
*Проверка:* CI `bird2`
|
||||||
|
|
||||||
|
**BIRD-11** | MUST | Только BIRD **2.x**, не 1.6.
|
||||||
|
*Проверка:* `bird --version`
|
||||||
|
|
||||||
|
**BIRD-12** | MUST | Apply: staging → `bird -p` → atomic swap → `birdc configure`; NEVER configure без parse check.
|
||||||
|
*Проверка:* `birdctl.go`; `birddeploy` review.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## B. Routing Policy Rules
|
||||||
|
|
||||||
|
**RTE-01** | MUST | BGP template: `import none` на каждый AFI, если задача явно не добавляет import policy.
|
||||||
|
*Rationale:* deny-by-default.
|
||||||
|
*Проверка:* `bgp.go`
|
||||||
|
|
||||||
|
**RTE-02** | MUST | Export filter завершается `reject;`.
|
||||||
|
*Проверка:* `filter.go`; `filter_export` scenario.
|
||||||
|
|
||||||
|
**RTE-03** | MUST | Анонс только префиксов materialized revision; NEVER static `route` вне pipeline/birdfmt без operator approval.
|
||||||
|
*Проверка:* store snapshot; review.
|
||||||
|
|
||||||
|
**RTE-04** | NEVER | Анонс `0.0.0.0/0`, `::/0`, RFC1918/ULA, loopback, link-local, multicast, reserved без documented exception.
|
||||||
|
*Проверка:* ingest validation; review.
|
||||||
|
|
||||||
|
**RTE-05** | NEVER | Префиксы чужих ASN без авторизации (IRR/RPKI — ops review).
|
||||||
|
*Проверка:* ops review.
|
||||||
|
|
||||||
|
**RTE-06** | MUST | Отдельные export filters IPv4 и IPv6.
|
||||||
|
*Проверка:* `RenderExportFilterIPv4/IPv6`
|
||||||
|
|
||||||
|
**RTE-07** | SHOULD | AS_PATH match — ASN из tenant modules; дедуп `filterUniqueASNs`.
|
||||||
|
*Проверка:* unit tests.
|
||||||
|
|
||||||
|
**RTE-08** | MUST | Peer import/export overrides — расширение `birdfmt`, не ad-hoc в operator `bird.conf`.
|
||||||
|
*Проверка:* review.
|
||||||
|
|
||||||
|
**RTE-09** | SHOULD | hold 90s / keepalive 30s — менять только с обоснованием в settings/docs.
|
||||||
|
*Проверка:* review.
|
||||||
|
|
||||||
|
**RTE-10** | MUST | После apply — post-check `birdc`/метрики (`mergeBirdPostApplyMeta`, `EVOBGP_BIRDC_SOCKET`).
|
||||||
|
*Проверка:* jobs meta; observability poller.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## C. IP Addressing Rules
|
||||||
|
|
||||||
|
**IP-01** | MUST | CIDR в Go — `netip.Prefix`; строка — `Masked().String()`.
|
||||||
|
*Проверка:* `pipeline/parse.go`
|
||||||
|
|
||||||
|
**IP-02** | MUST | IPv4/IPv6 — отдельные fragments/filters.
|
||||||
|
*Проверка:* `layout.go`
|
||||||
|
|
||||||
|
**IP-03** | MUST | BGP neighbor — `ParsePeerNeighbor`; `/32`/`/128` нормализуются в host addr.
|
||||||
|
*Проверка:* `peer_neighbor_test.go`
|
||||||
|
|
||||||
|
**IP-04** | NEVER | Hardcoded production CIDR вне testdata/fixtures.
|
||||||
|
*Проверка:* grep; review.
|
||||||
|
|
||||||
|
**IP-05** | MUST | Fixtures/docs — TEST-NET (RFC 5737, 3849): `203.0.113.0/24`, `198.51.100.0/24`, `2001:db8::/32`.
|
||||||
|
*Проверка:* scenarios.
|
||||||
|
|
||||||
|
**IP-06** | MUST | Domain→IP: host routes `/32`, `/128` via `ipToHostPrefix`; invalid отбрасывается.
|
||||||
|
*Проверка:* `collect_parallel.go`
|
||||||
|
|
||||||
|
**IP-07** | MUST | IP range entries — валидный CIDR на границе API/store.
|
||||||
|
*Проверка:* httpapi; review.
|
||||||
|
|
||||||
|
**IP-08** | SHOULD | Множество префиксов — без дубликатов; агрегация — policy decision.
|
||||||
|
*Проверка:* snapshot dedup.
|
||||||
|
|
||||||
|
**IP-09** | MUST | `router id`, `local as` — из tenant settings, не hardcode в callers `birdfmt`.
|
||||||
|
*Проверка:* `docs/manual.md`
|
||||||
|
|
||||||
|
**IP-10** | NEVER | Reserved blocks (`0.0.0.0/8`, `127.0.0.0/8`, `169.254.0.0/16`, `224.0.0.0/4`, IPv6 analogs) в materialized prefixes.
|
||||||
|
*Проверка:* review; future validator.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## D. Internet Compliance
|
||||||
|
|
||||||
|
**COMP-01** | SHOULD | Policy changes — сверка RFC 4271, 4760, 7454 (обзор).
|
||||||
|
*Проверка:* review.
|
||||||
|
|
||||||
|
**COMP-02** | MUST | No excessive deaggregation без need.
|
||||||
|
*Проверка:* review prefix list.
|
||||||
|
|
||||||
|
**COMP-03** | MUST | Standard community 0..65535; large — schema store.
|
||||||
|
*Проверка:* `community_bird_test.go`
|
||||||
|
|
||||||
|
**COMP-04** | NEVER | AS_PATH/MED manipulation без product spec.
|
||||||
|
*Проверка:* review output.
|
||||||
|
|
||||||
|
**COMP-05** | SHOULD | Revision/job meta: source, counts, samples (`revisionLogEntry`).
|
||||||
|
*Проверка:* jobs.
|
||||||
|
|
||||||
|
**COMP-06** | MUST | Node: `evobgp-node verify-bundle` перед apply.
|
||||||
|
*Проверка:* `docs/access.md`
|
||||||
|
|
||||||
|
**COMP-07** | SHOULD | `bird_local_asn` = `local as` в templates/peers.
|
||||||
|
*Проверка:* settings + birdfmt.
|
||||||
|
|
||||||
|
**COMP-08** | NEVER | Default route leak без named `default-originate` policy.
|
||||||
|
*Проверка:* export filter review.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Validation & Observability
|
||||||
|
|
||||||
|
**VAL-01** | MUST | Перед деплоем: `bird -c <bird.conf> -p` (main + includes).
|
||||||
|
*Проверка:* CI `bird2`; `ParseCheck`
|
||||||
|
|
||||||
|
**VAL-02** | MUST | Preview ревизии read-only; apply — jobs/apply/bundle only.
|
||||||
|
*Проверка:* OpenAPI + httpapi.
|
||||||
|
|
||||||
|
**VAL-03** | MUST | Изменения birdfmt — `go test ./internal/birdfmt/...`.
|
||||||
|
*Проверка:* CI `go`
|
||||||
|
|
||||||
|
**VAL-04** | SHOULD | Dry-run apply где API поддерживает `dry_run`.
|
||||||
|
*Проверка:* routes.go.
|
||||||
|
|
||||||
|
**VAL-05** | MUST | `/metrics` Prometheus; `EVOBGP_BIRDC_SOCKET` — BGP session poller.
|
||||||
|
*Проверка:* `observability`
|
||||||
|
|
||||||
|
**VAL-06** | MUST | Логировать job_id, revision_id, speaker_id на apply/refresh/rollback.
|
||||||
|
*Проверка:* jobs API.
|
||||||
|
|
||||||
|
**VAL-07** | SHOULD | После apply — `birdc show protocols` / parsers в `birdfmt`.
|
||||||
|
*Проверка:* `protocols.go`
|
||||||
|
|
||||||
|
**VAL-08** | MUST | Мониторить drift: `last_applied_revision_id` vs published (`evobgp-deploy`).
|
||||||
|
*Проверка:* deploy worker.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Documentation Sync
|
||||||
|
|
||||||
|
**DOC-SYNC-05** | MUST | BIRD — https://bird.network.cz/?get_doc
|
||||||
|
**DOC-SYNC-08** | MUST | BGP policy — RFC 4271, 4760, 7454 + BIRD docs + `birdfmt`
|
||||||
|
**DOC-SYNC-09** | MUST | CIDR — https://pkg.go.dev/net/netip ; примеры — RFC 5737, 3849
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Enforcement
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
go test ./internal/birdfmt/... ./internal/pipeline/... -count=1
|
||||||
|
bird -c internal/birdfmt/testdata/scenarios/<name>/bird.conf -p
|
||||||
|
```
|
||||||
|
|
||||||
|
**PR checklist `birdfmt/**`, `pipeline/**`, `deploy/bird/**`:**
|
||||||
|
- [ ] filter + unit-test + `bird -p`
|
||||||
|
- [ ] нет дубли filter logic v4/v6 без общей Go-функции
|
||||||
|
- [ ] префиксы только store/revision path
|
||||||
|
- [ ] `import none` на templates сохранён
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
---
|
||||||
|
description: EvoBGP WebUI — shadcn-svelte, Svelte 5, слои ui/core|patterns|app
|
||||||
|
globs:
|
||||||
|
- web/**
|
||||||
|
alwaysApply: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# Web UI — shadcn-svelte
|
||||||
|
|
||||||
|
**Источник правды:** https://shadcn-svelte.com/docs (не React shadcn/ui, не Legacy Docs).
|
||||||
|
|
||||||
|
Общие правила Go/API: `.cursor/rules/engineering.mdc`. Локальная карта: `web/README.md`.
|
||||||
|
|
||||||
|
## Слои UI
|
||||||
|
|
||||||
|
| Слой | Путь | Назначение |
|
||||||
|
|------|------|------------|
|
||||||
|
| Примитивы | `src/lib/ui/core/` | shadcn-svelte (только CLI `add`) |
|
||||||
|
| Паттерны | `src/lib/ui/patterns/` | FormField, AppDataTable, ConfirmDialog, EmptyState |
|
||||||
|
| App chrome | `src/lib/ui/app/` | Layout, PageHeader, `notify` |
|
||||||
|
| Legacy | `src/lib/components/ui/` | Re-export; **не добавлять новые файлы** |
|
||||||
|
|
||||||
|
Тема: `src/routes/layout.css`, `src/lib/ui/app/tokens.md`. CLI из `web/`: `npx shadcn-svelte@latest add <component> -y -o`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Правила
|
||||||
|
|
||||||
|
**WEB-01** | MUST | Перед новым UI — проверить https://shadcn-svelte.com/docs/components; использовать компонент, не HTML+CSS с нуля.
|
||||||
|
*Rationale:* Open Code + единый дизайн.
|
||||||
|
*Проверка:* review; нет голых `<button class=…>`.
|
||||||
|
|
||||||
|
**WEB-02** | MUST | Отсутствующий примитив — `npx shadcn-svelte@latest add <component> -y -o` → `src/lib/ui/core/`.
|
||||||
|
*Rationale:* Distribution через CLI и `components.json`.
|
||||||
|
*Проверка:* файлы только в `ui/core`.
|
||||||
|
|
||||||
|
**WEB-03** | NEVER | Альтернативные UI-kit'ы (Material, Vuetify, DaisyUI-only без shadcn-примитива).
|
||||||
|
*Проверка:* `package.json` review.
|
||||||
|
|
||||||
|
**WEB-04** | MUST | Комозиция по docs: все sub-компоненты (`DialogHeader`, `TableRow`, `Field`, …).
|
||||||
|
*Проверка:* сверка со страницей компонента в docs.
|
||||||
|
|
||||||
|
**WEB-05** | MUST | Формы — Formsnap + `sveltekit-superforms`; UI в `ui/patterns/form`, не ad-hoc валидация на странице.
|
||||||
|
*Проверка:* https://shadcn-svelte.com/docs/components/form
|
||||||
|
|
||||||
|
**WEB-06** | MUST | Таблицы — Data Table + `@tanstack/table-core`; на страницах — `AppDataTable` из patterns.
|
||||||
|
*Проверка:* https://shadcn-svelte.com/docs/components/data-table
|
||||||
|
|
||||||
|
**WEB-07** | MUST | Toast — Sonner через `notify` из `ui/app/toast.js`.
|
||||||
|
*Проверка:* https://shadcn-svelte.com/docs/components/sonner
|
||||||
|
|
||||||
|
**WEB-08** | MUST | Иконки — `@lucide/svelte` (`components.json` → `iconLibrary: lucide`).
|
||||||
|
*Проверка:* imports.
|
||||||
|
|
||||||
|
**WEB-09** | MUST | Цвета — CSS-переменные `layout.css` и токены `tokens.md`; не hex/rgb на страницах.
|
||||||
|
*Проверка:* grep `#[0-9a-f]{3,6}` в `routes/`.
|
||||||
|
|
||||||
|
**WEB-10** | SHOULD | Кастомизация — правка `ui/core` (Open Code), не `!important` поверх API.
|
||||||
|
*Проверка:* review.
|
||||||
|
|
||||||
|
**WEB-11** | MUST | `routes/**` — композиция `ui/core` + `ui/patterns` + `ui/app`; не копировать целые примитивы shadcn в route.
|
||||||
|
*Проверка:* review.
|
||||||
|
|
||||||
|
**WEB-12** | NEVER | Примеры React shadcn/ui или Svelte 4 Legacy без адаптации под https://shadcn-svelte.com/docs/migration/svelte-5
|
||||||
|
*Проверка:* `npm run check`.
|
||||||
|
|
||||||
|
**WEB-13** | MUST | Реактивность — Svelte 5 runes (`$state`, `$derived`, `$effect`); не `export let` для локального state страниц.
|
||||||
|
*Проверка:* `npm run check`; Svelte MCP.
|
||||||
|
|
||||||
|
**WEB-14** | SHOULD | Нетривиальный UI — прочитать страницу компонента (props, a11y).
|
||||||
|
*Проверка:* PR description.
|
||||||
|
|
||||||
|
**WEB-15** | MUST | Сомнения — https://shadcn-svelte.com/llms.txt , Svelte MCP, `npm run check`.
|
||||||
|
*Проверка:* локально.
|
||||||
|
|
||||||
|
**WEB-16** | MUST | Подтверждение удаления — `ConfirmDialog` из patterns, не `window.confirm`.
|
||||||
|
*Проверка:* review.
|
||||||
|
|
||||||
|
**WEB-17** | MUST | Пустые списки — `EmptyState` из patterns.
|
||||||
|
*Проверка:* review.
|
||||||
|
|
||||||
|
**WEB-18** | SHOULD | Повторяемая комбинация core (≥2 раза) — вынести в `ui/patterns/`.
|
||||||
|
*Проверка:* review.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Documentation Sync (Web)
|
||||||
|
|
||||||
|
**DOC-SYNC-06** | MUST | UI — первично https://shadcn-svelte.com/docs; при конфликте с блогами/Stack Overflow побеждает официальная страница компонента.
|
||||||
|
**DOC-SYNC-07** | MUST | Перед `add` — сверить Installation/Theming с `web/components.json` и `src/routes/layout.css`.
|
||||||
|
|
||||||
|
Tailwind v4: https://shadcn-svelte.com/docs/migration/tailwind-v4
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Enforcement
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd web
|
||||||
|
npm run check
|
||||||
|
npm run lint
|
||||||
|
```
|
||||||
|
|
||||||
|
**PR checklist `web/**`:**
|
||||||
|
- [ ] `ui/core` / `ui/patterns`, не дубли примитивов
|
||||||
|
- [ ] Новые примитивы через shadcn CLI
|
||||||
|
- [ ] Ссылка на docs компонента (если новый паттерн)
|
||||||
|
|
||||||
|
**CI:** job `web` рекомендован; пока обязательно локально.
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
---
|
||||||
|
name: commit-message
|
||||||
|
description: >-
|
||||||
|
ОБЯЗАТЕЛЬНО при commit, коммит, закоммить, commit message, conventional commit,
|
||||||
|
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)
|
||||||
|
|
||||||
|
## Когда применять (сразу читать этот скилл)
|
||||||
|
|
||||||
|
Триггеры в промпте пользователя или в плане/AGENTS.md:
|
||||||
|
|
||||||
|
- `commit`, `коммит`, `закоммить`, `commit message`, `conventional commit`
|
||||||
|
- `git commit`, `staged`, `сообщение коммита`, `сгенерируй коммит`
|
||||||
|
- агент собирается выполнить `git commit` или предложить текст коммита
|
||||||
|
|
||||||
|
## Шаг 0 — ОБЯЗАТЕЛЬНО (до любого текста коммита)
|
||||||
|
|
||||||
|
**Первый вызов инструментов** в этой задаче — Shell из корня репозитория:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
powershell -NoProfile -File scripts/commit/staged-context.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
| Правило | Деталь |
|
||||||
|
|---------|--------|
|
||||||
|
| **MUST** | Запустить скрипт до генерации заголовка/тела коммита |
|
||||||
|
| **MUST NOT** | Строить сообщение только по `git diff --cached` / `git status` без скрипта |
|
||||||
|
| **MUST NOT** | Пропускать скрипт, даже если diff «и так понятен» |
|
||||||
|
| Exit `1` | Index пуст — сообщить пользователю, **не коммитить** |
|
||||||
|
| Exit `0` | Разобрать JSON stdout: `staged_count`, `groups[]` (`scope`, `files`, `stat`, `diff_excerpt`) |
|
||||||
|
|
||||||
|
Дополнительно: [.cursor/rules/conventional-commits.mdc](../../rules/conventional-commits.mdc).
|
||||||
|
|
||||||
|
## Слияние групп (одна фича)
|
||||||
|
|
||||||
|
Скрипт группирует **только по путям**. Перед split проверьте логическую связность:
|
||||||
|
|
||||||
|
**Объединить в один коммит**, если это одна задача:
|
||||||
|
|
||||||
|
- `docs/openapi.yaml` + `internal/httpapi/` (+ опционально `web/`) — один endpoint/контракт;
|
||||||
|
- `migrations/` + `internal/store/` / `repository/` — одна схема;
|
||||||
|
- правки теста рядом с кодом той же фичи.
|
||||||
|
|
||||||
|
При объединении: один scope (доминирующий пакет, часто `httpapi` или `openapi`), один type, одно тело RU.
|
||||||
|
|
||||||
|
**Auto-split** — если группы **не связаны** (например `web/` + `internal/birdfmt/` без общего смысла): **отдельный коммит на группу**, без вопроса пользователю.
|
||||||
|
|
||||||
|
## Порядок коммитов при split
|
||||||
|
|
||||||
|
1. `openapi`
|
||||||
|
2. `httpapi`, `store`, `jobs`
|
||||||
|
3. `pipeline`, `birdfmt`, `birddeploy`, `bundle`
|
||||||
|
4. `web`
|
||||||
|
5. `ci`, `deploy`, `db`
|
||||||
|
6. `docs`
|
||||||
|
7. `chore`, `cmd`
|
||||||
|
|
||||||
|
## Алгоритм auto-split (уровень файлов)
|
||||||
|
|
||||||
|
1. `git status` и `git diff --cached --stat` — зафиксировать полный список staged-файлов.
|
||||||
|
2. `git reset HEAD` — снять всё из index (working tree не трогать).
|
||||||
|
3. Для каждой (объединённой) группы по порядку выше:
|
||||||
|
- `git add -- <paths…>`
|
||||||
|
- Сгенерировать сообщение по правилу conventional-commits.
|
||||||
|
- Закоммитить (см. ниже).
|
||||||
|
4. После всех коммитов — `git status` для проверки.
|
||||||
|
|
||||||
|
**Ограничение:** частичный stage одного файла с разной семантикой — предупредить; split по hunk'ам не делать; предложить разнести правки по файлам.
|
||||||
|
|
||||||
|
## Создание коммита
|
||||||
|
|
||||||
|
Только если пользователь **явно** просил закоммитить. Иначе — только вывести готовые сообщения.
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
git commit -m "$( @'
|
||||||
|
<type>(<scope>): <summary in English>
|
||||||
|
|
||||||
|
<Тело на русском.>
|
||||||
|
'@ )"
|
||||||
|
```
|
||||||
|
|
||||||
|
В PowerShell для многострочного тела используйте here-string как выше или `-m` для заголовка и `-m` для тела.
|
||||||
|
|
||||||
|
Перед коммитом: `git status`, `git diff --cached` для группы — убедиться, что stage соответствует сообщению.
|
||||||
|
|
||||||
|
## Генерация текста
|
||||||
|
|
||||||
|
По `groups[].diff_excerpt`, `stat`, `files`:
|
||||||
|
|
||||||
|
### Шаг 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** — конкретный, английский, императив; для `fix` — что **починено** (`fix broken …`, `prevent … loop`).
|
||||||
|
- **body** — русский: что, зачем, edge cases, breaking impact.
|
||||||
|
|
||||||
|
## Вывод пользователю
|
||||||
|
|
||||||
|
Для **каждого** коммита:
|
||||||
|
|
||||||
|
### 1. Готовое сообщение
|
||||||
|
|
||||||
|
```
|
||||||
|
<type>(<scope>): <summary>
|
||||||
|
|
||||||
|
<тело>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Пояснение (RU)
|
||||||
|
|
||||||
|
- **Semver impact:** `minor` | `patch` | `none` | `major` — и почему не другой type.
|
||||||
|
- Риски и impact.
|
||||||
|
- Split: сколько коммитов и почему.
|
||||||
|
|
||||||
|
## Не смешивать
|
||||||
|
|
||||||
|
Один commit message — одна primary intent. Не объединять несвязанный `fix` и `feat` в один заголовок.
|
||||||
|
|
||||||
|
## Ссылки
|
||||||
|
|
||||||
|
- Правило: `.cursor/rules/conventional-commits.mdc`
|
||||||
|
- Скрипт: `scripts/commit/staged-context.ps1`
|
||||||
|
- Просмотр групп: `powershell -File scripts/commit/staged-context.ps1 | ConvertFrom-Json`
|
||||||
+29
-51
@@ -1,75 +1,53 @@
|
|||||||
# Gitea Actions
|
# Gitea Actions
|
||||||
|
|
||||||
В репозитории включён workflow [workflows/ci.yaml](workflows/ci.yaml).
|
Workflow: [workflows/ci.yaml](workflows/ci.yaml).
|
||||||
|
|
||||||
Job **changes** вычисляет два флага:
|
| Job | PR | push в main |
|
||||||
|
|-----|-----|-------------|
|
||||||
|
| changes, openapi, web, go, bird2 | quality gates | quality gates |
|
||||||
|
| commitlint | да | — |
|
||||||
|
| **release** | — | semantic-release + docker push (один run) |
|
||||||
|
|
||||||
- **`openapi`** — в диффе есть `docs/openapi.yaml` или `redocly.yaml` → запускается **openapi** (Redocly lint).
|
Подробнее: [docs/releasing.md](../docs/releasing.md).
|
||||||
- **`code`** — в диффе есть **хотя бы один файл вне «только документация»** → запускаются **go**, **bird2** и при push в main/master **docker-images**.
|
|
||||||
|
|
||||||
Если изменены **только** файлы из списка ниже, **`code=false`**: Go-тесты, bird2 и сборка образов **не** запускаются; при необходимости всё ещё выполняется **openapi**, если трогали OpenAPI или `redocly.yaml`.
|
## CI (quality gates)
|
||||||
|
|
||||||
**Список путей «только документация» (без полной сборки):** всё под `docs/`, корневой `README.md`, `web/README.md`, `.gitea/README.md`, `redocly.yaml`.
|
Job **changes** вычисляет флаги по путям в diff. Изменение `.gitea/workflows/*` поднимает полный прогон.
|
||||||
|
|
||||||
Любой другой путь (в т.ч. `.gitea/workflows/ci.yaml`, `go.mod`, `web/package.json`, `deploy/**`, `**/*.go`) включает полный пайплайн.
|
На **pull request** — **commitlint** (Conventional Commits).
|
||||||
|
|
||||||
Требования к runner:
|
Runner: `ubuntu-latest`, **bird2** из apt, Docker для job **release**.
|
||||||
|
|
||||||
- Метка `ubuntu-latest` (или измените `runs-on` под свои labels).
|
## Release (job в ci.yaml)
|
||||||
- Job `bird2` ставит пакет **bird2** из репозитория **Ubuntu** на runner (как образ `evobgp-bird2` на `ubuntu:noble`) и вызывает `bird -c … -p` по файлам в checkout (без вложенного Docker: иначе bind-mount `/workspace/...` часто не виден демону Docker на хосте).
|
|
||||||
- Job **docker-images** требует **Docker** и (для push) доступ к Container Registry Gitea с runner’а.
|
|
||||||
|
|
||||||
## Сборка и публикация образов (`docker-images`)
|
После успешных quality gates на **push в main** job **release**:
|
||||||
|
|
||||||
Запускается только при **push** в ветки `main` или `master` (после успешного job `go`). Собираются образы из `deploy/docker/*` (все бинарники через `gobinary`, плюс `evobgp-web`, `evobgp-agent`, `evobgp-bird2`).
|
1. `npx semantic-release` — тег `vX.Y.Z` на **текущий commit** (без дополнительного commit в main).
|
||||||
|
2. Gitea Release + `CHANGELOG.md` как attachment (не в git).
|
||||||
|
3. `docker buildx bake default --push` с `VERSION=X.Y.Z` — в том же job.
|
||||||
|
|
||||||
### Секреты репозитория (для push в реестр)
|
Если releasable-коммитов нет — semantic-release no-op, образы не публикуются.
|
||||||
|
|
||||||
Хост Container Registry в workflow **зашит**: **`git.shts.su`** (`docker/login-action` и теги образов).
|
### Секреты
|
||||||
|
|
||||||
В настройках репозитория Gitea: **Settings → Actions → Secrets** (или аналог):
|
**`ACTIONS_PAT`**: push tags, releases, Container Registry. Fallback: **`gitea.token`**.
|
||||||
|
|
||||||
| Секрет | Назначение |
|
### Теги образов
|
||||||
|--------|------------|
|
|
||||||
| `ACTIONS_PAT` | (Опционально) PAT с правом **записи пакетов**. Если не задан, для `docker login` используется **`gitea.token`** (нужны права у workflow на пакеты). |
|
|
||||||
|
|
||||||
Вход в реестр в workflow:
|
|
||||||
|
|
||||||
- **registry:** `git.shts.su`;
|
|
||||||
- **username:** `${{ gitea.actor }}`;
|
|
||||||
- **password:** `${{ secrets.ACTIONS_PAT || gitea.token }}`.
|
|
||||||
|
|
||||||
### Почему шаг «Log in to Gitea Registry» мог быть skipped
|
|
||||||
|
|
||||||
Раньше в `if:` стояло **`secrets.ACTIONS_PAT != ''`**. В **Gitea act_runner** такая проверка секрета в условии шага часто **всегда ложна**, из‑за этого логин **всегда skipped**. Сейчас условие только `docker_push == 'true'` (всегда при нормальном шаге **Prepare**), пароль: **`secrets.ACTIONS_PAT || gitea.token`**.
|
|
||||||
|
|
||||||
Если после правки `docker login` падает с 401/403 — выдайте job-токену или PAT право **записи в Container Registry** (в настройках Gitea / организации / репозитория, в зависимости от версии).
|
|
||||||
|
|
||||||
Если **`gitea.token`** в выражениях недоступен, в [ci.yaml](workflows/ci.yaml) замените пароль на `${{ secrets.ACTIONS_PAT || github.token }}`.
|
|
||||||
|
|
||||||
### Имена образов и теги
|
|
||||||
|
|
||||||
Формат:
|
|
||||||
|
|
||||||
```text
|
```text
|
||||||
git.shts.su/<owner_lowercase>/<имя>:<тег>
|
git.shts.su/<owner>/<имя>:latest
|
||||||
|
git.shts.su/<owner>/<имя>:v1.2.3
|
||||||
|
git.shts.su/<owner>/<имя>:1.2.3
|
||||||
|
git.shts.su/<owner>/<имя>:<short-sha>
|
||||||
|
git.shts.su/<owner>/<имя>:sha-<full-sha>
|
||||||
```
|
```
|
||||||
|
|
||||||
где `<имя>` — одно из: `evobgp-api`, `evobgp-all`, `evobgp-scheduler`, `evobgp-ingest`, `evobgp-render`, `evobgp-deploy`, `evobgp-node`, `evobgp-web`, `evobgp-web-all` (UI с прокси на `evobgp-all` для профиля Compose `microvps-full`), `evobgp-agent`, `evobgp-bird2`.
|
Имена образов: `evobgp-api`, `evobgp-all`, `evobgp-scheduler`, `evobgp-ingest`, `evobgp-render`, `evobgp-deploy`, `evobgp-node`, `evobgp-web`, `evobgp-web-all`, `evobgp-agent`, `evobgp-bird2`.
|
||||||
|
|
||||||
Теги: `latest`, короткий SHA (7 символов), `sha-<полный_commit_sha>`.
|
Пример:
|
||||||
|
|
||||||
Пример pull:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker pull git.shts.su/myuser/evobgp-api:latest
|
docker pull git.shts.su/myuser/evobgp-api:1.2.3
|
||||||
```
|
```
|
||||||
|
|
||||||
Полная таблица образов, тегов, ссылок на страницы пакетов в Gitea и минимальный `docker run` для сервера без сборки — в [docs/quickstart.md](../docs/quickstart.md) (раздел «Готовые образы без сборки»).
|
См. [deploy/docker/README.md](../deploy/docker/README.md), [docs/quickstart.md](../docs/quickstart.md).
|
||||||
|
|
||||||
Убедитесь, что в **Gitea** включены пакеты (Container Registry) и у токена есть права на создание/обновление образов. Другой хост реестра — правьте **`registry:`** и префикс **`IMG=`** в [ci.yaml](workflows/ci.yaml).
|
|
||||||
|
|
||||||
### Примечания
|
|
||||||
|
|
||||||
- Сборка с платформой `linux/amd64`. При необходимости добавьте `linux/arm64` и multi-arch в `docker buildx build`.
|
|
||||||
- Для PR job **docker-images** не выполняется (только push в main/master), чтобы не публиковать образы из чужих форков без явной политики.
|
|
||||||
+80
-91
@@ -121,6 +121,26 @@ jobs:
|
|||||||
- name: Lint OpenAPI (Redocly)
|
- name: Lint OpenAPI (Redocly)
|
||||||
run: npx --yes @redocly/cli@1 lint docs/openapi.yaml
|
run: npx --yes @redocly/cli@1 lint docs/openapi.yaml
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
web:
|
||||||
|
needs: [changes]
|
||||||
|
if: needs.changes.outputs.web == 'true'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "20"
|
||||||
|
cache: npm
|
||||||
|
cache-dependency-path: web/package-lock.json
|
||||||
|
- name: npm ci, check, lint
|
||||||
|
run: |
|
||||||
|
set -euxo pipefail
|
||||||
|
cd web
|
||||||
|
npm ci
|
||||||
|
npm run check
|
||||||
|
npm run lint
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
go:
|
go:
|
||||||
needs: [changes]
|
needs: [changes]
|
||||||
@@ -135,6 +155,8 @@ jobs:
|
|||||||
cache-dependency-path: go.sum
|
cache-dependency-path: go.sum
|
||||||
- name: Vet
|
- name: Vet
|
||||||
run: go vet ./...
|
run: go vet ./...
|
||||||
|
- name: Lint httpapi (ERR-01 / ARCH-01)
|
||||||
|
run: sh scripts/lint-httpapi.sh
|
||||||
- name: Test
|
- name: Test
|
||||||
run: go test ./... -race -count=1
|
run: go test ./... -race -count=1
|
||||||
- name: Build all commands
|
- name: Build all commands
|
||||||
@@ -186,88 +208,99 @@ jobs:
|
|||||||
done
|
done
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Docker: все Go-образы одним buildx bake (один go mod download, одна компиляция cmd/*,
|
commitlint:
|
||||||
# birdc собирается один раз для api/all). Registry cache: evobgp-buildcache:go-buildcache.
|
if: github.event_name == 'pull_request'
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
docker-go:
|
|
||||||
needs: [changes, go]
|
|
||||||
if: >-
|
|
||||||
always() &&
|
|
||||||
needs.changes.result == 'success' &&
|
|
||||||
needs.go.result != 'failure' &&
|
|
||||||
github.event_name == 'push' &&
|
|
||||||
(github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master') &&
|
|
||||||
(needs.changes.outputs.go == 'true' || needs.changes.outputs.docker_go == 'true')
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Set up Docker Buildx
|
|
||||||
uses: docker/setup-buildx-action@v3
|
|
||||||
- name: Prepare image metadata
|
|
||||||
id: meta
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
owner_lc="$(echo '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]')"
|
|
||||||
echo "owner_lc=$owner_lc" >> "$GITHUB_OUTPUT"
|
|
||||||
short_sha="$(echo '${{ github.sha }}' | cut -c1-7)"
|
|
||||||
echo "short_sha=$short_sha" >> "$GITHUB_OUTPUT"
|
|
||||||
- name: Log in to Gitea Registry
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
with:
|
||||||
registry: git.shts.su
|
fetch-depth: 0
|
||||||
username: ${{ gitea.actor }}
|
- uses: actions/setup-node@v4
|
||||||
password: ${{ secrets.ACTIONS_PAT || gitea.token }}
|
with:
|
||||||
- name: Build and push Go images (bake)
|
node-version: "22"
|
||||||
env:
|
cache: npm
|
||||||
REGISTRY: git.shts.su/${{ steps.meta.outputs.owner_lc }}
|
cache-dependency-path: package-lock.json
|
||||||
IMAGE_TAG: latest
|
- name: Lint commit messages
|
||||||
SHORT_SHA: ${{ steps.meta.outputs.short_sha }}
|
|
||||||
SHA_FULL: ${{ github.sha }}
|
|
||||||
CACHE_REF_GO: git.shts.su/${{ steps.meta.outputs.owner_lc }}/evobgp-buildcache:go-buildcache
|
|
||||||
CACHE_REF_WEB: ""
|
|
||||||
BUILDX_BAKE_ENTITLEMENTS_FS: "0"
|
|
||||||
run: |
|
run: |
|
||||||
set -euxo pipefail
|
set -euxo pipefail
|
||||||
cd "${{ github.workspace }}/deploy/docker"
|
npm ci
|
||||||
sh write-bake-override.sh
|
npx commitlint --from "${{ github.event.pull_request.base.sha }}" --to "${{ github.event.pull_request.head.sha }}"
|
||||||
docker buildx bake --allow=fs.read="${{ github.workspace }}" \
|
|
||||||
-f docker-bake.hcl -f docker-bake.override.hcl go-images --push
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Docker: Web (evobgp-web, evobgp-web-all) — один npm ci (кэш) + два nginx-тега.
|
# Один push в main: semantic-release (тег на текущий commit, без доп. commit) + docker push.
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
docker-web:
|
release:
|
||||||
needs: [changes]
|
needs: [changes, openapi, web, go, bird2]
|
||||||
if: >-
|
if: >-
|
||||||
|
always() &&
|
||||||
github.event_name == 'push' &&
|
github.event_name == 'push' &&
|
||||||
(github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master') &&
|
(github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master') &&
|
||||||
(needs.changes.outputs.web == 'true' || needs.changes.outputs.docker_web == 'true')
|
needs.changes.result == 'success' &&
|
||||||
|
(needs.openapi.result == 'success' || needs.openapi.result == 'skipped') &&
|
||||||
|
(needs.web.result == 'success' || needs.web.result == 'skipped') &&
|
||||||
|
(needs.go.result == 'success' || needs.go.result == 'skipped') &&
|
||||||
|
(needs.bird2.result == 'success' || needs.bird2.result == 'skipped')
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
token: ${{ secrets.ACTIONS_PAT || gitea.token }}
|
||||||
|
persist-credentials: true
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "22"
|
||||||
|
cache: npm
|
||||||
|
cache-dependency-path: package-lock.json
|
||||||
|
- name: Install release tooling
|
||||||
|
run: npm ci
|
||||||
|
- name: Semantic release
|
||||||
|
run: npx semantic-release
|
||||||
|
env:
|
||||||
|
GITEA_URL: https://git.shts.su
|
||||||
|
GITEA_TOKEN: ${{ secrets.ACTIONS_PAT || gitea.token }}
|
||||||
|
- name: Detect new release
|
||||||
|
id: rel
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
if [ -f .release-version ]; then
|
||||||
|
echo "version=$(tr -d '[:space:]' < .release-version)" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "released=true" >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
echo "released=false" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "No releasable commits — skipping image publish"
|
||||||
|
fi
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
|
if: steps.rel.outputs.released == 'true'
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v3
|
||||||
- name: Prepare image metadata
|
- name: Prepare image metadata
|
||||||
|
if: steps.rel.outputs.released == 'true'
|
||||||
id: meta
|
id: meta
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
echo "version=${{ steps.rel.outputs.version }}" >> "$GITHUB_OUTPUT"
|
||||||
owner_lc="$(echo '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]')"
|
owner_lc="$(echo '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]')"
|
||||||
echo "owner_lc=$owner_lc" >> "$GITHUB_OUTPUT"
|
echo "owner_lc=$owner_lc" >> "$GITHUB_OUTPUT"
|
||||||
short_sha="$(echo '${{ github.sha }}' | cut -c1-7)"
|
short_sha="$(echo '${{ github.sha }}' | cut -c1-7)"
|
||||||
echo "short_sha=$short_sha" >> "$GITHUB_OUTPUT"
|
echo "short_sha=$short_sha" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "build_time=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT"
|
||||||
- name: Log in to Gitea Registry
|
- name: Log in to Gitea Registry
|
||||||
|
if: steps.rel.outputs.released == 'true'
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
registry: git.shts.su
|
registry: git.shts.su
|
||||||
username: ${{ gitea.actor }}
|
username: ${{ gitea.actor }}
|
||||||
password: ${{ secrets.ACTIONS_PAT || gitea.token }}
|
password: ${{ secrets.ACTIONS_PAT || gitea.token }}
|
||||||
- name: Build and push Web images (bake)
|
- name: Build and push images (bake)
|
||||||
|
if: steps.rel.outputs.released == 'true'
|
||||||
env:
|
env:
|
||||||
REGISTRY: git.shts.su/${{ steps.meta.outputs.owner_lc }}
|
REGISTRY: git.shts.su/${{ steps.meta.outputs.owner_lc }}
|
||||||
IMAGE_TAG: latest
|
IMAGE_TAG: latest
|
||||||
|
VERSION: ${{ steps.meta.outputs.version }}
|
||||||
SHORT_SHA: ${{ steps.meta.outputs.short_sha }}
|
SHORT_SHA: ${{ steps.meta.outputs.short_sha }}
|
||||||
SHA_FULL: ${{ github.sha }}
|
SHA_FULL: ${{ github.sha }}
|
||||||
CACHE_REF_GO: ""
|
BUILD_TIME: ${{ steps.meta.outputs.build_time }}
|
||||||
|
CACHE_REF_GO: git.shts.su/${{ steps.meta.outputs.owner_lc }}/evobgp-buildcache:go-buildcache
|
||||||
CACHE_REF_WEB: git.shts.su/${{ steps.meta.outputs.owner_lc }}/evobgp-buildcache:web-buildcache
|
CACHE_REF_WEB: git.shts.su/${{ steps.meta.outputs.owner_lc }}/evobgp-buildcache:web-buildcache
|
||||||
BUILDX_BAKE_ENTITLEMENTS_FS: "0"
|
BUILDX_BAKE_ENTITLEMENTS_FS: "0"
|
||||||
run: |
|
run: |
|
||||||
@@ -275,49 +308,5 @@ jobs:
|
|||||||
cd "${{ github.workspace }}/deploy/docker"
|
cd "${{ github.workspace }}/deploy/docker"
|
||||||
sh write-bake-override.sh
|
sh write-bake-override.sh
|
||||||
docker buildx bake --allow=fs.read="${{ github.workspace }}" \
|
docker buildx bake --allow=fs.read="${{ github.workspace }}" \
|
||||||
-f docker-bake.hcl -f docker-bake.override.hcl web-images --push
|
-f docker-bake.hcl -f docker-bake.override.hcl default --push
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Docker: BIRD2 (evobgp-bird2).
|
|
||||||
# Собирается только при изменении deploy/bird/ или Dockerfile bird2.
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
docker-bird:
|
|
||||||
needs: [changes]
|
|
||||||
if: >-
|
|
||||||
github.event_name == 'push' &&
|
|
||||||
(github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master') &&
|
|
||||||
(needs.changes.outputs.bird_conf == 'true' || needs.changes.outputs.docker_bird == 'true')
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- name: Set up Docker Buildx
|
|
||||||
uses: docker/setup-buildx-action@v3
|
|
||||||
- name: Prepare image metadata
|
|
||||||
id: meta
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
owner_lc="$(echo '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]')"
|
|
||||||
echo "owner_lc=$owner_lc" >> "$GITHUB_OUTPUT"
|
|
||||||
short_sha="$(echo '${{ github.sha }}' | cut -c1-7)"
|
|
||||||
echo "short_sha=$short_sha" >> "$GITHUB_OUTPUT"
|
|
||||||
- name: Log in to Gitea Registry
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
registry: git.shts.su
|
|
||||||
username: ${{ gitea.actor }}
|
|
||||||
password: ${{ secrets.ACTIONS_PAT || gitea.token }}
|
|
||||||
- name: Build and push evobgp-bird2 (bake)
|
|
||||||
env:
|
|
||||||
REGISTRY: git.shts.su/${{ steps.meta.outputs.owner_lc }}
|
|
||||||
IMAGE_TAG: latest
|
|
||||||
SHORT_SHA: ${{ steps.meta.outputs.short_sha }}
|
|
||||||
SHA_FULL: ${{ github.sha }}
|
|
||||||
CACHE_REF_GO: ""
|
|
||||||
CACHE_REF_WEB: ""
|
|
||||||
BUILDX_BAKE_ENTITLEMENTS_FS: "0"
|
|
||||||
run: |
|
|
||||||
set -euxo pipefail
|
|
||||||
cd "${{ github.workspace }}/deploy/docker"
|
|
||||||
sh write-bake-override.sh
|
|
||||||
docker buildx bake --allow=fs.read="${{ github.workspace }}" \
|
|
||||||
-f docker-bake.hcl -f docker-bake.override.hcl evobgp-bird2 --push
|
|
||||||
+18
@@ -0,0 +1,18 @@
|
|||||||
|
# Root npm (semantic-release, commitlint) — npm ci in CI, never commit deps
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Generated by deploy/docker/write-bake-override.sh (CI/local bake)
|
||||||
|
deploy/docker/docker-bake.override.hcl
|
||||||
|
|
||||||
|
# CI-only markers (not committed)
|
||||||
|
.release-version
|
||||||
|
CHANGELOG.md
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Env / secrets (local only)
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
run:
|
||||||
|
timeout: 5m
|
||||||
|
|
||||||
|
linters:
|
||||||
|
enable:
|
||||||
|
- gofmt
|
||||||
|
- govet
|
||||||
|
- errcheck
|
||||||
|
|
||||||
|
issues:
|
||||||
|
exclude-use-default: false
|
||||||
|
max-issues-per-linter: 50
|
||||||
|
max-same-issues: 20
|
||||||
|
|
||||||
|
linters-settings:
|
||||||
|
gofmt:
|
||||||
|
simplify: true
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
{
|
||||||
|
"branches": ["main", "master"],
|
||||||
|
"tagFormat": "v${version}",
|
||||||
|
"plugins": [
|
||||||
|
[
|
||||||
|
"@semantic-release/commit-analyzer",
|
||||||
|
{
|
||||||
|
"preset": "conventionalcommits",
|
||||||
|
"releaseRules": [
|
||||||
|
{ "type": "feat", "release": "minor" },
|
||||||
|
{ "type": "fix", "release": "patch" },
|
||||||
|
{ "type": "perf", "release": "patch" },
|
||||||
|
{ "breaking": true, "release": "major" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"@semantic-release/release-notes-generator",
|
||||||
|
{
|
||||||
|
"preset": "conventionalcommits"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"@semantic-release/changelog",
|
||||||
|
{
|
||||||
|
"changelogFile": "CHANGELOG.md"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"@semantic-release/exec",
|
||||||
|
{
|
||||||
|
"successCmd": "echo ${nextRelease.version} > .release-version"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"@markwylde/semantic-release-gitea",
|
||||||
|
{
|
||||||
|
"giteaUrl": "https://git.shts.su",
|
||||||
|
"assets": [
|
||||||
|
{
|
||||||
|
"path": "CHANGELOG.md",
|
||||||
|
"label": "Changelog"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
## С чего начать (минимум чтения)
|
## С чего начать (минимум чтения)
|
||||||
|
|
||||||
|
0. **Инженерные правила** — при изменении кода следовать [.cursor/rules/engineering.mdc](.cursor/rules/engineering.mdc); для `web/` — [.cursor/rules/web-shadcn.mdc](.cursor/rules/web-shadcn.mdc); для `birdfmt` / `pipeline` / BIRD — [.cursor/rules/networking-bird.mdc](.cursor/rules/networking-bird.mdc).
|
||||||
1. **[docs/README.md](docs/README.md)** — оглавление и роли читателя.
|
1. **[docs/README.md](docs/README.md)** — оглавление и роли читателя.
|
||||||
2. **[docs/architecture.md](docs/architecture.md)** — компоненты `cmd/`, карта `internal/`, потоки данных (одного этого файла обычно достаточно для ориентации).
|
2. **[docs/architecture.md](docs/architecture.md)** — компоненты `cmd/`, карта `internal/`, потоки данных (одного этого файла обычно достаточно для ориентации).
|
||||||
3. Задача-специфично: [docs/api.md](docs/api.md), [docs/access.md](docs/access.md), [web/README.md](web/README.md) — только если меняете API, доступ или фронт.
|
3. Задача-специфично: [docs/api.md](docs/api.md), [docs/access.md](docs/access.md), [web/README.md](web/README.md) — только если меняете API, доступ или фронт.
|
||||||
@@ -35,6 +36,17 @@
|
|||||||
- **Повторное использование:** если [docs/architecture.md](docs/architecture.md) уже описывает поток — не пересказывайте его длинно; укажите документ и конкретный подпункт задачи.
|
- **Повторное использование:** если [docs/architecture.md](docs/architecture.md) уже описывает поток — не пересказывайте его длинно; укажите документ и конкретный подпункт задачи.
|
||||||
- **Длинные планы:** `.cursor/plans/*.plan.md` — для истории решений; для навигации пользователю достаточно `docs/`; не читайте план целиком без причины.
|
- **Длинные планы:** `.cursor/plans/*.plan.md` — для истории решений; для навигации пользователю достаточно `docs/`; не читайте план целиком без причины.
|
||||||
|
|
||||||
|
## Коммиты (Conventional Commits)
|
||||||
|
|
||||||
|
Если пользователь просит **коммит**, **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`.
|
- Консоль пользователя: **PowerShell**; пути в стиле `deploy\compose`.
|
||||||
|
|||||||
+150
@@ -0,0 +1,150 @@
|
|||||||
|
## 1.0.0 (2026-05-19)
|
||||||
|
|
||||||
|
* feat: add @tanstack/table-core dependency and integrate module name mapping in operations page. Upda ([b140dcb](http://192.168.100.56:3000/denozord/EvoBGP/commit/b140dcb))
|
||||||
|
* feat: add @types/node and undici-types to devDependencies in package.json and package-lock.json for ([d92a06c](http://192.168.100.56:3000/denozord/EvoBGP/commit/d92a06c))
|
||||||
|
* feat: add aggregated router-lists catalog endpoint and update module listing filters ([c811c43](http://192.168.100.56:3000/denozord/EvoBGP/commit/c811c43))
|
||||||
|
* feat: add auto-updater service for Docker stack with configurable settings ([ae9fbe3](http://192.168.100.56:3000/denozord/EvoBGP/commit/ae9fbe3))
|
||||||
|
* feat: add bird configuration preview functionality with tabbed interface for fragments and prefixes. ([1969a00](http://192.168.100.56:3000/denozord/EvoBGP/commit/1969a00))
|
||||||
|
* feat: add BIRD status endpoint and integrate birdc checks into job processing. Enhance UI to display ([94a4c6a](http://192.168.100.56:3000/denozord/EvoBGP/commit/94a4c6a))
|
||||||
|
* feat: add CSV import and export functionality for module entries. Implement endpoints for exporting ([bae2d68](http://192.168.100.56:3000/denozord/EvoBGP/commit/bae2d68))
|
||||||
|
* feat: add debug logging and optimize database queries in Postgres repository ([6d0e214](http://192.168.100.56:3000/denozord/EvoBGP/commit/6d0e214))
|
||||||
|
* feat: add diagnostic log endpoint for revisions and implement download functionality ([2260ecf](http://192.168.100.56:3000/denozord/EvoBGP/commit/2260ecf))
|
||||||
|
* feat: add enabled state to BGP peer types and UI. Implement peer enabling toggle in the network page ([80965ad](http://192.168.100.56:3000/denozord/EvoBGP/commit/80965ad))
|
||||||
|
* feat: add last refreshed timestamp to modules and CDN sources ([1e13ef9](http://192.168.100.56:3000/denozord/EvoBGP/commit/1e13ef9))
|
||||||
|
* feat: add live BGP protocol state tracking to peer listing ([8a50ba4](http://192.168.100.56:3000/denozord/EvoBGP/commit/8a50ba4))
|
||||||
|
* feat: add optional name field to BGP peer creation and update UI ([1a61ae2](http://192.168.100.56:3000/denozord/EvoBGP/commit/1a61ae2))
|
||||||
|
* feat: add optional name field to DoH profile schema and types ([6bee5ad](http://192.168.100.56:3000/denozord/EvoBGP/commit/6bee5ad))
|
||||||
|
* feat: add smart aggregation for IPv4 CIDR prefixes in refresh pipeline. Implement functionality to a ([0428b83](http://192.168.100.56:3000/denozord/EvoBGP/commit/0428b83))
|
||||||
|
* feat: add strict bind configuration to BGP templates. Enhance BGP template rendering by including 's ([07f2ce5](http://192.168.100.56:3000/denozord/EvoBGP/commit/07f2ce5))
|
||||||
|
* feat: add tenant refresh functionality to HTTP API and job processing ([7a2b015](http://192.168.100.56:3000/denozord/EvoBGP/commit/7a2b015))
|
||||||
|
* feat: auto-refresh non-IP module updates and add full project manual ([1e77e1a](http://192.168.100.56:3000/denozord/EvoBGP/commit/1e77e1a))
|
||||||
|
* feat: enhance API client with pagination and new data fetching functions. Implement `apiPageAll` for ([9f0b128](http://192.168.100.56:3000/denozord/EvoBGP/commit/9f0b128))
|
||||||
|
* feat: enhance BGP template and peer configuration handling. Introduce policies_json parameter for lo ([be8e7b5](http://192.168.100.56:3000/denozord/EvoBGP/commit/be8e7b5))
|
||||||
|
* feat: enhance CDN source management with last refreshed timestamp ([4776434](http://192.168.100.56:3000/denozord/EvoBGP/commit/4776434))
|
||||||
|
* feat: enhance community attribute handling in BIRD format. Add validation for community route bodies ([b541794](http://192.168.100.56:3000/denozord/EvoBGP/commit/b541794))
|
||||||
|
* feat: enhance community label handling and improve UI localization across operations pages. Introduc ([53fd527](http://192.168.100.56:3000/denozord/EvoBGP/commit/53fd527))
|
||||||
|
* feat: enhance deployment configuration for secure Web UI with Traefik integration ([4acd685](http://192.168.100.56:3000/denozord/EvoBGP/commit/4acd685))
|
||||||
|
* feat: enhance DoH profile management and resolver policy in modules ([34ecc5c](http://192.168.100.56:3000/denozord/EvoBGP/commit/34ecc5c))
|
||||||
|
* feat: enhance EvoBGP with new command-line options for the evobgp-agent, including a watch command f ([6a55f72](http://192.168.100.56:3000/denozord/EvoBGP/commit/6a55f72))
|
||||||
|
* feat: enhance evobgp with new command-line tools for bundle management, including pull, verify, and ([bf52b21](http://192.168.100.56:3000/denozord/EvoBGP/commit/bf52b21))
|
||||||
|
* feat: enhance job detail handling in UI with caching and loading states. Introduce SvelteMap for job ([05bc5c4](http://192.168.100.56:3000/denozord/EvoBGP/commit/05bc5c4))
|
||||||
|
* feat: enhance job detail UI with improved overflow handling and layout adjustments. Update card comp ([67ce135](http://192.168.100.56:3000/denozord/EvoBGP/commit/67ce135))
|
||||||
|
* feat: enhance job filtering and display functionality in operations page. Introduce job search and f ([e1c4cbc](http://192.168.100.56:3000/denozord/EvoBGP/commit/e1c4cbc))
|
||||||
|
* feat: enhance job processing with detailed logging and UI improvements. Introduce revision log entri ([c48cc4e](http://192.168.100.56:3000/denozord/EvoBGP/commit/c48cc4e))
|
||||||
|
* feat: enhance monitoring page with detailed status tracking and error handling ([458a885](http://192.168.100.56:3000/denozord/EvoBGP/commit/458a885))
|
||||||
|
* feat: enhance observability with prefix aggregation metrics ([07b97bd](http://192.168.100.56:3000/denozord/EvoBGP/commit/07b97bd))
|
||||||
|
* feat: enhance peer neighbor handling by introducing NormalizePeerNeighborString function. Update Cre ([80f3a8d](http://192.168.100.56:3000/denozord/EvoBGP/commit/80f3a8d))
|
||||||
|
* feat: enhance RefreshModule to aggregate prefixes from all enabled tenant modules. Update documentat ([431c6a9](http://192.168.100.56:3000/denozord/EvoBGP/commit/431c6a9))
|
||||||
|
* feat: enhance RefreshModule to support legacy ASN resolution and improve prefix handling. Introduce ([e1aaa18](http://192.168.100.56:3000/denozord/EvoBGP/commit/e1aaa18))
|
||||||
|
* feat: enhance settings page layout and section management ([d91667f](http://192.168.100.56:3000/denozord/EvoBGP/commit/d91667f))
|
||||||
|
* feat: enhance UI components with improved styling and layout adjustments. Update button sizes, text ([6a72842](http://192.168.100.56:3000/denozord/EvoBGP/commit/6a72842))
|
||||||
|
* feat: enhance worker functionality by adding job queuing for deploy_apply. Introduce a Registry fiel ([42d956d](http://192.168.100.56:3000/denozord/EvoBGP/commit/42d956d))
|
||||||
|
* feat: implement AS holder name resolution and enhance ASEntry structure. Add ASHolderName function t ([c958d5a](http://192.168.100.56:3000/denozord/EvoBGP/commit/c958d5a))
|
||||||
|
* feat: implement bulk selection and deletion for modules and entries. Add functionality for selecting ([e816d17](http://192.168.100.56:3000/denozord/EvoBGP/commit/e816d17))
|
||||||
|
* feat: implement CDN source preview functionality and enhance data handling. Add a new endpoint for p ([e20c9f3](http://192.168.100.56:3000/denozord/EvoBGP/commit/e20c9f3))
|
||||||
|
* feat: implement countAll function for API data retrieval ([40c8a8d](http://192.168.100.56:3000/denozord/EvoBGP/commit/40c8a8d))
|
||||||
|
* feat: implement domain resolution via DoH in refresh pipeline. Enhance the collectModulePrefixRows f ([678bd27](http://192.168.100.56:3000/denozord/EvoBGP/commit/678bd27))
|
||||||
|
* feat: implement effective peer enabling logic during peer creation. Update CreatePeer methods in Pos ([3e85431](http://192.168.100.56:3000/denozord/EvoBGP/commit/3e85431))
|
||||||
|
* feat: implement module refresh job management with concurrency control. Add CountOtherActiveModuleRe ([149fb0a](http://192.168.100.56:3000/denozord/EvoBGP/commit/149fb0a))
|
||||||
|
* feat: implement peer reconciliation job for BGP peers ([3b17228](http://192.168.100.56:3000/denozord/EvoBGP/commit/3b17228))
|
||||||
|
* feat: implement revision retention settings and pruning logic ([4f50350](http://192.168.100.56:3000/denozord/EvoBGP/commit/4f50350))
|
||||||
|
* feat: implement theme selection and automatic theme application based on user preference. Update App ([b70a643](http://192.168.100.56:3000/denozord/EvoBGP/commit/b70a643))
|
||||||
|
* feat: implement versioning and release management in the project ([bb334b1](http://192.168.100.56:3000/denozord/EvoBGP/commit/bb334b1))
|
||||||
|
* feat: improve BGP protocol summary handling by introducing isBGPProtocolSummaryRow function. Update ([399622c](http://192.168.100.56:3000/denozord/EvoBGP/commit/399622c))
|
||||||
|
* feat: improve job detail UI with enhanced spacing and layout adjustments. Update table cell classes ([97908b5](http://192.168.100.56:3000/denozord/EvoBGP/commit/97908b5))
|
||||||
|
* feat: improve peer speaker assignment UX in network settings ([f877ec8](http://192.168.100.56:3000/denozord/EvoBGP/commit/f877ec8))
|
||||||
|
* feat: improve UI components with enhanced overflow handling and layout adjustments. Update card and ([0ca5542](http://192.168.100.56:3000/denozord/EvoBGP/commit/0ca5542))
|
||||||
|
* feat: improve UI layout and responsiveness in module and operations pages. Refactor component struct ([233f29b](http://192.168.100.56:3000/denozord/EvoBGP/commit/233f29b))
|
||||||
|
* feat: redesign settings page with structured controls ([2da6d49](http://192.168.100.56:3000/denozord/EvoBGP/commit/2da6d49))
|
||||||
|
* feat: refactor materialization logic to group static routes by community in the refresh pipeline. In ([1c5a9c9](http://192.168.100.56:3000/denozord/EvoBGP/commit/1c5a9c9))
|
||||||
|
* feat: refactor operations page to enhance UI and component structure. Remove unused card components ([88226f6](http://192.168.100.56:3000/denozord/EvoBGP/commit/88226f6))
|
||||||
|
* feat: refine BGP template handling and documentation updates. Remove local IP requirements in BGP te ([6266a39](http://192.168.100.56:3000/denozord/EvoBGP/commit/6266a39))
|
||||||
|
* feat: refine job detail UI with improved layout and scrolling capabilities. Enhance spacing and over ([57c09ac](http://192.168.100.56:3000/denozord/EvoBGP/commit/57c09ac))
|
||||||
|
* feat: update BGP community structure and related documentation. Refactor BGP community handling by r ([a2cbfac](http://192.168.100.56:3000/denozord/EvoBGP/commit/a2cbfac))
|
||||||
|
* feat: update BGP template configuration and OpenAPI documentation. Change import settings from 'all' ([133ea93](http://192.168.100.56:3000/denozord/EvoBGP/commit/133ea93))
|
||||||
|
* feat: update BIRD installation process to build from source. Modify CI workflow to ensure BIRD 2.14 ([e2002ba](http://192.168.100.56:3000/denozord/EvoBGP/commit/e2002ba))
|
||||||
|
* feat: update CI configuration and Dockerfiles for improved build efficiency ([80229b3](http://192.168.100.56:3000/denozord/EvoBGP/commit/80229b3))
|
||||||
|
* feat: update collectModulePrefixRows to support prior snapshots and enhance CDN/domain prefix collec ([ee364c8](http://192.168.100.56:3000/denozord/EvoBGP/commit/ee364c8))
|
||||||
|
* feat: update Go module dependencies and enhance DNS resolution in refresh pipeline. Upgrade Go versi ([655d750](http://192.168.100.56:3000/denozord/EvoBGP/commit/655d750))
|
||||||
|
* feat: update OpenAPI documentation and internal logic for BIRD settings. Enhance the description of ([62bf987](http://192.168.100.56:3000/denozord/EvoBGP/commit/62bf987))
|
||||||
|
* feat: update RevisionDiff type and enhance OperationsDiffTab component for better diff handling. Int ([4aaaa30](http://192.168.100.56:3000/denozord/EvoBGP/commit/4aaaa30))
|
||||||
|
* feat: update Traefik deployment configuration for Let's Encrypt and logging ([39ba976](http://192.168.100.56:3000/denozord/EvoBGP/commit/39ba976))
|
||||||
|
* docs: add router-lists-ui integration documentation to README ([b7a8aab](http://192.168.100.56:3000/denozord/EvoBGP/commit/b7a8aab))
|
||||||
|
* docs: add Web UI section to EvoBGP architecture plan, detailing Svelte and shadcn-svelte integration ([272542b](http://192.168.100.56:3000/denozord/EvoBGP/commit/272542b))
|
||||||
|
* docs: enhance EvoBGP architecture plan with new risk mitigation strategies, testing matrix for BIRD2 ([3992d01](http://192.168.100.56:3000/denozord/EvoBGP/commit/3992d01))
|
||||||
|
* docs: enhance README and CI workflow documentation for Docker image building and publishing process, ([9eb3e33](http://192.168.100.56:3000/denozord/EvoBGP/commit/9eb3e33))
|
||||||
|
* docs: enhance README and CI workflow to clarify Docker image build conditions and Gitea Container Re ([c6687b2](http://192.168.100.56:3000/denozord/EvoBGP/commit/c6687b2))
|
||||||
|
* docs: openapi.html со встроенным Redoc; инструкция для Gitea и скрипт сборки ([a754e1d](http://192.168.100.56:3000/denozord/EvoBGP/commit/a754e1d))
|
||||||
|
* docs: update AGENTS and README to include guidelines for Conventional Commits ([4c23232](http://192.168.100.56:3000/denozord/EvoBGP/commit/4c23232))
|
||||||
|
* docs: update AGENTS and README with engineering rules and guidelines ([aff27e8](http://192.168.100.56:3000/denozord/EvoBGP/commit/aff27e8))
|
||||||
|
* docs: update architecture and API documentation to clarify job processing, in-memory registry usage, ([acc0608](http://192.168.100.56:3000/denozord/EvoBGP/commit/acc0608))
|
||||||
|
* docs: update EvoBGP architecture plan with detailed REST API section, including general agreements, ([1a86d6c](http://192.168.100.56:3000/denozord/EvoBGP/commit/1a86d6c))
|
||||||
|
* docs: update EvoBGP architecture plan with new repository structure, refined sections, and detailed ([4480d64](http://192.168.100.56:3000/denozord/EvoBGP/commit/4480d64))
|
||||||
|
* docs: update README and CI workflow to clarify Docker workspace mounting for bird2 job ([6be46db](http://192.168.100.56:3000/denozord/EvoBGP/commit/6be46db))
|
||||||
|
* docs: update README and CI workflow to clarify Gitea Registry login process and improve Docker image ([f1b33b9](http://192.168.100.56:3000/denozord/EvoBGP/commit/f1b33b9))
|
||||||
|
* docs: update README and Docker Compose configurations to clarify usage of pre-built images from the ([7fac79c](http://192.168.100.56:3000/denozord/EvoBGP/commit/7fac79c))
|
||||||
|
* docs: update README to include information about the EvoBGP web interface, linking to quickstart and ([5d21f01](http://192.168.100.56:3000/denozord/EvoBGP/commit/5d21f01))
|
||||||
|
* docs: статическая Redoc-страница OpenAPI (для просмотра в Gitea без встроенного рендера) ([5c9d2e2](http://192.168.100.56:3000/denozord/EvoBGP/commit/5c9d2e2))
|
||||||
|
* docs: убрать типографское тире и невидимый Unicode из текстов API ([fbe9cd3](http://192.168.100.56:3000/denozord/EvoBGP/commit/fbe9cd3))
|
||||||
|
* refactor: adjust BootstrapWorkers function call in server.go to change the order of returned values, ([032fa99](http://192.168.100.56:3000/denozord/EvoBGP/commit/032fa99))
|
||||||
|
* refactor: consolidate module interval display logic and improve edit form handling ([1f85654](http://192.168.100.56:3000/denozord/EvoBGP/commit/1f85654))
|
||||||
|
* refactor: enhance bird metrics polling with context support ([f6b94a4](http://192.168.100.56:3000/denozord/EvoBGP/commit/f6b94a4))
|
||||||
|
* refactor: enhance CI workflow for granular change detection by module and improve idempotency key ge ([54eaad3](http://192.168.100.56:3000/denozord/EvoBGP/commit/54eaad3))
|
||||||
|
* refactor: enhance job status checks in worker tests ([6107c3e](http://192.168.100.56:3000/denozord/EvoBGP/commit/6107c3e))
|
||||||
|
* refactor: enhance JSON struct tags for various data models in backend and memory store. Update field ([4a860a0](http://192.168.100.56:3000/denozord/EvoBGP/commit/4a860a0))
|
||||||
|
* refactor: enhance module display and input handling in module pages ([25374a6](http://192.168.100.56:3000/denozord/EvoBGP/commit/25374a6))
|
||||||
|
* refactor: enhance module interval label logic and add reset buttons in edit forms ([b99fc17](http://192.168.100.56:3000/denozord/EvoBGP/commit/b99fc17))
|
||||||
|
* refactor: improve code consistency and formatting across components ([c263fd5](http://192.168.100.56:3000/denozord/EvoBGP/commit/c263fd5))
|
||||||
|
* refactor: improve handling of deployable fragments in ApplyRevision and buildPreviewFragments functi ([6932271](http://192.168.100.56:3000/denozord/EvoBGP/commit/6932271))
|
||||||
|
* refactor: improve module save functionality and nullable value handling ([05ec176](http://192.168.100.56:3000/denozord/EvoBGP/commit/05ec176))
|
||||||
|
* refactor: improve PATCH handling for module updates with nullable field support ([e700f90](http://192.168.100.56:3000/denozord/EvoBGP/commit/e700f90))
|
||||||
|
* refactor: optimize job listing and counting logic for concurrency safety. Separate tenant filtering ([d83efe2](http://192.168.100.56:3000/denozord/EvoBGP/commit/d83efe2))
|
||||||
|
* refactor: phase 2 structural alignment — reports, importer, nodecli, pagination ([250cdea](http://192.168.100.56:3000/denozord/EvoBGP/commit/250cdea))
|
||||||
|
* refactor: remove strict bind configuration from BGP templates and update related logic. Eliminate 's ([482eeb1](http://192.168.100.56:3000/denozord/EvoBGP/commit/482eeb1))
|
||||||
|
* refactor: streamline Docker build process in CI configuration and documentation ([6025f38](http://192.168.100.56:3000/denozord/EvoBGP/commit/6025f38))
|
||||||
|
* refactor: streamline edit form handling for community and DoH profile selections ([1765ab8](http://192.168.100.56:3000/denozord/EvoBGP/commit/1765ab8))
|
||||||
|
* refactor: streamline module refresh process and enhance revision handling ([10e2415](http://192.168.100.56:3000/denozord/EvoBGP/commit/10e2415))
|
||||||
|
* refactor: switch BIRD installation from source to Ubuntu package. Update CI workflow and Dockerfiles ([0b3ceaa](http://192.168.100.56:3000/denozord/EvoBGP/commit/0b3ceaa))
|
||||||
|
* refactor: update AS entry handling in API and database to support ASN-only entries. Remove prefix fi ([1db6b5b](http://192.168.100.56:3000/denozord/EvoBGP/commit/1db6b5b))
|
||||||
|
* refactor: update Docker build context and paths in CI configuration and documentation ([7d52a4a](http://192.168.100.56:3000/denozord/EvoBGP/commit/7d52a4a))
|
||||||
|
* refactor: update Docker build variable syntax in CI configuration and documentation ([27e01d0](http://192.168.100.56:3000/denozord/EvoBGP/commit/27e01d0))
|
||||||
|
* refactor: update job kind display logic in schedule page ([dc1f4a5](http://192.168.100.56:3000/denozord/EvoBGP/commit/dc1f4a5))
|
||||||
|
* refactor: update ListModules and GetModule methods in Postgres repository to include cron expression ([73d0003](http://192.168.100.56:3000/denozord/EvoBGP/commit/73d0003))
|
||||||
|
* refactor: update Switch component styles and structure for improved accessibility and responsiveness ([d4282c2](http://192.168.100.56:3000/denozord/EvoBGP/commit/d4282c2))
|
||||||
|
* refactor: update worker processes in EvoBGP to accept dependencies for shared store and job registry ([b7968db](http://192.168.100.56:3000/denozord/EvoBGP/commit/b7968db))
|
||||||
|
* ci: add conditional job for OpenAPI linting based on file changes; update README with workflow detai ([3adfc49](http://192.168.100.56:3000/denozord/EvoBGP/commit/3adfc49))
|
||||||
|
* ci: phase 4 automation — web job, lint-httpapi, golangci ([64516c3](http://192.168.100.56:3000/denozord/EvoBGP/commit/64516c3))
|
||||||
|
* ci: update README and CI workflow to improve bird2 job installation process and clarify Docker usage ([eddc6b8](http://192.168.100.56:3000/denozord/EvoBGP/commit/eddc6b8))
|
||||||
|
* chore: phase 3 code quality — pipeline prefixes, godoc baseline ([ea65451](http://192.168.100.56:3000/denozord/EvoBGP/commit/ea65451))
|
||||||
|
* chore: update CI configuration to disable filesystem entitlement for Docker builds ([9ab7de6](http://192.168.100.56:3000/denozord/EvoBGP/commit/9ab7de6))
|
||||||
|
* chore: update package dependencies and enhance AppShell component with new navigation items and tool ([7d85fef](http://192.168.100.56:3000/denozord/EvoBGP/commit/7d85fef))
|
||||||
|
* chore: upgrade Go version to 1.24 in Dockerfiles for evobgp-agent and gobinary. This change ensures ([4ba5b03](http://192.168.100.56:3000/denozord/EvoBGP/commit/4ba5b03))
|
||||||
|
* fix: adjust Postgres resource limits and healthcheck parameters. Increase memory limit to 256M and m ([20e674c](http://192.168.100.56:3000/denozord/EvoBGP/commit/20e674c))
|
||||||
|
* fix: improve input handling and validation for settings fields ([1386b63](http://192.168.100.56:3000/denozord/EvoBGP/commit/1386b63))
|
||||||
|
* fix: improve sidebar and navigation layout in AppShell component ([9f02128](http://192.168.100.56:3000/denozord/EvoBGP/commit/9f02128))
|
||||||
|
* fix: update BGP configuration in test scenarios to use new IPv6 addresses ([9fd9745](http://192.168.100.56:3000/denozord/EvoBGP/commit/9fd9745))
|
||||||
|
* fix: update bird build script and Dockerfile to include libncurses dependency ([0cb5a7d](http://192.168.100.56:3000/denozord/EvoBGP/commit/0cb5a7d))
|
||||||
|
* fix: update bird build script and Dockerfile to include readline dependency ([35ed79b](http://192.168.100.56:3000/denozord/EvoBGP/commit/35ed79b))
|
||||||
|
* fix: update bird build script and Dockerfile to remove readline dependency ([fefadac](http://192.168.100.56:3000/denozord/EvoBGP/commit/fefadac))
|
||||||
|
* fix: update deployment configuration for Traefik and secure Web UI ([00bd037](http://192.168.100.56:3000/denozord/EvoBGP/commit/00bd037))
|
||||||
|
* fix: update Docker build commands in CI configuration and documentation ([b5061a5](http://192.168.100.56:3000/denozord/EvoBGP/commit/b5061a5))
|
||||||
|
* fix(httpapi): phase 1 compliance — ERR-01, SEC-04, CDN client, scheduler docs ([1dd7135](http://192.168.100.56:3000/denozord/EvoBGP/commit/1dd7135))
|
||||||
|
* test: add CDN prefetch tests for 304 responses and snapshot handling ([a536a2d](http://192.168.100.56:3000/denozord/EvoBGP/commit/a536a2d))
|
||||||
|
* test: add validation for 'strict bind on' in BGP rendering tests. Enhance BGP test cases to ensure ' ([dc5e20e](http://192.168.100.56:3000/denozord/EvoBGP/commit/dc5e20e))
|
||||||
|
* docker: refactor cache handling in Docker bake configuration ([5e59a1c](http://192.168.100.56:3000/denozord/EvoBGP/commit/5e59a1c))
|
||||||
|
* docker: simplify web image targets in Docker bake configuration ([765a805](http://192.168.100.56:3000/denozord/EvoBGP/commit/765a805))
|
||||||
|
* docker: update web image build process and README ([92f6de8](http://192.168.100.56:3000/denozord/EvoBGP/commit/92f6de8))
|
||||||
|
* web(ui): app layout — sidebar, mobile sheet, topbar, PageHeader ([2d78c81](http://192.168.100.56:3000/denozord/EvoBGP/commit/2d78c81))
|
||||||
|
* web(ui): AppDataTable pattern and modules pilot ([b992301](http://192.168.100.56:3000/denozord/EvoBGP/commit/b992301))
|
||||||
|
* web(ui): design tokens — spacing and component scale ([ac635e9](http://192.168.100.56:3000/denozord/EvoBGP/commit/ac635e9))
|
||||||
|
* web(ui): feedback — confirm dialog, skeletons, README ([87309cf](http://192.168.100.56:3000/denozord/EvoBGP/commit/87309cf))
|
||||||
|
* web(ui): form patterns — FormField, formsnap, directories pilot ([77a2cfd](http://192.168.100.56:3000/denozord/EvoBGP/commit/77a2cfd))
|
||||||
|
* web(ui): foundation — ui/core layout, shadcn primitives, notify ([00a2a44](http://192.168.100.56:3000/denozord/EvoBGP/commit/00a2a44))
|
||||||
|
* web(ui): migrate dashboard, schedule, monitoring to ui patterns ([57dcf30](http://192.168.100.56:3000/denozord/EvoBGP/commit/57dcf30))
|
||||||
|
* web(ui): migrate network to ui patterns ([3a3056b](http://192.168.100.56:3000/denozord/EvoBGP/commit/3a3056b))
|
||||||
|
* web(ui): migrate operations and module detail to ui patterns ([1f9930d](http://192.168.100.56:3000/denozord/EvoBGP/commit/1f9930d))
|
||||||
|
* web(ui): migrate settings to ui patterns ([9808aca](http://192.168.100.56:3000/denozord/EvoBGP/commit/9808aca))
|
||||||
|
* Enhance EvoBGP architecture plan by refining the overview, updating todos with detailed descriptions ([30efdbf](http://192.168.100.56:3000/denozord/EvoBGP/commit/30efdbf))
|
||||||
|
* init ([cbf3aaa](http://192.168.100.56:3000/denozord/EvoBGP/commit/cbf3aaa))
|
||||||
|
* Refine EvoBGP architecture plan with updated database schema details, improved descriptions for comp ([431ef3e](http://192.168.100.56:3000/denozord/EvoBGP/commit/431ef3e))
|
||||||
|
* Update EvoBGP architecture plan with new overview and todos. Refined descriptions for control-plane ([31ff5bb](http://192.168.100.56:3000/denozord/EvoBGP/commit/31ff5bb))
|
||||||
@@ -44,4 +44,12 @@ docker compose --profile reference up -d
|
|||||||
- **Go:** модуль `evobgp`, точки входа в `cmd/`.
|
- **Go:** модуль `evobgp`, точки входа в `cmd/`.
|
||||||
- **Веб:** SvelteKit в каталоге `web/` (см. [web/README.md](web/README.md)).
|
- **Веб:** SvelteKit в каталоге `web/` (см. [web/README.md](web/README.md)).
|
||||||
|
|
||||||
|
## Версионирование и релизы
|
||||||
|
|
||||||
|
Версии определяются автоматически из Conventional Commits при merge в `main` (semantic-release на Gitea Actions). Первая версия — **1.0.0**; ручной bump не нужен.
|
||||||
|
|
||||||
|
- [docs/releasing.md](docs/releasing.md) — пайплайн, commit conventions, секреты CI
|
||||||
|
- API: `GET /version` и `GET /v1/version` (поле `version`)
|
||||||
|
- Docker-образы: теги `latest`, `vX.Y.Z`, `X.Y.Z` — в **том же CI run**, что и релиз (job `release`)
|
||||||
|
|
||||||
Лицензия и условия использования — по политике владельца репозитория.
|
Лицензия и условия использования — по политике владельца репозитория.
|
||||||
@@ -19,6 +19,7 @@ import (
|
|||||||
"evobgp/internal/platform"
|
"evobgp/internal/platform"
|
||||||
"evobgp/internal/render"
|
"evobgp/internal/render"
|
||||||
"evobgp/internal/scheduler"
|
"evobgp/internal/scheduler"
|
||||||
|
"evobgp/internal/version"
|
||||||
)
|
)
|
||||||
|
|
||||||
// microVPS entrypoint: один процесс — HTTP API и фоновые воркеры scheduler, ingest, render, deploy (общий store и jobs.Registry).
|
// microVPS entrypoint: один процесс — HTTP API и фоновые воркеры scheduler, ingest, render, deploy (общий store и jobs.Registry).
|
||||||
@@ -37,7 +38,7 @@ func main() {
|
|||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
observability.SetBuildInfo("0.1.0", strings.TrimSpace(os.Getenv("EVOBGP_GIT_SHA")))
|
observability.SetBuildInfo(version.Version, version.GitSHA)
|
||||||
|
|
||||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||||
defer stop()
|
defer stop()
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
"evobgp/internal/httpapi"
|
"evobgp/internal/httpapi"
|
||||||
"evobgp/internal/observability"
|
"evobgp/internal/observability"
|
||||||
"evobgp/internal/platform"
|
"evobgp/internal/platform"
|
||||||
|
"evobgp/internal/version"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -34,7 +35,7 @@ func main() {
|
|||||||
}
|
}
|
||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
|
|
||||||
observability.SetBuildInfo("0.1.0", strings.TrimSpace(os.Getenv("EVOBGP_GIT_SHA")))
|
observability.SetBuildInfo(version.Version, version.GitSHA)
|
||||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||||
defer stop()
|
defer stop()
|
||||||
|
|
||||||
|
|||||||
+3
-228
@@ -1,240 +1,15 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"crypto/ed25519"
|
|
||||||
"encoding/base64"
|
|
||||||
"encoding/json"
|
|
||||||
"flag"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"evobgp/internal/birdfmt"
|
"evobgp/internal/nodecli"
|
||||||
"evobgp/internal/bundle"
|
|
||||||
"evobgp/internal/signing"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
if len(os.Args) < 2 {
|
if len(os.Args) < 2 {
|
||||||
usage()
|
nodecli.Usage(os.Stderr)
|
||||||
os.Exit(2)
|
os.Exit(2)
|
||||||
}
|
}
|
||||||
switch os.Args[1] {
|
os.Exit(nodecli.Run(os.Args[1:]))
|
||||||
case "pull-bundle":
|
|
||||||
os.Exit(cmdPullBundle(os.Args[2:]))
|
|
||||||
case "verify-bundle":
|
|
||||||
os.Exit(cmdVerifyBundle(os.Args[2:]))
|
|
||||||
case "apply-bundle":
|
|
||||||
os.Exit(cmdApplyBundle(os.Args[2:]))
|
|
||||||
default:
|
|
||||||
usage()
|
|
||||||
os.Exit(2)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func usage() {
|
|
||||||
fmt.Fprintf(os.Stderr, `Usage:
|
|
||||||
%s pull-bundle -base-url URL -token TOKEN -speaker-id ID [-revision-id ID] [-o path]
|
|
||||||
%s verify-bundle -f bundle.tar.gz (-pubkey-base64 B64 | -pubkey-hex HEX)
|
|
||||||
%s apply-bundle -f bundle.tar.gz -extract-dir DIR (-pubkey-base64 B64 | -pubkey-hex HEX)
|
|
||||||
[-bird PATH] [-birdc PATH] [-socket PATH] [-timeout DURATION]
|
|
||||||
|
|
||||||
apply-bundle verifies, extracts, runs bird -p on main bird.conf, then birdc configure.
|
|
||||||
`, os.Args[0], os.Args[0], os.Args[0])
|
|
||||||
}
|
|
||||||
|
|
||||||
func cmdPullBundle(args []string) int {
|
|
||||||
fs := flag.NewFlagSet("pull-bundle", flag.ExitOnError)
|
|
||||||
base := fs.String("base-url", "", "control plane base URL, e.g. http://localhost:8080")
|
|
||||||
token := fs.String("token", "", "Bearer token (node role)")
|
|
||||||
speaker := fs.String("speaker-id", "", "bgp_speaker id")
|
|
||||||
revision := fs.String("revision-id", "", "revision to fetch (empty = latest pointer)")
|
|
||||||
out := fs.String("o", "bundle.tar.gz", "output file")
|
|
||||||
_ = fs.Parse(args)
|
|
||||||
|
|
||||||
if strings.TrimSpace(*base) == "" || *token == "" || *speaker == "" {
|
|
||||||
fmt.Fprintln(os.Stderr, "pull-bundle: -base-url, -token, -speaker-id are required")
|
|
||||||
return 2
|
|
||||||
}
|
|
||||||
rev := strings.TrimSpace(*revision)
|
|
||||||
if rev == "" {
|
|
||||||
var err error
|
|
||||||
rev, err = fetchLatestRevision(*base, *token, *speaker)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintln(os.Stderr, err)
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
body, err := fetchBundle(*base, *token, *speaker, rev)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintln(os.Stderr, err)
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
if err := os.WriteFile(*out, body, 0o644); err != nil {
|
|
||||||
fmt.Fprintln(os.Stderr, err)
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
fmt.Fprintf(os.Stderr, "wrote %s (revision %s)\n", *out, rev)
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
func fetchLatestRevision(base, token, speaker string) (string, error) {
|
|
||||||
u := strings.TrimRight(base, "/") + "/v1/speakers/" + speaker + "/revisions/latest"
|
|
||||||
req, err := http.NewRequest(http.MethodGet, u, nil)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
req.Header.Set("Authorization", "Bearer "+token)
|
|
||||||
resp, err := http.DefaultClient.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
defer 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)))
|
|
||||||
}
|
|
||||||
var out struct {
|
|
||||||
RevisionID string `json:"revision_id"`
|
|
||||||
}
|
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
if out.RevisionID == "" {
|
|
||||||
return "", fmt.Errorf("empty revision_id in response")
|
|
||||||
}
|
|
||||||
return out.RevisionID, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func fetchBundle(base, token, speaker, revision string) ([]byte, error) {
|
|
||||||
u := strings.TrimRight(base, "/") + "/v1/speakers/" + speaker + "/bundle/" + revision
|
|
||||||
req, err := http.NewRequest(http.MethodGet, u, nil)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
req.Header.Set("Authorization", "Bearer "+token)
|
|
||||||
resp, err := http.DefaultClient.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer 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)))
|
|
||||||
}
|
|
||||||
return io.ReadAll(resp.Body)
|
|
||||||
}
|
|
||||||
|
|
||||||
func loadPubKey(pubB64, pubHex string) (ed25519.PublicKey, error) {
|
|
||||||
switch {
|
|
||||||
case strings.TrimSpace(pubB64) != "":
|
|
||||||
raw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(pubB64))
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if len(raw) != ed25519.PublicKeySize {
|
|
||||||
return nil, fmt.Errorf("pubkey-base64 must decode to %d bytes", ed25519.PublicKeySize)
|
|
||||||
}
|
|
||||||
return ed25519.PublicKey(raw), nil
|
|
||||||
case strings.TrimSpace(pubHex) != "":
|
|
||||||
return bundle.ParsePublicKeyHex(pubHex)
|
|
||||||
default:
|
|
||||||
return nil, fmt.Errorf("public key required")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func cmdVerifyBundle(args []string) int {
|
|
||||||
fs := flag.NewFlagSet("verify-bundle", flag.ExitOnError)
|
|
||||||
path := fs.String("f", "", "path to bundle.tar.gz")
|
|
||||||
pubB64 := fs.String("pubkey-base64", "", "Ed25519 public key (base64)")
|
|
||||||
pubHex := fs.String("pubkey-hex", "", "Ed25519 public key (64 hex chars)")
|
|
||||||
_ = fs.Parse(args)
|
|
||||||
|
|
||||||
if *path == "" {
|
|
||||||
fmt.Fprintln(os.Stderr, "verify-bundle: -f required")
|
|
||||||
return 2
|
|
||||||
}
|
|
||||||
pub, err := loadPubKey(*pubB64, *pubHex)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintln(os.Stderr, err)
|
|
||||||
return 2
|
|
||||||
}
|
|
||||||
raw, err := os.ReadFile(*path)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintln(os.Stderr, err)
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
v, err := signing.VerifyGzippedTar(raw, pub)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintln(os.Stderr, err)
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
fmt.Fprintf(os.Stderr, "ok: revision %s, %d payload files\n", v.Manifest.RevisionID, len(v.Files))
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
func cmdApplyBundle(args []string) int {
|
|
||||||
fs := flag.NewFlagSet("apply-bundle", flag.ExitOnError)
|
|
||||||
path := fs.String("f", "", "path to bundle.tar.gz")
|
|
||||||
dir := fs.String("extract-dir", "", "directory to extract into")
|
|
||||||
pubB64 := fs.String("pubkey-base64", "", "Ed25519 public key (base64)")
|
|
||||||
pubHex := fs.String("pubkey-hex", "", "Ed25519 public key (64 hex chars)")
|
|
||||||
bird := fs.String("bird", "", "bird binary (default PATH)")
|
|
||||||
birdc := fs.String("birdc", "", "birdc binary (default PATH)")
|
|
||||||
socket := fs.String("socket", "", "birdc -s socket path")
|
|
||||||
timeout := fs.Duration("timeout", 30*time.Second, "timeout for bird/birdc")
|
|
||||||
_ = fs.Parse(args)
|
|
||||||
|
|
||||||
if *path == "" || *dir == "" {
|
|
||||||
fmt.Fprintln(os.Stderr, "apply-bundle: -f and -extract-dir required")
|
|
||||||
return 2
|
|
||||||
}
|
|
||||||
pub, err := loadPubKey(*pubB64, *pubHex)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintln(os.Stderr, err)
|
|
||||||
return 2
|
|
||||||
}
|
|
||||||
raw, err := os.ReadFile(*path)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintln(os.Stderr, err)
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
v, err := signing.VerifyGzippedTar(raw, pub)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintln(os.Stderr, err)
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
root := filepath.Clean(*dir)
|
|
||||||
if err := os.MkdirAll(root, 0o755); err != nil {
|
|
||||||
fmt.Fprintln(os.Stderr, err)
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
if err := bundle.WriteExtractedFiles(root, v); err != nil {
|
|
||||||
fmt.Fprintln(os.Stderr, err)
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
mainRel := v.FindMainBirdConf()
|
|
||||||
if mainRel == "" {
|
|
||||||
fmt.Fprintln(os.Stderr, "bundle has no bird.conf path in manifest")
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
mainPath := filepath.Join(root, filepath.FromSlash(strings.TrimPrefix(mainRel, "/")))
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), *timeout)
|
|
||||||
defer cancel()
|
|
||||||
ctl := &birdfmt.BirdCtl{Bird: *bird, Birdc: *birdc, Socket: *socket}
|
|
||||||
if err := ctl.ParseCheck(ctx, mainPath); err != nil {
|
|
||||||
fmt.Fprintln(os.Stderr, err)
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
if err := ctl.Configure(ctx); err != nil {
|
|
||||||
fmt.Fprintln(os.Stderr, err)
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
fmt.Fprintf(os.Stderr, "applied revision %s (main config %s)\n", v.Manifest.RevisionID, mainPath)
|
|
||||||
return 0
|
|
||||||
}
|
}
|
||||||
@@ -41,6 +41,9 @@ func main() {
|
|||||||
|
|
||||||
apiBase := strings.TrimSpace(os.Getenv("EVOBGP_CONTROL_PLANE_URL"))
|
apiBase := strings.TrimSpace(os.Getenv("EVOBGP_CONTROL_PLANE_URL"))
|
||||||
apiTok := strings.TrimSpace(os.Getenv("EVOBGP_SCHEDULER_BEARER"))
|
apiTok := strings.TrimSpace(os.Getenv("EVOBGP_SCHEDULER_BEARER"))
|
||||||
|
if os.Getenv("EVOBGP_SCHEDULER_STANDALONE") == "1" && apiBase == "" {
|
||||||
|
log.Fatal("evobgp-scheduler: EVOBGP_SCHEDULER_STANDALONE=1 requires EVOBGP_CONTROL_PLANE_URL and EVOBGP_SCHEDULER_BEARER (split deploy must not use in-process jobs.Registry)")
|
||||||
|
}
|
||||||
deps := &scheduler.Deps{
|
deps := &scheduler.Deps{
|
||||||
Store: st,
|
Store: st,
|
||||||
HTTP: &http.Client{Timeout: 45 * time.Second},
|
HTTP: &http.Client{Timeout: 45 * time.Second},
|
||||||
|
|||||||
@@ -126,6 +126,8 @@ services:
|
|||||||
condition: service_started
|
condition: service_started
|
||||||
environment:
|
environment:
|
||||||
<<: *env-ref
|
<<: *env-ref
|
||||||
|
# Split deploy: scheduler must enqueue jobs via API, not in-process Registry (ARCH-04).
|
||||||
|
EVOBGP_SCHEDULER_STANDALONE: "1"
|
||||||
EVOBGP_CONTROL_PLANE_URL: http://evobgp-api:8080
|
EVOBGP_CONTROL_PLANE_URL: http://evobgp-api:8080
|
||||||
EVOBGP_SCHEDULER_BEARER: dev
|
EVOBGP_SCHEDULER_BEARER: dev
|
||||||
logging: *default-logging
|
logging: *default-logging
|
||||||
|
|||||||
+14
-4
@@ -2,22 +2,30 @@
|
|||||||
|
|
||||||
## CI (Gitea Actions)
|
## CI (Gitea Actions)
|
||||||
|
|
||||||
Сборка образов — **`docker buildx bake`** (`deploy/docker/docker-bake.hcl`), а не матрица из восьми отдельных `docker build`.
|
Сборка образов — **`docker buildx bake`** (`deploy/docker/docker-bake.hcl`), не отдельные `docker build`.
|
||||||
|
|
||||||
|
**Publish:** push в `main` после quality gates — job **release** в [.gitea/workflows/ci.yaml](../.gitea/workflows/ci.yaml): semantic-release + bake с `VERSION` из релиза.
|
||||||
|
|
||||||
|
Локально BuildKit также кэширует `/go/pkg/mod` и `~/.cache/go-build` через `RUN --mount=type=cache`.
|
||||||
|
|
||||||
| Было | Стало |
|
| Было | Стало |
|
||||||
|------|--------|
|
|------|--------|
|
||||||
| 8× `go mod download` + 8× `go build` (разные BIN) | 1× download + 1× компиляция всех `cmd/*` |
|
| 8× `go mod download` + 8× `go build` (разные BIN) | 1× download + 1× компиляция всех `cmd/*` |
|
||||||
| 2× сборка BIRD из исходников (api, all) | 1× stage `birdc`, копируется в runtime |
|
| 2× сборка BIRD из исходников (api, all) | 1× stage `birdc`, копируется в runtime |
|
||||||
| 2× `npm ci` (web, web-all) | 1× `web-deps` + два nginx-образа |
|
| 2× `npm ci` (web, web-all) | 1× `web-deps` + 1× `web-build` + два nginx-образа (общий артефакт) |
|
||||||
| 8 runner'ов с checkout/login | 1 job `docker-go`, 1 job `docker-web` |
|
| 8 runner'ов с checkout/login | 1 job `docker-go`, 1 job `docker-web` |
|
||||||
|
|
||||||
Кэш registry (переменные bake):
|
Кэш registry (переменные bake):
|
||||||
|
|
||||||
- `git.shts.su/<owner>/evobgp-buildcache:go-buildcache`
|
- `git.shts.su/<owner>/evobgp-buildcache:go-buildcache` — **запись** только из target `go-build-all`
|
||||||
- `git.shts.su/<owner>/evobgp-buildcache:web-buildcache`
|
- `git.shts.su/<owner>/evobgp-buildcache:web-buildcache` — **запись** только из target `web-build`
|
||||||
|
|
||||||
|
Остальные bake-target’ы только `cache-from` (чтение). Параллельный `cache-to` в один ref ломает manifest в registry (`content descriptor … not found`).
|
||||||
|
|
||||||
Локально BuildKit также кэширует `/go/pkg/mod` и `~/.cache/go-build` через `RUN --mount=type=cache`.
|
Локально BuildKit также кэширует `/go/pkg/mod` и `~/.cache/go-build` через `RUN --mount=type=cache`.
|
||||||
|
|
||||||
|
Если CI падает на «not found» после смены схемы кэша — один раз удалите теги `evobgp-buildcache:go-buildcache` и `:web-buildcache` в registry и пересоберите.
|
||||||
|
|
||||||
## Локальная сборка
|
## Локальная сборка
|
||||||
|
|
||||||
Из корня репозитория:
|
Из корня репозитория:
|
||||||
@@ -27,6 +35,8 @@ cd deploy/docker
|
|||||||
export REGISTRY=git.shts.su/<owner>
|
export REGISTRY=git.shts.su/<owner>
|
||||||
export IMAGE_TAG=latest
|
export IMAGE_TAG=latest
|
||||||
export SHORT_SHA=$(git rev-parse --short HEAD)
|
export SHORT_SHA=$(git rev-parse --short HEAD)
|
||||||
|
export VERSION=dev
|
||||||
|
export BUILD_TIME=
|
||||||
sh write-bake-override.sh
|
sh write-bake-override.sh
|
||||||
docker buildx bake --allow=fs.read=../.. -f docker-bake.hcl -f docker-bake.override.hcl go-images
|
docker buildx bake --allow=fs.read=../.. -f docker-bake.hcl -f docker-bake.override.hcl go-images
|
||||||
docker buildx bake --allow=fs.read=../.. -f docker-bake.hcl -f docker-bake.override.hcl web-images
|
docker buildx bake --allow=fs.read=../.. -f docker-bake.hcl -f docker-bake.override.hcl web-images
|
||||||
|
|||||||
@@ -19,6 +19,14 @@ variable "SHA_FULL" {
|
|||||||
default = ""
|
default = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
variable "VERSION" {
|
||||||
|
default = "dev"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "BUILD_TIME" {
|
||||||
|
default = ""
|
||||||
|
}
|
||||||
|
|
||||||
variable "CACHE_REF_GO" {
|
variable "CACHE_REF_GO" {
|
||||||
default = ""
|
default = ""
|
||||||
}
|
}
|
||||||
@@ -32,7 +40,10 @@ function "go-cache-from" {
|
|||||||
result = notequal("", CACHE_REF_GO) ? ["type=registry,ref=${CACHE_REF_GO}"] : []
|
result = notequal("", CACHE_REF_GO) ? ["type=registry,ref=${CACHE_REF_GO}"] : []
|
||||||
}
|
}
|
||||||
|
|
||||||
function "go-cache-to" {
|
# Экспорт кэша — только go-build-all / web-build (один writer на ref).
|
||||||
|
# Несколько target с cache-to в один ref и mode=max дают гонку в registry
|
||||||
|
# (content descriptor not found при параллельном bake).
|
||||||
|
function "go-cache-to-export" {
|
||||||
params = []
|
params = []
|
||||||
result = notequal("", CACHE_REF_GO) ? ["type=registry,ref=${CACHE_REF_GO},mode=max"] : []
|
result = notequal("", CACHE_REF_GO) ? ["type=registry,ref=${CACHE_REF_GO},mode=max"] : []
|
||||||
}
|
}
|
||||||
@@ -42,7 +53,7 @@ function "web-cache-from" {
|
|||||||
result = notequal("", CACHE_REF_WEB) ? ["type=registry,ref=${CACHE_REF_WEB}"] : []
|
result = notequal("", CACHE_REF_WEB) ? ["type=registry,ref=${CACHE_REF_WEB}"] : []
|
||||||
}
|
}
|
||||||
|
|
||||||
function "web-cache-to" {
|
function "web-cache-to-export" {
|
||||||
params = []
|
params = []
|
||||||
result = notequal("", CACHE_REF_WEB) ? ["type=registry,ref=${CACHE_REF_WEB},mode=max"] : []
|
result = notequal("", CACHE_REF_WEB) ? ["type=registry,ref=${CACHE_REF_WEB},mode=max"] : []
|
||||||
}
|
}
|
||||||
@@ -64,6 +75,7 @@ group "go-images" {
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# web-deps / web-build — только зависимости (contexts), без tags; при --push в группе не указывать.
|
||||||
group "web-images" {
|
group "web-images" {
|
||||||
targets = ["evobgp-web", "evobgp-web-all"]
|
targets = ["evobgp-web", "evobgp-web-all"]
|
||||||
}
|
}
|
||||||
@@ -76,7 +88,6 @@ target "go-deps" {
|
|||||||
target = "deps"
|
target = "deps"
|
||||||
platforms = ["linux/amd64"]
|
platforms = ["linux/amd64"]
|
||||||
cache-from = go-cache-from()
|
cache-from = go-cache-from()
|
||||||
cache-to = go-cache-to()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
target "go-build-all" {
|
target "go-build-all" {
|
||||||
@@ -84,11 +95,16 @@ target "go-build-all" {
|
|||||||
dockerfile = "deploy/docker/gobinary/Dockerfile"
|
dockerfile = "deploy/docker/gobinary/Dockerfile"
|
||||||
target = "build-all"
|
target = "build-all"
|
||||||
platforms = ["linux/amd64"]
|
platforms = ["linux/amd64"]
|
||||||
|
args = {
|
||||||
|
VERSION = VERSION
|
||||||
|
GIT_SHA = notequal("", SHA_FULL) ? SHA_FULL : SHORT_SHA
|
||||||
|
BUILD_TIME = BUILD_TIME
|
||||||
|
}
|
||||||
contexts = {
|
contexts = {
|
||||||
deps = "target:go-deps"
|
deps = "target:go-deps"
|
||||||
}
|
}
|
||||||
cache-from = go-cache-from()
|
cache-from = go-cache-from()
|
||||||
cache-to = go-cache-to()
|
cache-to = go-cache-to-export()
|
||||||
}
|
}
|
||||||
|
|
||||||
target "go-birdc" {
|
target "go-birdc" {
|
||||||
@@ -97,7 +113,6 @@ target "go-birdc" {
|
|||||||
target = "birdc"
|
target = "birdc"
|
||||||
platforms = ["linux/amd64"]
|
platforms = ["linux/amd64"]
|
||||||
cache-from = go-cache-from()
|
cache-from = go-cache-from()
|
||||||
cache-to = go-cache-to()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
target "_go-runtime" {
|
target "_go-runtime" {
|
||||||
@@ -109,7 +124,6 @@ target "_go-runtime" {
|
|||||||
build-all = "target:go-build-all"
|
build-all = "target:go-build-all"
|
||||||
}
|
}
|
||||||
cache-from = go-cache-from()
|
cache-from = go-cache-from()
|
||||||
cache-to = go-cache-to()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
target "_go-runtime-birdc" {
|
target "_go-runtime-birdc" {
|
||||||
@@ -122,19 +136,24 @@ target "_go-runtime-birdc" {
|
|||||||
birdc = "target:go-birdc"
|
birdc = "target:go-birdc"
|
||||||
}
|
}
|
||||||
cache-from = go-cache-from()
|
cache-from = go-cache-from()
|
||||||
cache-to = go-cache-to()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function "image-tags" {
|
function "image-tags" {
|
||||||
params = [name]
|
params = [name]
|
||||||
result = notequal("", SHA_FULL) ? [
|
result = concat(
|
||||||
|
notequal("", SHA_FULL) ? [
|
||||||
"${REGISTRY}/${name}:${IMAGE_TAG}",
|
"${REGISTRY}/${name}:${IMAGE_TAG}",
|
||||||
"${REGISTRY}/${name}:${SHORT_SHA}",
|
"${REGISTRY}/${name}:${SHORT_SHA}",
|
||||||
"${REGISTRY}/${name}:sha-${SHA_FULL}",
|
"${REGISTRY}/${name}:sha-${SHA_FULL}",
|
||||||
] : [
|
] : [
|
||||||
"${REGISTRY}/${name}:${IMAGE_TAG}",
|
"${REGISTRY}/${name}:${IMAGE_TAG}",
|
||||||
"${REGISTRY}/${name}:${SHORT_SHA}",
|
"${REGISTRY}/${name}:${SHORT_SHA}",
|
||||||
]
|
],
|
||||||
|
notequal(VERSION, "dev") && notequal(VERSION, "") ? [
|
||||||
|
"${REGISTRY}/${name}:v${VERSION}",
|
||||||
|
"${REGISTRY}/${name}:${VERSION}",
|
||||||
|
] : []
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
target "evobgp-api" {
|
target "evobgp-api" {
|
||||||
@@ -187,7 +206,6 @@ target "evobgp-agent" {
|
|||||||
build-all = "target:go-build-all"
|
build-all = "target:go-build-all"
|
||||||
}
|
}
|
||||||
cache-from = go-cache-from()
|
cache-from = go-cache-from()
|
||||||
cache-to = go-cache-to()
|
|
||||||
tags = image-tags("evobgp-agent")
|
tags = image-tags("evobgp-agent")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -199,25 +217,43 @@ target "web-deps" {
|
|||||||
target = "deps"
|
target = "deps"
|
||||||
platforms = ["linux/amd64"]
|
platforms = ["linux/amd64"]
|
||||||
cache-from = web-cache-from()
|
cache-from = web-cache-from()
|
||||||
cache-to = web-cache-to()
|
}
|
||||||
|
|
||||||
|
target "web-build" {
|
||||||
|
context = "../.."
|
||||||
|
dockerfile = "deploy/docker/evobgp-web/Dockerfile"
|
||||||
|
target = "build"
|
||||||
|
platforms = ["linux/amd64"]
|
||||||
|
contexts = {
|
||||||
|
deps = "target:web-deps"
|
||||||
|
}
|
||||||
|
cache-from = web-cache-from()
|
||||||
|
cache-to = web-cache-to-export()
|
||||||
}
|
}
|
||||||
|
|
||||||
target "evobgp-web" {
|
target "evobgp-web" {
|
||||||
context = "../.."
|
context = "../.."
|
||||||
dockerfile = "deploy/docker/evobgp-web/Dockerfile"
|
dockerfile = "deploy/docker/evobgp-web/Dockerfile"
|
||||||
|
target = "web"
|
||||||
platforms = ["linux/amd64"]
|
platforms = ["linux/amd64"]
|
||||||
contexts = {
|
contexts = {
|
||||||
deps = "target:web-deps"
|
web-artifacts = "target:web-build"
|
||||||
}
|
}
|
||||||
args = { EVOBGP_UPSTREAM = "evobgp-api" }
|
args = { EVOBGP_UPSTREAM = "evobgp-api" }
|
||||||
cache-from = web-cache-from()
|
cache-from = web-cache-from()
|
||||||
cache-to = web-cache-to()
|
|
||||||
tags = image-tags("evobgp-web")
|
tags = image-tags("evobgp-web")
|
||||||
}
|
}
|
||||||
|
|
||||||
target "evobgp-web-all" {
|
target "evobgp-web-all" {
|
||||||
inherits = ["evobgp-web"]
|
context = "../.."
|
||||||
|
dockerfile = "deploy/docker/evobgp-web/Dockerfile"
|
||||||
|
target = "web"
|
||||||
|
platforms = ["linux/amd64"]
|
||||||
|
contexts = {
|
||||||
|
web-artifacts = "target:web-build"
|
||||||
|
}
|
||||||
args = { EVOBGP_UPSTREAM = "evobgp-all" }
|
args = { EVOBGP_UPSTREAM = "evobgp-all" }
|
||||||
|
cache-from = web-cache-from()
|
||||||
tags = image-tags("evobgp-web-all")
|
tags = image-tags("evobgp-web-all")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
# syntax=docker/dockerfile:1.7
|
# syntax=docker/dockerfile:1.7
|
||||||
# Статическая панель EvoBGP (SvelteKit) + nginx.
|
# Статическая панель EvoBGP (SvelteKit) + nginx.
|
||||||
|
# Финальный stage `web` ожидает bake-контекст web-artifacts (= target:web-build).
|
||||||
FROM public.ecr.aws/docker/library/node:22-alpine AS deps
|
FROM public.ecr.aws/docker/library/node:22-alpine AS deps
|
||||||
WORKDIR /web
|
WORKDIR /web
|
||||||
COPY web/package.json web/package-lock.json ./
|
COPY web/package.json web/package-lock.json ./
|
||||||
@@ -10,9 +11,9 @@ FROM deps AS build
|
|||||||
COPY web/ ./
|
COPY web/ ./
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
FROM public.ecr.aws/docker/library/nginx:1.27-alpine
|
FROM public.ecr.aws/docker/library/nginx:1.27-alpine AS web
|
||||||
ARG EVOBGP_UPSTREAM=evobgp-api
|
ARG EVOBGP_UPSTREAM=evobgp-api
|
||||||
COPY deploy/docker/evobgp-web/nginx.conf /tmp/nginx-default.conf
|
COPY deploy/docker/evobgp-web/nginx.conf /tmp/nginx-default.conf
|
||||||
RUN sed -e "s/evobgp-api/${EVOBGP_UPSTREAM}/g" /tmp/nginx-default.conf > /etc/nginx/conf.d/default.conf \
|
RUN sed -e "s/evobgp-api/${EVOBGP_UPSTREAM}/g" /tmp/nginx-default.conf > /etc/nginx/conf.d/default.conf \
|
||||||
&& rm -f /tmp/nginx-default.conf
|
&& rm -f /tmp/nginx-default.conf
|
||||||
COPY --from=build /web/build /usr/share/nginx/html
|
COPY --from=web-artifacts /web/build /usr/share/nginx/html
|
||||||
@@ -10,22 +10,38 @@ RUN --mount=type=cache,target=/go/pkg/mod,sharing=locked \
|
|||||||
|
|
||||||
FROM deps AS build-all
|
FROM deps AS build-all
|
||||||
COPY . .
|
COPY . .
|
||||||
|
ARG VERSION=dev
|
||||||
|
ARG GIT_SHA=unknown
|
||||||
|
ARG BUILD_TIME=
|
||||||
RUN --mount=type=cache,target=/go/pkg/mod,sharing=locked \
|
RUN --mount=type=cache,target=/go/pkg/mod,sharing=locked \
|
||||||
--mount=type=cache,target=/root/.cache/go-build,sharing=locked \
|
--mount=type=cache,target=/root/.cache/go-build,sharing=locked \
|
||||||
set -eux; \
|
set -eux; \
|
||||||
mkdir -p /out; \
|
mkdir -p /out; \
|
||||||
for d in cmd/*/; do \
|
for d in cmd/*/; do \
|
||||||
name="$(basename "$d")"; \
|
name="$(basename "$d")"; \
|
||||||
CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o "/out/${name}" "./cmd/${name}"; \
|
CGO_ENABLED=0 go build -trimpath \
|
||||||
|
-ldflags="-s -w \
|
||||||
|
-X evobgp/internal/version.Version=${VERSION} \
|
||||||
|
-X evobgp/internal/version.GitSHA=${GIT_SHA} \
|
||||||
|
-X evobgp/internal/version.BuildTime=${BUILD_TIME}" \
|
||||||
|
-o "/out/${name}" "./cmd/${name}"; \
|
||||||
done
|
done
|
||||||
|
|
||||||
# Один бинарь (локальная сборка); в CI — build-all + runtime.
|
# Один бинарь (локальная сборка); в CI — build-all + runtime.
|
||||||
FROM deps AS build
|
FROM deps AS build
|
||||||
COPY . .
|
COPY . .
|
||||||
ARG BIN=evobgp-api
|
ARG BIN=evobgp-api
|
||||||
|
ARG VERSION=dev
|
||||||
|
ARG GIT_SHA=unknown
|
||||||
|
ARG BUILD_TIME=
|
||||||
RUN --mount=type=cache,target=/go/pkg/mod,sharing=locked \
|
RUN --mount=type=cache,target=/go/pkg/mod,sharing=locked \
|
||||||
--mount=type=cache,target=/root/.cache/go-build,sharing=locked \
|
--mount=type=cache,target=/root/.cache/go-build,sharing=locked \
|
||||||
CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/evobgp "./cmd/${BIN}"
|
CGO_ENABLED=0 go build -trimpath \
|
||||||
|
-ldflags="-s -w \
|
||||||
|
-X evobgp/internal/version.Version=${VERSION} \
|
||||||
|
-X evobgp/internal/version.GitSHA=${GIT_SHA} \
|
||||||
|
-X evobgp/internal/version.BuildTime=${BUILD_TIME}" \
|
||||||
|
-o /out/evobgp "./cmd/${BIN}"
|
||||||
|
|
||||||
FROM public.ecr.aws/docker/library/debian:bookworm-slim AS birdc
|
FROM public.ecr.aws/docker/library/debian:bookworm-slim AS birdc
|
||||||
ARG BIRD_VERSION=2.14
|
ARG BIRD_VERSION=2.14
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ REGISTRY="${REGISTRY:?REGISTRY required}"
|
|||||||
IMAGE_TAG="${IMAGE_TAG:-latest}"
|
IMAGE_TAG="${IMAGE_TAG:-latest}"
|
||||||
SHORT_SHA="${SHORT_SHA:-dev}"
|
SHORT_SHA="${SHORT_SHA:-dev}"
|
||||||
SHA_FULL="${SHA_FULL:-}"
|
SHA_FULL="${SHA_FULL:-}"
|
||||||
|
VERSION="${VERSION:-dev}"
|
||||||
|
BUILD_TIME="${BUILD_TIME:-}"
|
||||||
CACHE_REF_GO="${CACHE_REF_GO:-}"
|
CACHE_REF_GO="${CACHE_REF_GO:-}"
|
||||||
CACHE_REF_WEB="${CACHE_REF_WEB:-}"
|
CACHE_REF_WEB="${CACHE_REF_WEB:-}"
|
||||||
cat >"$OUT" <<EOF
|
cat >"$OUT" <<EOF
|
||||||
@@ -22,6 +24,12 @@ variable "SHORT_SHA" {
|
|||||||
variable "SHA_FULL" {
|
variable "SHA_FULL" {
|
||||||
default = "${SHA_FULL}"
|
default = "${SHA_FULL}"
|
||||||
}
|
}
|
||||||
|
variable "VERSION" {
|
||||||
|
default = "${VERSION}"
|
||||||
|
}
|
||||||
|
variable "BUILD_TIME" {
|
||||||
|
default = "${BUILD_TIME}"
|
||||||
|
}
|
||||||
variable "CACHE_REF_GO" {
|
variable "CACHE_REF_GO" {
|
||||||
default = "${CACHE_REF_GO}"
|
default = "${CACHE_REF_GO}"
|
||||||
}
|
}
|
||||||
|
|||||||
+25
-1
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
## По роли читателя
|
## По роли читателя
|
||||||
|
|
||||||
- **ИИ-агент / ассистент в репозитории** — [../AGENTS.md](../AGENTS.md): с чего начать чтение, карта `internal/` и `cmd/`, что не тащить в контекст.
|
- **ИИ-агент / ассистент в репозитории** — [../AGENTS.md](../AGENTS.md): с чего начать чтение, карта `internal/` и `cmd/`, что не тащить в контекст. **Инженерные правила:** [../.cursor/rules/engineering.mdc](../.cursor/rules/engineering.mdc) (общие), [../.cursor/rules/web-shadcn.mdc](../.cursor/rules/web-shadcn.mdc) (Web UI), [../.cursor/rules/networking-bird.mdc](../.cursor/rules/networking-bird.mdc) (BIRD2/BGP/IP).
|
||||||
- **Оператор / DevOps** — [quickstart.md](quickstart.md), [architecture.md](architecture.md), [access.md](access.md), [deploy/compose/docker-compose.yaml](../deploy/compose/docker-compose.yaml).
|
- **Оператор / DevOps** — [quickstart.md](quickstart.md), [architecture.md](architecture.md), [access.md](access.md), [deploy/compose/docker-compose.yaml](../deploy/compose/docker-compose.yaml).
|
||||||
- **Разработчик бэкенда или интегратор API** — [api.md](api.md), [access.md](access.md), [openapi.yaml](openapi.yaml), исходники маршрутов в `internal/httpapi/`.
|
- **Разработчик бэкенда или интегратор API** — [api.md](api.md), [access.md](access.md), [openapi.yaml](openapi.yaml), исходники маршрутов в `internal/httpapi/`.
|
||||||
- **Разработчик фронтенда** — [quickstart.md](quickstart.md) (раздел про `web/` и CORS), [api.md](api.md), [../web/README.md](../web/README.md).
|
- **Разработчик фронтенда** — [quickstart.md](quickstart.md) (раздел про `web/` и CORS), [api.md](api.md), [../web/README.md](../web/README.md).
|
||||||
@@ -20,10 +20,34 @@
|
|||||||
| [api.md](api.md) | REST: префикс `/v1`, публичные маршруты, ссылки на OpenAPI |
|
| [api.md](api.md) | REST: префикс `/v1`, публичные маршруты, ссылки на OpenAPI |
|
||||||
| [router-lists-ui-integration.md](router-lists-ui-integration.md) | Интеграция `router-lists-ui` с EvoBGP API (`DOMAINS/IP_RANGES/AS_PREFIXES/communities`) |
|
| [router-lists-ui-integration.md](router-lists-ui-integration.md) | Интеграция `router-lists-ui` с EvoBGP API (`DOMAINS/IP_RANGES/AS_PREFIXES/communities`) |
|
||||||
| [access.md](access.md) | Выдача доступа: API-ключи, роли, нода, CORS |
|
| [access.md](access.md) | Выдача доступа: API-ключи, роли, нода, CORS |
|
||||||
|
| [releasing.md](releasing.md) | Автоматические релизы, Conventional Commits, CI |
|
||||||
| [openapi.yaml](openapi.yaml) | Источник правды по контракту API |
|
| [openapi.yaml](openapi.yaml) | Источник правды по контракту API |
|
||||||
| [OPENAPI-GITEA.md](OPENAPI-GITEA.md) | Как открыть HTML-документацию API (в т.ч. из Gitea) |
|
| [OPENAPI-GITEA.md](OPENAPI-GITEA.md) | Как открыть HTML-документацию API (в т.ч. из Gitea) |
|
||||||
| [evobgp-api-sketches.md](evobgp-api-sketches.md) | Ранний черновик идей API (контекст, не замена OpenAPI) |
|
| [evobgp-api-sketches.md](evobgp-api-sketches.md) | Ранний черновик идей API (контекст, не замена OpenAPI) |
|
||||||
|
|
||||||
|
## Инженерные правила (Cursor / разработка)
|
||||||
|
|
||||||
|
| Файл | Область |
|
||||||
|
|------|---------|
|
||||||
|
| [../.cursor/rules/engineering.mdc](../.cursor/rules/engineering.mdc) | Go, API, migrations, security, enforcement |
|
||||||
|
| [../.cursor/rules/web-shadcn.mdc](../.cursor/rules/web-shadcn.mdc) | SvelteKit, shadcn-svelte |
|
||||||
|
| [../.cursor/rules/networking-bird.mdc](../.cursor/rules/networking-bird.mdc) | BIRD2, BGP policy, IP/CIDR |
|
||||||
|
| [../.cursor/rules/conventional-commits.mdc](../.cursor/rules/conventional-commits.mdc) | Conventional Commits (заголовок EN, тело RU) |
|
||||||
|
|
||||||
|
### Сообщения коммитов (Cursor)
|
||||||
|
|
||||||
|
После `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
|
## Репозиторий и CI
|
||||||
|
|
||||||
- [../.gitea/README.md](../.gitea/README.md) — Gitea Actions, runner, сборка образов и push в Container Registry.
|
- [../.gitea/README.md](../.gitea/README.md) — Gitea Actions, runner, сборка образов и push в Container Registry.
|
||||||
|
- [releasing.md](releasing.md) — semantic-release, commit conventions, теги образов.
|
||||||
+74
-2
@@ -1,10 +1,12 @@
|
|||||||
openapi: 3.1.0
|
openapi: 3.1.0
|
||||||
info:
|
info:
|
||||||
title: EvoBGP Control Plane API
|
title: EvoBGP Control Plane API
|
||||||
version: 0.1.0
|
version: 1.0.0
|
||||||
description: |
|
description: |
|
||||||
REST API управления префиксами, модулями ingest, ревизиями конфигурации BIRD и задачами (async jobs).
|
REST API управления префиксами, модулями ingest, ревизиями конфигурации BIRD и задачами (async jobs).
|
||||||
|
|
||||||
|
**Актуальная semver-сборка:** `GET /version` или `GET /v1/version` (поле `version`; совпадает с git-тегом `vX.Y.Z`).
|
||||||
|
|
||||||
**Соглашения:** префикс `/v1`; идентификаторы - UUID v7 или ULID (строки); время - ISO 8601 UTC.
|
**Соглашения:** префикс `/v1`; идентификаторы - UUID v7 или ULID (строки); время - ISO 8601 UTC.
|
||||||
Ошибки - `application/problem+json` ([RFC 9457](https://www.rfc-editor.org/rfc/rfc9457)).
|
Ошибки - `application/problem+json` ([RFC 9457](https://www.rfc-editor.org/rfc/rfc9457)).
|
||||||
Пагинация списков - `cursor` + `limit`; ответ содержит `items`, `next_cursor`, `has_more`.
|
Пагинация списков - `cursor` + `limit`; ответ содержит `items`, `next_cursor`, `has_more`.
|
||||||
@@ -295,6 +297,18 @@ components:
|
|||||||
- DOMAINS
|
- DOMAINS
|
||||||
- IP_RANGES
|
- IP_RANGES
|
||||||
|
|
||||||
|
DohResolverPolicy:
|
||||||
|
type: string
|
||||||
|
description: |
|
||||||
|
Политика резолва доменов через DoH при нескольких профилях.
|
||||||
|
`primary_only` — только первый профиль; `failover` — по порядку до первого успешного;
|
||||||
|
`union` — объединение A/AAAA со всех профилей.
|
||||||
|
enum:
|
||||||
|
- primary_only
|
||||||
|
- failover
|
||||||
|
- union
|
||||||
|
default: primary_only
|
||||||
|
|
||||||
JobStatus:
|
JobStatus:
|
||||||
type: string
|
type: string
|
||||||
description: Статус задачи; перечень может расширяться.
|
description: Статус задачи; перечень может расширяться.
|
||||||
@@ -332,6 +346,14 @@ components:
|
|||||||
type: integer
|
type: integer
|
||||||
doh_profile_id:
|
doh_profile_id:
|
||||||
type: ["string", "null"]
|
type: ["string", "null"]
|
||||||
|
description: Первый DoH-профиль (legacy); предпочтительно `doh_profile_ids`.
|
||||||
|
doh_profile_ids:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: "#/components/schemas/ResourceId"
|
||||||
|
description: Упорядоченный список DoH-профилей для модулей `DOMAINS`.
|
||||||
|
doh_resolver_policy:
|
||||||
|
$ref: "#/components/schemas/DohResolverPolicy"
|
||||||
refresh_interval_sec:
|
refresh_interval_sec:
|
||||||
type: ["integer", "null"]
|
type: ["integer", "null"]
|
||||||
minimum: 0
|
minimum: 0
|
||||||
@@ -363,6 +385,14 @@ components:
|
|||||||
default: 0
|
default: 0
|
||||||
doh_profile_id:
|
doh_profile_id:
|
||||||
type: ["string", "null"]
|
type: ["string", "null"]
|
||||||
|
description: Первый DoH-профиль (legacy); предпочтительно `doh_profile_ids`.
|
||||||
|
doh_profile_ids:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: "#/components/schemas/ResourceId"
|
||||||
|
description: Упорядоченный список DoH-профилей для модулей `DOMAINS`.
|
||||||
|
doh_resolver_policy:
|
||||||
|
$ref: "#/components/schemas/DohResolverPolicy"
|
||||||
refresh_interval_sec:
|
refresh_interval_sec:
|
||||||
type: ["integer", "null"]
|
type: ["integer", "null"]
|
||||||
last_refreshed_at:
|
last_refreshed_at:
|
||||||
@@ -385,6 +415,14 @@ components:
|
|||||||
type: integer
|
type: integer
|
||||||
doh_profile_id:
|
doh_profile_id:
|
||||||
type: ["string", "null"]
|
type: ["string", "null"]
|
||||||
|
description: Первый DoH-профиль (legacy); предпочтительно `doh_profile_ids`.
|
||||||
|
doh_profile_ids:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: "#/components/schemas/ResourceId"
|
||||||
|
description: Упорядоченный список DoH-профилей для модулей `DOMAINS`.
|
||||||
|
doh_resolver_policy:
|
||||||
|
$ref: "#/components/schemas/DohResolverPolicy"
|
||||||
refresh_interval_sec:
|
refresh_interval_sec:
|
||||||
type: ["integer", "null"]
|
type: ["integer", "null"]
|
||||||
cron_expr:
|
cron_expr:
|
||||||
@@ -570,6 +608,9 @@ components:
|
|||||||
properties:
|
properties:
|
||||||
id:
|
id:
|
||||||
$ref: "#/components/schemas/ResourceId"
|
$ref: "#/components/schemas/ResourceId"
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
description: Отображаемое имя профиля (опционально).
|
||||||
url:
|
url:
|
||||||
type: string
|
type: string
|
||||||
format: uri
|
format: uri
|
||||||
@@ -585,6 +626,9 @@ components:
|
|||||||
required:
|
required:
|
||||||
- url
|
- url
|
||||||
properties:
|
properties:
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
description: Отображаемое имя профиля (опционально).
|
||||||
url:
|
url:
|
||||||
type: string
|
type: string
|
||||||
format: uri
|
format: uri
|
||||||
@@ -716,8 +760,14 @@ components:
|
|||||||
VersionInfo:
|
VersionInfo:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
|
version:
|
||||||
|
type: string
|
||||||
|
description: Semver сборки (git tag без префикса v).
|
||||||
|
example: "1.2.3"
|
||||||
api_version:
|
api_version:
|
||||||
type: string
|
type: string
|
||||||
|
deprecated: true
|
||||||
|
description: Alias поля `version` (сохранён для обратной совместимости).
|
||||||
git_sha:
|
git_sha:
|
||||||
type: string
|
type: string
|
||||||
build_time:
|
build_time:
|
||||||
@@ -804,6 +854,8 @@ components:
|
|||||||
DohProfilePatch:
|
DohProfilePatch:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
url:
|
url:
|
||||||
type: string
|
type: string
|
||||||
format: uri
|
format: uri
|
||||||
@@ -923,11 +975,31 @@ paths:
|
|||||||
default:
|
default:
|
||||||
$ref: "#/components/responses/DefaultProblem"
|
$ref: "#/components/responses/DefaultProblem"
|
||||||
|
|
||||||
|
/version:
|
||||||
|
get:
|
||||||
|
tags: [System]
|
||||||
|
summary: Версия сборки (корневой путь)
|
||||||
|
description: |
|
||||||
|
Аналог `GET /v1/version`. Публичный маршрут без аутентификации.
|
||||||
|
Semver в поле `version` задаётся при сборке Docker-образов из git-тега.
|
||||||
|
operationId: getVersionRoot
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Метаданные сборки.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/VersionInfo"
|
||||||
|
default:
|
||||||
|
$ref: "#/components/responses/DefaultProblem"
|
||||||
|
|
||||||
/v1/version:
|
/v1/version:
|
||||||
get:
|
get:
|
||||||
tags: [System]
|
tags: [System]
|
||||||
summary: Версия сборки
|
summary: Версия сборки
|
||||||
description: Версия API и control-plane (`git_sha`, `build_time` и др.).
|
description: |
|
||||||
|
Semver control-plane и метаданные сборки (`git_sha`, `build_time`).
|
||||||
|
Дублирует `GET /version`.
|
||||||
operationId: getVersion
|
operationId: getVersion
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
# Релизы и версионирование EvoBGP
|
||||||
|
|
||||||
|
EvoBGP использует [Conventional Commits](https://www.conventionalcommits.org/) и [semantic-release](https://semantic-release.gitbook.io/) для полностью автоматических релизов на Gitea (`git.shts.su`). Ручное повышение версии в коде не требуется.
|
||||||
|
|
||||||
|
## Как определяется версия
|
||||||
|
|
||||||
|
| Тип коммита | Bump |
|
||||||
|
|-------------|------|
|
||||||
|
| `feat` | minor (1.0.0 → 1.1.0) |
|
||||||
|
| `fix`, `perf` | patch (1.0.0 → 1.0.1) |
|
||||||
|
| `feat!`, `fix!` или `BREAKING CHANGE:` в теле | major (1.0.0 → 2.0.0) |
|
||||||
|
| `docs`, `chore`, `ci`, `test`, `refactor` | без релиза |
|
||||||
|
|
||||||
|
Первый релиз при отсутствии 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)
|
||||||
|
|
||||||
|
```text
|
||||||
|
push/merge в main
|
||||||
|
→ CI: openapi, web, go, bird2 (параллельно)
|
||||||
|
→ job release (в том же workflow, после quality gates):
|
||||||
|
→ semantic-release: git tag vX.Y.Z на текущий commit (без доп. commit)
|
||||||
|
→ Gitea Release + CHANGELOG.md как attachment
|
||||||
|
→ docker buildx bake с VERSION=X.Y.Z
|
||||||
|
→ образы: latest, vX.Y.Z, X.Y.Z, sha-*, короткий SHA
|
||||||
|
```
|
||||||
|
|
||||||
|
Pull request: только quality gates + commitlint; релиз и образы **не** публикуются.
|
||||||
|
|
||||||
|
Workflow: [.gitea/workflows/ci.yaml](../.gitea/workflows/ci.yaml) (job **release**).
|
||||||
|
|
||||||
|
Конфиг semantic-release: [.releaserc.json](../.releaserc.json) — без `@semantic-release/git` (CHANGELOG не коммитится в репозиторий).
|
||||||
|
|
||||||
|
## Секреты Gitea
|
||||||
|
|
||||||
|
Один PAT — **`ACTIONS_PAT`** (Settings → Actions → Secrets).
|
||||||
|
|
||||||
|
| Право PAT | Зачем |
|
||||||
|
|-----------|--------|
|
||||||
|
| push tags | git tag `vX.Y.Z` на commit merge |
|
||||||
|
| releases | Gitea Release + notes |
|
||||||
|
| packages (Container Registry) | push образов |
|
||||||
|
|
||||||
|
Fallback: **`gitea.token`** (нужны права на releases и packages).
|
||||||
|
|
||||||
|
## Источник правды для версии в runtime
|
||||||
|
|
||||||
|
Semver из git-тега пробрасывается в Go-бинарники через `-ldflags` при сборке Docker (`deploy/docker/gobinary/Dockerfile`). Пакет [`internal/version`](../internal/version/version.go):
|
||||||
|
|
||||||
|
- локально (`go run`) — `version: "dev"`
|
||||||
|
- в образе после релиза — совпадает с тегом (например `1.2.3`)
|
||||||
|
|
||||||
|
API: `GET /version`, `GET /v1/version` — поля `version`, `git_sha`, `build_time`.
|
||||||
|
|
||||||
|
Web UI показывает версию из API (footer sidebar, страница «Мониторинг»).
|
||||||
|
|
||||||
|
## CHANGELOG
|
||||||
|
|
||||||
|
Release notes — в Gitea Release; файл `CHANGELOG.md` генерируется в CI и прикрепляется как asset, **не** попадает в git history.
|
||||||
|
|
||||||
|
## Проверка после релиза
|
||||||
|
|
||||||
|
1. Один run workflow **CI** на push в main: job **release** зелёный.
|
||||||
|
2. Gitea: тег `vX.Y.Z` на том же commit, что и merge; Release с notes.
|
||||||
|
3. Container Registry: `evobgp-api:vX.Y.Z`, `evobgp-api:X.Y.Z`, `evobgp-api:latest`.
|
||||||
|
4. `curl http://localhost:8080/version` → `"version":"X.Y.Z"`.
|
||||||
|
|
||||||
|
## Первый релиз (bootstrap)
|
||||||
|
|
||||||
|
Merge в `main` с `feat(release): ...` → **v1.0.0** в том же CI run.
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
// Engineering rules for BIRD/BGP/IP: .cursor/rules/networking-bird.mdc
|
||||||
|
//
|
||||||
// Package birdfmt builds BIRD 2 configuration text: main bird.conf skeleton, include
|
// Package birdfmt builds BIRD 2 configuration text: main bird.conf skeleton, include
|
||||||
// fragments under bird.d/, export filters, static route protocols, BGP templates, and
|
// fragments under bird.d/, export filters, static route protocols, BGP templates, and
|
||||||
// BGP peer protocols (from template or standalone). Runtime helpers parse-check configs
|
// BGP peer protocols (from template or standalone). Runtime helpers parse-check configs
|
||||||
|
|||||||
@@ -15,6 +15,11 @@ import (
|
|||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// NewCDNHTTPClient returns the shared HTTP client for CDN and preview fetches (PERF-02 / ERR-03).
|
||||||
|
func NewCDNHTTPClient() *http.Client {
|
||||||
|
return &http.Client{Timeout: 45 * time.Second}
|
||||||
|
}
|
||||||
|
|
||||||
// BootstrapWorkers opens the same store.Backend and jobs.Registry as New (without HTTP or bundle keys).
|
// BootstrapWorkers opens the same store.Backend and jobs.Registry as New (without HTTP or bundle keys).
|
||||||
// Used by standalone worker binaries (scheduler, ingest, …) that share PostgreSQL with the API.
|
// Used by standalone worker binaries (scheduler, ingest, …) that share PostgreSQL with the API.
|
||||||
func BootstrapWorkers(ctx context.Context, opts Options) (store.Backend, *jobs.Registry, *pgxpool.Pool, error) {
|
func BootstrapWorkers(ctx context.Context, opts Options) (store.Backend, *jobs.Registry, *pgxpool.Pool, error) {
|
||||||
@@ -44,7 +49,7 @@ func BootstrapWorkers(ctx context.Context, opts Options) (store.Backend, *jobs.R
|
|||||||
backend = mem
|
backend = mem
|
||||||
}
|
}
|
||||||
|
|
||||||
cdnHTTP := &http.Client{Timeout: 45 * time.Second}
|
cdnHTTP := NewCDNHTTPClient()
|
||||||
wk := &jobs.Worker{Store: backend, HTTPClient: cdnHTTP}
|
wk := &jobs.Worker{Store: backend, HTTPClient: cdnHTTP}
|
||||||
reg := jobs.NewRegistry(wk.Process)
|
reg := jobs.NewRegistry(wk.Process)
|
||||||
wk.Registry = reg
|
wk.Registry = reg
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
)
|
||||||
|
|
||||||
|
func parseListLimit(r *http.Request) int {
|
||||||
|
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
||||||
|
if limit <= 0 {
|
||||||
|
return 50
|
||||||
|
}
|
||||||
|
if limit > 500 {
|
||||||
|
return 500
|
||||||
|
}
|
||||||
|
return limit
|
||||||
|
}
|
||||||
|
|
||||||
|
func strPtrOrNull(s string) any {
|
||||||
|
if s == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
@@ -2,9 +2,15 @@ package httpapi
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
internalErrorDetail = "an internal error occurred"
|
||||||
|
badGatewayDetail = "upstream request failed"
|
||||||
|
)
|
||||||
|
|
||||||
// Problem is RFC 9457 application/problem+json.
|
// Problem is RFC 9457 application/problem+json.
|
||||||
type Problem struct {
|
type Problem struct {
|
||||||
Type string `json:"type,omitempty"`
|
Type string `json:"type,omitempty"`
|
||||||
@@ -34,3 +40,19 @@ func writeJSON(w http.ResponseWriter, status int, v any) {
|
|||||||
func writeNoContent(w http.ResponseWriter) {
|
func writeNoContent(w http.ResponseWriter) {
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// writeInternalError logs err server-side and returns a generic 500 problem (ERR-01).
|
||||||
|
func writeInternalError(w http.ResponseWriter, operation string, err error) {
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("httpapi: %s: %v", operation, err)
|
||||||
|
}
|
||||||
|
writeProblem(w, http.StatusInternalServerError, "Internal Error", internalErrorDetail)
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeBadGateway logs err server-side and returns a generic 502 problem (ERR-01).
|
||||||
|
func writeBadGateway(w http.ResponseWriter, operation string, err error) {
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("httpapi: %s: %v", operation, err)
|
||||||
|
}
|
||||||
|
writeProblem(w, http.StatusBadGateway, "Bad Gateway", badGatewayDetail)
|
||||||
|
}
|
||||||
+56
-327
@@ -19,7 +19,9 @@ import (
|
|||||||
"evobgp/internal/jobs"
|
"evobgp/internal/jobs"
|
||||||
"evobgp/internal/observability"
|
"evobgp/internal/observability"
|
||||||
"evobgp/internal/pipeline"
|
"evobgp/internal/pipeline"
|
||||||
|
"evobgp/internal/reports"
|
||||||
"evobgp/internal/store"
|
"evobgp/internal/store"
|
||||||
|
"evobgp/internal/version"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Handler returns the root HTTP handler (system routes public; rest under /v1/ authenticated).
|
// Handler returns the root HTTP handler (system routes public; rest under /v1/ authenticated).
|
||||||
@@ -29,6 +31,7 @@ func (s *Server) Handler() http.Handler {
|
|||||||
wrappedV1 := http.StripPrefix("/v1", v1)
|
wrappedV1 := http.StripPrefix("/v1", v1)
|
||||||
|
|
||||||
s.mux.Handle("GET /metrics", observability.MetricsHandler())
|
s.mux.Handle("GET /metrics", observability.MetricsHandler())
|
||||||
|
s.mux.HandleFunc("GET /version", s.handleVersion)
|
||||||
s.mux.HandleFunc("GET /v1/health", s.handleHealth)
|
s.mux.HandleFunc("GET /v1/health", s.handleHealth)
|
||||||
s.mux.HandleFunc("GET /v1/ready", s.handleReady)
|
s.mux.HandleFunc("GET /v1/ready", s.handleReady)
|
||||||
s.mux.HandleFunc("GET /v1/version", s.handleVersion)
|
s.mux.HandleFunc("GET /v1/version", s.handleVersion)
|
||||||
@@ -83,7 +86,7 @@ func (s *Server) handleReady(w http.ResponseWriter, r *http.Request) {
|
|||||||
defer cancel()
|
defer cancel()
|
||||||
if s.pgPool != nil {
|
if s.pgPool != nil {
|
||||||
if err := s.pgPool.Ping(ctx); err != nil {
|
if err := s.pgPool.Ping(ctx); err != nil {
|
||||||
checks["postgres"] = err.Error()
|
checks["postgres"] = "unavailable"
|
||||||
writeJSON(w, http.StatusServiceUnavailable, map[string]any{"status": "not_ready", "checks": checks})
|
writeJSON(w, http.StatusServiceUnavailable, map[string]any{"status": "not_ready", "checks": checks})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -95,11 +98,16 @@ func (s *Server) handleReady(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleVersion(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleVersion(w http.ResponseWriter, r *http.Request) {
|
||||||
sha := strings.TrimSpace(os.Getenv("EVOBGP_GIT_SHA"))
|
ver, sha, buildTime := version.Info()
|
||||||
if sha == "" {
|
resp := map[string]string{
|
||||||
sha = "unknown"
|
"version": ver,
|
||||||
|
"api_version": ver,
|
||||||
|
"git_sha": sha,
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]string{"api_version": "0.1.0", "git_sha": sha})
|
if buildTime != "" {
|
||||||
|
resp["build_time"] = buildTime
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, resp)
|
||||||
}
|
}
|
||||||
|
|
||||||
func moduleJSON(mod *store.Module) map[string]any {
|
func moduleJSON(mod *store.Module) map[string]any {
|
||||||
@@ -122,6 +130,13 @@ func moduleJSON(mod *store.Module) map[string]any {
|
|||||||
} else {
|
} else {
|
||||||
m["default_community_id"] = nil
|
m["default_community_id"] = nil
|
||||||
}
|
}
|
||||||
|
ids := mod.EffectiveDohProfileIDs()
|
||||||
|
if len(ids) > 0 {
|
||||||
|
m["doh_profile_ids"] = ids
|
||||||
|
} else {
|
||||||
|
m["doh_profile_ids"] = []string{}
|
||||||
|
}
|
||||||
|
m["doh_resolver_policy"] = store.NormalizeDohResolverPolicy(mod.DohResolverPolicy)
|
||||||
if mod.DohProfileID != nil {
|
if mod.DohProfileID != nil {
|
||||||
m["doh_profile_id"] = *mod.DohProfileID
|
m["doh_profile_id"] = *mod.DohProfileID
|
||||||
} else {
|
} else {
|
||||||
@@ -182,19 +197,23 @@ func (s *Server) handleListModules(w http.ResponseWriter, r *http.Request) {
|
|||||||
enabledFilter = &v
|
enabledFilter = &v
|
||||||
}
|
}
|
||||||
|
|
||||||
mods := s.store.ListModules(a.TenantID)
|
filtered := make([]*store.Module, 0)
|
||||||
items := make([]map[string]any, 0, len(mods))
|
for _, mod := range s.store.ListModules(a.TenantID) {
|
||||||
for _, mod := range mods {
|
|
||||||
if typeFilter != "" && mod.Type != typeFilter {
|
if typeFilter != "" && mod.Type != typeFilter {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if enabledFilter != nil && mod.Enabled != *enabledFilter {
|
if enabledFilter != nil && mod.Enabled != *enabledFilter {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
filtered = append(filtered, mod)
|
||||||
|
}
|
||||||
|
page, next, more := store.PaginateOffset(filtered, r.URL.Query().Get("cursor"), parseListLimit(r))
|
||||||
|
items := make([]map[string]any, 0, len(page))
|
||||||
|
for _, mod := range page {
|
||||||
items = append(items, moduleJSON(mod))
|
items = append(items, moduleJSON(mod))
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]any{
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
"items": items, "next_cursor": nil, "has_more": false,
|
"items": items, "next_cursor": strPtrOrNull(next), "has_more": more,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -207,91 +226,17 @@ func (s *Server) handleRouterListsCatalog(w http.ResponseWriter, r *http.Request
|
|||||||
if !s.requireAtLeast(w, a, "viewer") {
|
if !s.requireAtLeast(w, a, "viewer") {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
cat, err := reports.BuildRouterListsCatalog(s.store, a.TenantID)
|
||||||
mods := s.store.ListModules(a.TenantID)
|
|
||||||
moduleItems := make([]map[string]any, 0, len(mods))
|
|
||||||
domains := make([]map[string]any, 0)
|
|
||||||
asns := make([]map[string]any, 0)
|
|
||||||
ipRanges := make([]map[string]any, 0)
|
|
||||||
|
|
||||||
for _, mod := range mods {
|
|
||||||
switch mod.Type {
|
|
||||||
case "DOMAINS", "AS_PREFIXES", "IP_RANGES":
|
|
||||||
moduleItems = append(moduleItems, moduleJSON(mod))
|
|
||||||
default:
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
switch mod.Type {
|
|
||||||
case "DOMAINS":
|
|
||||||
list, err := s.store.ListDomainEntries(a.TenantID, mod.ID)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeStoreErr(w, err)
|
writeStoreErr(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
for _, x := range list {
|
|
||||||
domains = append(domains, map[string]any{
|
|
||||||
"module_id": mod.ID,
|
|
||||||
"entry": domainEntryJSON(x),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
case "AS_PREFIXES":
|
|
||||||
list, err := s.store.ListASEntries(a.TenantID, mod.ID)
|
|
||||||
if err != nil {
|
|
||||||
writeStoreErr(w, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
for _, x := range list {
|
|
||||||
asns = append(asns, map[string]any{
|
|
||||||
"module_id": mod.ID,
|
|
||||||
"entry": asEntryJSON(x),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
case "IP_RANGES":
|
|
||||||
list, err := s.store.ListIPRangeEntries(a.TenantID, mod.ID)
|
|
||||||
if err != nil {
|
|
||||||
writeStoreErr(w, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
for _, x := range list {
|
|
||||||
ipRanges = append(ipRanges, map[string]any{
|
|
||||||
"module_id": mod.ID,
|
|
||||||
"entry": ipRangeJSON(x),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
comms, err := s.store.ListCommunities(a.TenantID)
|
|
||||||
if err != nil {
|
|
||||||
writeStoreErr(w, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
communityItems := make([]map[string]any, 0, len(comms))
|
|
||||||
for _, c := range comms {
|
|
||||||
communityItems = append(communityItems, map[string]any{
|
|
||||||
"id": c.ID,
|
|
||||||
"community": c.Community,
|
|
||||||
"title": c.Title,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
writeJSON(w, http.StatusOK, map[string]any{
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
"modules": map[string]any{
|
"modules": map[string]any{"items": cat.Modules},
|
||||||
"items": moduleItems,
|
"domains": map[string]any{"items": cat.Domains},
|
||||||
},
|
"asns": map[string]any{"items": cat.ASNs},
|
||||||
"domains": map[string]any{
|
"ip_ranges": map[string]any{"items": cat.IPRanges},
|
||||||
"items": domains,
|
"communities": map[string]any{"items": cat.Communities},
|
||||||
},
|
|
||||||
"asns": map[string]any{
|
|
||||||
"items": asns,
|
|
||||||
},
|
|
||||||
"ip_ranges": map[string]any{
|
|
||||||
"items": ipRanges,
|
|
||||||
},
|
|
||||||
"communities": map[string]any{
|
|
||||||
"items": communityItems,
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -310,7 +255,7 @@ func (s *Server) handleGetModule(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeProblem(w, http.StatusNotFound, "Not Found", "module not found")
|
writeProblem(w, http.StatusNotFound, "Not Found", "module not found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeProblem(w, http.StatusInternalServerError, "Internal Error", err.Error())
|
writeInternalError(w, "internal", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, moduleJSON(mod))
|
writeJSON(w, http.StatusOK, moduleJSON(mod))
|
||||||
@@ -325,10 +270,11 @@ func (s *Server) handleListPeers(w http.ResponseWriter, r *http.Request) {
|
|||||||
if !s.requireAtLeast(w, a, "viewer") {
|
if !s.requireAtLeast(w, a, "viewer") {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
peers := s.store.ListPeers(a.TenantID)
|
allPeers := s.store.ListPeers(a.TenantID)
|
||||||
|
page, next, more := store.PaginateOffset(allPeers, r.URL.Query().Get("cursor"), parseListLimit(r))
|
||||||
liveStates := s.liveBGPProtocolStates(r.Context())
|
liveStates := s.liveBGPProtocolStates(r.Context())
|
||||||
items := make([]map[string]any, 0, len(peers))
|
items := make([]map[string]any, 0, len(page))
|
||||||
for _, p := range peers {
|
for _, p := range page {
|
||||||
row := peerJSON(p)
|
row := peerJSON(p)
|
||||||
if st, ok := liveStates[peerProtocolNameForID(p.ID)]; ok && strings.TrimSpace(st) != "" {
|
if st, ok := liveStates[peerProtocolNameForID(p.ID)]; ok && strings.TrimSpace(st) != "" {
|
||||||
row["session_state"] = strings.TrimSpace(st)
|
row["session_state"] = strings.TrimSpace(st)
|
||||||
@@ -336,7 +282,7 @@ func (s *Server) handleListPeers(w http.ResponseWriter, r *http.Request) {
|
|||||||
items = append(items, row)
|
items = append(items, row)
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]any{
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
"items": items, "next_cursor": nil, "has_more": false,
|
"items": items, "next_cursor": strPtrOrNull(next), "has_more": more,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -444,7 +390,7 @@ func (s *Server) handleModuleRefresh(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeProblem(w, http.StatusNotFound, "Not Found", "module not found")
|
writeProblem(w, http.StatusNotFound, "Not Found", "module not found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeProblem(w, http.StatusInternalServerError, "Internal Error", err.Error())
|
writeInternalError(w, "internal", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
idem := r.Header.Get("Idempotency-Key")
|
idem := r.Header.Get("Idempotency-Key")
|
||||||
@@ -456,7 +402,7 @@ func (s *Server) handleModuleRefresh(w http.ResponseWriter, r *http.Request) {
|
|||||||
mid := mod.ID
|
mid := mod.ID
|
||||||
j, _, err := s.jobs.Enqueue(a.TenantID, jobs.KindModuleRefresh, idemPtr, &mid, map[string]any{"module_id": moduleID})
|
j, _, err := s.jobs.Enqueue(a.TenantID, jobs.KindModuleRefresh, idemPtr, &mid, map[string]any{"module_id": moduleID})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeProblem(w, http.StatusInternalServerError, "Internal Error", err.Error())
|
writeInternalError(w, "internal", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.Header().Set("Location", "/v1/jobs/"+j.ID)
|
w.Header().Set("Location", "/v1/jobs/"+j.ID)
|
||||||
@@ -505,7 +451,7 @@ func (s *Server) handleTenantRefresh(w http.ResponseWriter, r *http.Request) {
|
|||||||
"trigger": "api",
|
"trigger": "api",
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeProblem(w, http.StatusInternalServerError, "Internal Error", err.Error())
|
writeInternalError(w, "internal", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.Header().Set("Location", "/v1/jobs/"+j.ID)
|
w.Header().Set("Location", "/v1/jobs/"+j.ID)
|
||||||
@@ -563,13 +509,6 @@ func revisionJSON(rev *store.Revision) map[string]any {
|
|||||||
return m
|
return m
|
||||||
}
|
}
|
||||||
|
|
||||||
func strPtrOrNull(s string) any {
|
|
||||||
if s == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
|
|
||||||
// enqueueModuleRefreshIfEnabled queues module_refresh when the module exists and is enabled (best-effort, no HTTP error).
|
// enqueueModuleRefreshIfEnabled queues module_refresh when the module exists and is enabled (best-effort, no HTTP error).
|
||||||
func (s *Server) enqueueModuleRefreshIfEnabled(tenantID, moduleID, trigger string) {
|
func (s *Server) enqueueModuleRefreshIfEnabled(tenantID, moduleID, trigger string) {
|
||||||
if s.jobs == nil {
|
if s.jobs == nil {
|
||||||
@@ -665,225 +604,15 @@ func (s *Server) handleRevisionDiagnosticLog(w http.ResponseWriter, r *http.Requ
|
|||||||
writeProblem(w, http.StatusNotFound, "Not Found", "revision not found")
|
writeProblem(w, http.StatusNotFound, "Not Found", "revision not found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
logOut, err := reports.BuildRevisionDiagnosticLog(s.store, a.TenantID, rev)
|
||||||
moduleType := ""
|
if err != nil {
|
||||||
moduleName := ""
|
writeInternalError(w, "revision diagnostic log", err)
|
||||||
if strings.TrimSpace(rev.ModuleID) != "" {
|
return
|
||||||
if mod, modErr := s.store.GetModule(a.TenantID, rev.ModuleID); modErr == nil && mod != nil {
|
|
||||||
moduleType = strings.TrimSpace(mod.Type)
|
|
||||||
moduleName = strings.TrimSpace(mod.Name)
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
communityLabels := map[string]string{}
|
|
||||||
if communities, listErr := s.store.ListCommunities(a.TenantID); listErr == nil {
|
|
||||||
for _, c := range communities {
|
|
||||||
if c == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
label := strings.TrimSpace(c.Title)
|
|
||||||
if label == "" {
|
|
||||||
label = strings.TrimSpace(c.Community)
|
|
||||||
}
|
|
||||||
if label == "" {
|
|
||||||
label = c.ID
|
|
||||||
}
|
|
||||||
communityLabels[c.ID] = label
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type rawRow struct {
|
|
||||||
Prefix string
|
|
||||||
Source string
|
|
||||||
Kind string
|
|
||||||
SourceName string
|
|
||||||
SourceDetail string
|
|
||||||
CommunityID string
|
|
||||||
CommunityLabel string
|
|
||||||
}
|
|
||||||
type summary struct {
|
|
||||||
Kind string
|
|
||||||
Source string
|
|
||||||
SourceDetail string
|
|
||||||
CommunityID string
|
|
||||||
CommunityLabel string
|
|
||||||
Count int
|
|
||||||
Sample []string
|
|
||||||
}
|
|
||||||
|
|
||||||
cdnSourceURLByID := map[string]string{}
|
|
||||||
if moduleType == "CDN_CIDRS" && strings.TrimSpace(rev.ModuleID) != "" {
|
|
||||||
if sources, srcErr := s.store.ListCDNSources(a.TenantID, rev.ModuleID); srcErr == nil {
|
|
||||||
for _, src := range sources {
|
|
||||||
if src == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
id := strings.TrimSpace(src.ID)
|
|
||||||
url := strings.TrimSpace(src.URL)
|
|
||||||
if id != "" && url != "" {
|
|
||||||
cdnSourceURLByID[id] = url
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
rows := make([]rawRow, 0, rev.MaterializedPrefixCount)
|
|
||||||
byGroup := map[string]*summary{}
|
|
||||||
cursor := ""
|
|
||||||
for {
|
|
||||||
page, next, more := s.store.ListRevisionPrefixes(a.TenantID, revID, cursor, 2000)
|
|
||||||
for _, p := range page {
|
|
||||||
kind, sourceName := classifyRevisionSource(p.Source)
|
|
||||||
sourceDetail := sourceName
|
|
||||||
if kind == "cdn" {
|
|
||||||
if url, ok := cdnSourceURLByID[sourceName]; ok && strings.TrimSpace(url) != "" {
|
|
||||||
sourceDetail = url
|
|
||||||
}
|
|
||||||
}
|
|
||||||
communityID := "none"
|
|
||||||
if p.CommunityID != nil && strings.TrimSpace(*p.CommunityID) != "" {
|
|
||||||
communityID = strings.TrimSpace(*p.CommunityID)
|
|
||||||
}
|
|
||||||
communityLabel := "без community"
|
|
||||||
if communityID != "none" {
|
|
||||||
if lbl, ok := communityLabels[communityID]; ok && strings.TrimSpace(lbl) != "" {
|
|
||||||
communityLabel = lbl
|
|
||||||
} else {
|
|
||||||
communityLabel = communityID
|
|
||||||
}
|
|
||||||
}
|
|
||||||
rows = append(rows, rawRow{
|
|
||||||
Prefix: p.Prefix,
|
|
||||||
Source: p.Source,
|
|
||||||
Kind: kind,
|
|
||||||
SourceName: sourceName,
|
|
||||||
SourceDetail: sourceDetail,
|
|
||||||
CommunityID: communityID,
|
|
||||||
CommunityLabel: communityLabel,
|
|
||||||
})
|
|
||||||
groupKey := kind + "|" + sourceName + "|" + communityID
|
|
||||||
g, ok := byGroup[groupKey]
|
|
||||||
if !ok {
|
|
||||||
g = &summary{
|
|
||||||
Kind: kind,
|
|
||||||
Source: sourceName,
|
|
||||||
SourceDetail: sourceDetail,
|
|
||||||
CommunityID: communityID,
|
|
||||||
CommunityLabel: communityLabel,
|
|
||||||
Sample: make([]string, 0, 5),
|
|
||||||
}
|
|
||||||
byGroup[groupKey] = g
|
|
||||||
}
|
|
||||||
g.Count++
|
|
||||||
if len(g.Sample) < 5 {
|
|
||||||
g.Sample = append(g.Sample, p.Prefix)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !more || strings.TrimSpace(next) == "" {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
cursor = next
|
|
||||||
}
|
|
||||||
|
|
||||||
summaryKeys := make([]string, 0, len(byGroup))
|
|
||||||
for k := range byGroup {
|
|
||||||
summaryKeys = append(summaryKeys, k)
|
|
||||||
}
|
|
||||||
sort.Strings(summaryKeys)
|
|
||||||
|
|
||||||
sort.Slice(rows, func(i, j int) bool {
|
|
||||||
if rows[i].Kind != rows[j].Kind {
|
|
||||||
return rows[i].Kind < rows[j].Kind
|
|
||||||
}
|
|
||||||
if rows[i].SourceName != rows[j].SourceName {
|
|
||||||
return rows[i].SourceName < rows[j].SourceName
|
|
||||||
}
|
|
||||||
if rows[i].CommunityID != rows[j].CommunityID {
|
|
||||||
return rows[i].CommunityID < rows[j].CommunityID
|
|
||||||
}
|
|
||||||
return rows[i].Prefix < rows[j].Prefix
|
|
||||||
})
|
|
||||||
|
|
||||||
var b strings.Builder
|
|
||||||
b.WriteString("EvoBGP revision diagnostic log\n")
|
|
||||||
b.WriteString("generated_at=" + time.Now().UTC().Format(time.RFC3339Nano) + "\n")
|
|
||||||
b.WriteString("tenant_id=" + a.TenantID + "\n")
|
|
||||||
b.WriteString("revision_id=" + rev.ID + "\n")
|
|
||||||
b.WriteString("revision_created_at=" + rev.CreatedAt.UTC().Format(time.RFC3339Nano) + "\n")
|
|
||||||
b.WriteString("content_hash=" + rev.ContentHash + "\n")
|
|
||||||
b.WriteString("materialized_prefix_count=" + strconv.Itoa(rev.MaterializedPrefixCount) + "\n")
|
|
||||||
if rev.ModuleID != "" {
|
|
||||||
b.WriteString("module_id=" + rev.ModuleID + "\n")
|
|
||||||
} else {
|
|
||||||
b.WriteString("module_id=\n")
|
|
||||||
}
|
|
||||||
b.WriteString("module_type=" + moduleType + "\n")
|
|
||||||
b.WriteString("module_name=" + moduleName + "\n")
|
|
||||||
b.WriteString("fetched_rows=" + strconv.Itoa(len(rows)) + "\n\n")
|
|
||||||
|
|
||||||
b.WriteString("## Aggregation by source and community\n")
|
|
||||||
for _, k := range summaryKeys {
|
|
||||||
g := byGroup[k]
|
|
||||||
b.WriteString("- kind=" + g.Kind +
|
|
||||||
" source=" + g.Source +
|
|
||||||
" source_detail=" + g.SourceDetail +
|
|
||||||
" community_id=" + g.CommunityID +
|
|
||||||
" community_label=" + g.CommunityLabel +
|
|
||||||
" count=" + strconv.Itoa(g.Count))
|
|
||||||
if len(g.Sample) > 0 {
|
|
||||||
b.WriteString(" sample=" + strings.Join(g.Sample, ","))
|
|
||||||
}
|
|
||||||
b.WriteByte('\n')
|
|
||||||
}
|
|
||||||
|
|
||||||
b.WriteString("\n## Raw rows\n")
|
|
||||||
b.WriteString("prefix\tkind\tsource\tsource_detail\tcommunity_id\tcommunity_label\n")
|
|
||||||
for _, row := range rows {
|
|
||||||
b.WriteString(row.Prefix)
|
|
||||||
b.WriteByte('\t')
|
|
||||||
b.WriteString(row.Kind)
|
|
||||||
b.WriteByte('\t')
|
|
||||||
b.WriteString(row.Source)
|
|
||||||
b.WriteByte('\t')
|
|
||||||
b.WriteString(strings.ReplaceAll(row.SourceDetail, "\t", " "))
|
|
||||||
b.WriteByte('\t')
|
|
||||||
b.WriteString(row.CommunityID)
|
|
||||||
b.WriteByte('\t')
|
|
||||||
b.WriteString(strings.ReplaceAll(row.CommunityLabel, "\t", " "))
|
|
||||||
b.WriteByte('\n')
|
|
||||||
}
|
|
||||||
|
|
||||||
filename := "revision-" + shortRevisionID(rev.ID) + "-diagnostic.log"
|
|
||||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||||
w.Header().Set("Content-Disposition", `attachment; filename="`+filename+`"`)
|
w.Header().Set("Content-Disposition", `attachment; filename="`+logOut.Filename+`"`)
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
_, _ = w.Write([]byte(b.String()))
|
_, _ = w.Write(logOut.Body)
|
||||||
}
|
|
||||||
|
|
||||||
func classifyRevisionSource(src string) (kind, sourceName string) {
|
|
||||||
switch {
|
|
||||||
case strings.HasPrefix(src, "as:"):
|
|
||||||
return "asn", strings.TrimPrefix(src, "as:")
|
|
||||||
case strings.HasPrefix(src, "domain:"):
|
|
||||||
return "domain", strings.TrimPrefix(src, "domain:")
|
|
||||||
case strings.HasPrefix(src, "cdn:"):
|
|
||||||
return "cdn", strings.TrimPrefix(src, "cdn:")
|
|
||||||
case src == "ip_range":
|
|
||||||
return "ip_range", "manual_ranges"
|
|
||||||
default:
|
|
||||||
if strings.TrimSpace(src) == "" {
|
|
||||||
return "unknown", "unknown"
|
|
||||||
}
|
|
||||||
return "source", strings.TrimSpace(src)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func shortRevisionID(id string) string {
|
|
||||||
s := strings.TrimSpace(id)
|
|
||||||
if len(s) <= 8 {
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
return s[:8]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleRevisionDiff(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleRevisionDiff(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -909,7 +638,7 @@ func (s *Server) handleRevisionRollback(w http.ResponseWriter, r *http.Request)
|
|||||||
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
|
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !s.requireAtLeast(w, a, "editor") {
|
if !s.requireAtLeast(w, a, "operator") {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
revID := r.PathValue("revision_id")
|
revID := r.PathValue("revision_id")
|
||||||
@@ -926,7 +655,7 @@ func (s *Server) handleRevisionRollback(w http.ResponseWriter, r *http.Request)
|
|||||||
"source_revision_id": revID,
|
"source_revision_id": revID,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeProblem(w, http.StatusInternalServerError, "Internal Error", err.Error())
|
writeInternalError(w, "internal", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.Header().Set("Location", "/v1/jobs/"+j.ID)
|
w.Header().Set("Location", "/v1/jobs/"+j.ID)
|
||||||
@@ -973,7 +702,7 @@ func (s *Server) handleApply(w http.ResponseWriter, r *http.Request) {
|
|||||||
"strategy": body.Strategy,
|
"strategy": body.Strategy,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeProblem(w, http.StatusInternalServerError, "Internal Error", err.Error())
|
writeInternalError(w, "internal", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.Header().Set("Location", "/v1/jobs/"+j.ID)
|
w.Header().Set("Location", "/v1/jobs/"+j.ID)
|
||||||
@@ -1024,7 +753,7 @@ func (s *Server) handleSpeakerApply(w http.ResponseWriter, r *http.Request) {
|
|||||||
"speaker_id": spkID,
|
"speaker_id": spkID,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeProblem(w, http.StatusInternalServerError, "Internal Error", err.Error())
|
writeInternalError(w, "internal", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.Header().Set("Location", "/v1/jobs/"+j.ID)
|
w.Header().Set("Location", "/v1/jobs/"+j.ID)
|
||||||
@@ -1049,7 +778,7 @@ func (s *Server) handleBirdReload(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
j, _, err := s.jobs.Enqueue(a.TenantID, jobs.KindBirdReload, idemPtr, nil, nil)
|
j, _, err := s.jobs.Enqueue(a.TenantID, jobs.KindBirdReload, idemPtr, nil, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeProblem(w, http.StatusInternalServerError, "Internal Error", err.Error())
|
writeInternalError(w, "internal", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.Header().Set("Location", "/v1/jobs/"+j.ID)
|
w.Header().Set("Location", "/v1/jobs/"+j.ID)
|
||||||
@@ -1184,7 +913,7 @@ func (s *Server) handleNodeBundle(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
tgz, err := bundle.BuildGzippedTar(rid, sid, rev.PreviewFragments, s.bundlePriv)
|
tgz, err := bundle.BuildGzippedTar(rid, sid, rev.PreviewFragments, s.bundlePriv)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeProblem(w, http.StatusInternalServerError, "Internal Error", err.Error())
|
writeInternalError(w, "internal", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.Header().Set("Content-Type", "application/gzip")
|
w.Header().Set("Content-Type", "application/gzip")
|
||||||
@@ -1234,7 +963,7 @@ func (s *Server) handleNodeEnroll(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
patch := &store.SpeakerPatch{MetaJSON: &meta}
|
patch := &store.SpeakerPatch{MetaJSON: &meta}
|
||||||
if _, err := s.store.UpdateSpeaker(a.TenantID, sid, patch); err != nil {
|
if _, err := s.store.UpdateSpeaker(a.TenantID, sid, patch); err != nil {
|
||||||
writeProblem(w, http.StatusInternalServerError, "Internal Error", err.Error())
|
writeInternalError(w, "internal", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]any{
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
|||||||
+41
-150
@@ -3,6 +3,7 @@ package httpapi
|
|||||||
import (
|
import (
|
||||||
"encoding/csv"
|
"encoding/csv"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -10,6 +11,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"evobgp/internal/importer"
|
||||||
"evobgp/internal/pipeline"
|
"evobgp/internal/pipeline"
|
||||||
"evobgp/internal/store"
|
"evobgp/internal/store"
|
||||||
)
|
)
|
||||||
@@ -83,6 +85,8 @@ func (s *Server) handlePostModule(w http.ResponseWriter, r *http.Request) {
|
|||||||
CronExpr string `json:"cron_expr"`
|
CronExpr string `json:"cron_expr"`
|
||||||
DefaultCommunityID *string `json:"default_community_id"`
|
DefaultCommunityID *string `json:"default_community_id"`
|
||||||
DohProfileID *string `json:"doh_profile_id"`
|
DohProfileID *string `json:"doh_profile_id"`
|
||||||
|
DohProfileIDs []string `json:"doh_profile_ids"`
|
||||||
|
DohResolverPolicy string `json:"doh_resolver_policy"`
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid json")
|
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid json")
|
||||||
@@ -92,6 +96,7 @@ func (s *Server) handlePostModule(w http.ResponseWriter, r *http.Request) {
|
|||||||
Type: body.Type, Name: body.Name, Enabled: body.Enabled, Priority: body.Priority,
|
Type: body.Type, Name: body.Name, Enabled: body.Enabled, Priority: body.Priority,
|
||||||
RefreshIntervalSec: body.RefreshIntervalSec, CronExpr: body.CronExpr,
|
RefreshIntervalSec: body.RefreshIntervalSec, CronExpr: body.CronExpr,
|
||||||
DefaultCommunityID: body.DefaultCommunityID, DohProfileID: body.DohProfileID,
|
DefaultCommunityID: body.DefaultCommunityID, DohProfileID: body.DohProfileID,
|
||||||
|
DohProfileIDs: body.DohProfileIDs, DohResolverPolicy: body.DohResolverPolicy,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeStoreErr(w, err)
|
writeStoreErr(w, err)
|
||||||
@@ -129,6 +134,14 @@ func (s *Server) handlePatchModule(w http.ResponseWriter, r *http.Request) {
|
|||||||
empty := ""
|
empty := ""
|
||||||
body.DohProfileID = &empty
|
body.DohProfileID = &empty
|
||||||
}
|
}
|
||||||
|
if v, ok := raw["doh_profile_ids"]; ok && string(v) == "null" {
|
||||||
|
empty := []string{}
|
||||||
|
body.DohProfileIDs = &empty
|
||||||
|
}
|
||||||
|
if v, ok := raw["doh_resolver_policy"]; ok && string(v) == "null" {
|
||||||
|
p := store.DohPolicyPrimaryOnly
|
||||||
|
body.DohResolverPolicy = &p
|
||||||
|
}
|
||||||
if v, ok := raw["cron_expr"]; ok && string(v) == "null" {
|
if v, ok := raw["cron_expr"]; ok && string(v) == "null" {
|
||||||
empty := ""
|
empty := ""
|
||||||
body.CronExpr = &empty
|
body.CronExpr = &empty
|
||||||
@@ -167,7 +180,7 @@ func writeStoreErr(w http.ResponseWriter, err error) {
|
|||||||
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", err.Error())
|
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeProblem(w, http.StatusInternalServerError, "Internal Error", err.Error())
|
writeInternalError(w, "store", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleListCDNSources(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleListCDNSources(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -240,20 +253,20 @@ func (s *Server) handlePreviewCDNSource(w http.ResponseWriter, r *http.Request)
|
|||||||
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", "invalid url")
|
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", "invalid url")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
resp, err := http.DefaultClient.Do(req)
|
resp, err := s.cdnHTTP.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeProblem(w, http.StatusBadGateway, "Bad Gateway", err.Error())
|
writeBadGateway(w, "cdn preview fetch", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
_, _ = io.Copy(io.Discard, resp.Body)
|
_, _ = io.Copy(io.Discard, resp.Body)
|
||||||
writeProblem(w, http.StatusBadGateway, "Bad Gateway", fmt.Sprintf("upstream status: %s", resp.Status))
|
writeBadGateway(w, "cdn preview fetch", fmt.Errorf("upstream status: %s", resp.Status))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
raw, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
|
raw, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeProblem(w, http.StatusBadGateway, "Bad Gateway", err.Error())
|
writeBadGateway(w, "cdn preview read body", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
pfxs, err := pipeline.ExtractCIDRs(string(raw), body.SourceKind, body.PrefixPath)
|
pfxs, err := pipeline.ExtractCIDRs(string(raw), body.SourceKind, body.PrefixPath)
|
||||||
@@ -678,163 +691,41 @@ func (s *Server) handleImportModuleEntriesCSV(w http.ResponseWriter, r *http.Req
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
moduleID := r.PathValue("module_id")
|
moduleID := r.PathValue("module_id")
|
||||||
mod, err := s.store.GetModule(a.TenantID, moduleID)
|
res, err := importer.ImportModuleEntriesCSV(s.store, a.TenantID, moduleID, r.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeStoreErr(w, err)
|
if errors.Is(err, store.ErrInvalidInput) {
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
cr := csv.NewReader(io.LimitReader(r.Body, 8<<20))
|
|
||||||
cr.TrimLeadingSpace = true
|
|
||||||
cr.FieldsPerRecord = -1
|
|
||||||
rows, err := cr.ReadAll()
|
|
||||||
if err != nil {
|
|
||||||
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid csv")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if len(rows) == 0 {
|
|
||||||
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", "csv is empty")
|
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", "csv is empty")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if strings.Contains(err.Error(), "importer: invalid csv") {
|
||||||
communities, err := s.store.ListCommunities(a.TenantID)
|
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid csv")
|
||||||
if err != nil {
|
|
||||||
writeStoreErr(w, err)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
communityIDByID := make(map[string]string, len(communities))
|
if strings.Contains(err.Error(), "importer: line") {
|
||||||
communityIDByValue := make(map[string]string, len(communities))
|
detail := strings.TrimPrefix(err.Error(), "importer: ")
|
||||||
for _, c := range communities {
|
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", detail)
|
||||||
communityIDByID[c.ID] = c.ID
|
|
||||||
communityIDByValue[strings.TrimSpace(c.Community)] = c.ID
|
|
||||||
}
|
|
||||||
|
|
||||||
resolveCommunity := func(raw string, required bool) (*string, error) {
|
|
||||||
v := strings.TrimSpace(raw)
|
|
||||||
if v == "" {
|
|
||||||
if required {
|
|
||||||
return nil, fmt.Errorf("community is required")
|
|
||||||
}
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
if id, ok := communityIDByID[v]; ok {
|
|
||||||
return &id, nil
|
|
||||||
}
|
|
||||||
if id, ok := communityIDByValue[v]; ok {
|
|
||||||
return &id, nil
|
|
||||||
}
|
|
||||||
return nil, fmt.Errorf("unknown community %q", v)
|
|
||||||
}
|
|
||||||
|
|
||||||
start := 0
|
|
||||||
if len(rows[0]) >= 2 {
|
|
||||||
key := strings.ToLower(strings.TrimSpace(rows[0][0]))
|
|
||||||
switch key {
|
|
||||||
case "asn", "domain", "iprange":
|
|
||||||
start = 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
imported := 0
|
|
||||||
switch mod.Type {
|
|
||||||
case "AS_PREFIXES":
|
|
||||||
for i := start; i < len(rows); i++ {
|
|
||||||
rec := rows[i]
|
|
||||||
if len(rec) == 0 || (strings.TrimSpace(rec[0]) == "" && (len(rec) < 2 || strings.TrimSpace(rec[1]) == "")) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if len(rec) < 2 {
|
|
||||||
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", fmt.Sprintf("line %d: expected 2 columns", i+1))
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
asn, err := strconv.ParseInt(strings.TrimSpace(rec[0]), 10, 64)
|
if strings.Contains(err.Error(), "importer: csv import/export") {
|
||||||
if err != nil || asn <= 0 {
|
|
||||||
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", fmt.Sprintf("line %d: invalid asn", i+1))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
cid, err := resolveCommunity(rec[1], false)
|
|
||||||
if err != nil {
|
|
||||||
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", fmt.Sprintf("line %d: %v", i+1, err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
_, err = s.store.CreateASEntry(a.TenantID, moduleID, &store.ASEntry{ASN: asn, CommunityID: cid})
|
|
||||||
if err != nil {
|
|
||||||
writeStoreErr(w, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
imported++
|
|
||||||
}
|
|
||||||
if imported > 0 {
|
|
||||||
s.enqueueModuleRefreshIfEnabled(a.TenantID, moduleID, "as_entry_import_csv")
|
|
||||||
}
|
|
||||||
case "DOMAINS":
|
|
||||||
for i := start; i < len(rows); i++ {
|
|
||||||
rec := rows[i]
|
|
||||||
if len(rec) == 0 || (strings.TrimSpace(rec[0]) == "" && (len(rec) < 2 || strings.TrimSpace(rec[1]) == "")) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if len(rec) < 2 {
|
|
||||||
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", fmt.Sprintf("line %d: expected 2 columns", i+1))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
fqdn := strings.TrimSpace(rec[0])
|
|
||||||
if fqdn == "" {
|
|
||||||
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", fmt.Sprintf("line %d: domain is required", i+1))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
cid, err := resolveCommunity(rec[1], false)
|
|
||||||
if err != nil {
|
|
||||||
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", fmt.Sprintf("line %d: %v", i+1, err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
_, err = s.store.CreateDomainEntry(a.TenantID, moduleID, &store.DomainEntry{FQDN: fqdn, CommunityID: cid})
|
|
||||||
if err != nil {
|
|
||||||
writeStoreErr(w, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
imported++
|
|
||||||
}
|
|
||||||
if imported > 0 {
|
|
||||||
s.enqueueModuleRefreshIfEnabled(a.TenantID, moduleID, "domain_entry_import_csv")
|
|
||||||
}
|
|
||||||
case "IP_RANGES":
|
|
||||||
for i := start; i < len(rows); i++ {
|
|
||||||
rec := rows[i]
|
|
||||||
if len(rec) == 0 || (strings.TrimSpace(rec[0]) == "" && (len(rec) < 2 || strings.TrimSpace(rec[1]) == "")) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if len(rec) < 2 {
|
|
||||||
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", fmt.Sprintf("line %d: expected 2 columns", i+1))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
prefix := strings.TrimSpace(rec[0])
|
|
||||||
if prefix == "" {
|
|
||||||
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", fmt.Sprintf("line %d: ipRange is required", i+1))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
cid, err := resolveCommunity(rec[1], true)
|
|
||||||
if err != nil {
|
|
||||||
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", fmt.Sprintf("line %d: %v", i+1, err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
_, err = s.store.CreateIPRangeEntry(a.TenantID, moduleID, &store.IPRangeEntry{Prefix: prefix, CommunityID: cid})
|
|
||||||
if err != nil {
|
|
||||||
writeStoreErr(w, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
imported++
|
|
||||||
}
|
|
||||||
if imported > 0 {
|
|
||||||
s.enqueueModuleRefreshIfEnabled(a.TenantID, moduleID, "ip_range_import_csv")
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", "csv import/export is supported only for AS_PREFIXES, DOMAINS, IP_RANGES")
|
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", "csv import/export is supported only for AS_PREFIXES, DOMAINS, IP_RANGES")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
writeStoreErr(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if res.Imported > 0 {
|
||||||
|
switch res.ModuleType {
|
||||||
|
case "AS_PREFIXES":
|
||||||
|
s.enqueueModuleRefreshIfEnabled(a.TenantID, moduleID, "as_entry_import_csv")
|
||||||
|
case "DOMAINS":
|
||||||
|
s.enqueueModuleRefreshIfEnabled(a.TenantID, moduleID, "domain_entry_import_csv")
|
||||||
|
case "IP_RANGES":
|
||||||
|
s.enqueueModuleRefreshIfEnabled(a.TenantID, moduleID, "ip_range_import_csv")
|
||||||
|
}
|
||||||
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]any{
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
"imported": imported,
|
"imported": res.Imported,
|
||||||
"module_type": mod.Type,
|
"module_type": res.ModuleType,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ type Server struct {
|
|||||||
apiKeys []apiKeyRecord
|
apiKeys []apiKeyRecord
|
||||||
insecureDev bool
|
insecureDev bool
|
||||||
corsOrigins []string
|
corsOrigins []string
|
||||||
|
cdnHTTP *http.Client
|
||||||
mux *http.ServeMux
|
mux *http.ServeMux
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,6 +68,7 @@ func New(opts Options) (*Server, error) {
|
|||||||
apiKeys: parseAPIKeysSpec(opts.APIKeys),
|
apiKeys: parseAPIKeysSpec(opts.APIKeys),
|
||||||
insecureDev: opts.InsecureDev && opts.SeedDemo,
|
insecureDev: opts.InsecureDev && opts.SeedDemo,
|
||||||
corsOrigins: parseCORSOrigins(opts.CORSAllowedOrigins),
|
corsOrigins: parseCORSOrigins(opts.CORSAllowedOrigins),
|
||||||
|
cdnHTTP: NewCDNHTTPClient(),
|
||||||
}
|
}
|
||||||
s.mux = http.NewServeMux()
|
s.mux = http.NewServeMux()
|
||||||
s.registerRoutes()
|
s.registerRoutes()
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
|
|
||||||
"evobgp/internal/jobs"
|
"evobgp/internal/jobs"
|
||||||
"evobgp/internal/signing"
|
"evobgp/internal/signing"
|
||||||
|
"evobgp/internal/version"
|
||||||
)
|
)
|
||||||
|
|
||||||
const testBundleSeed = "0101010101010101010101010101010101010101010101010101010101010101"
|
const testBundleSeed = "0101010101010101010101010101010101010101010101010101010101010101"
|
||||||
@@ -28,7 +29,7 @@ func TestAPIRefreshApplyJobsBundle(t *testing.T) {
|
|||||||
}
|
}
|
||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
tenant, modCDN, modIP, rev, speaker := srv.Store().DemoIDs()
|
tenant, modCDN, modIP, rev, speaker := srv.Store().DemoIDs()
|
||||||
srv.apiKeys = parseAPIKeysSpec("nodekey|" + tenant + "|node,opkey|" + tenant + "|operator")
|
srv.apiKeys = parseAPIKeysSpec("nodekey|" + tenant + "|node,opkey|" + tenant + "|operator,edkey|" + tenant + "|editor")
|
||||||
|
|
||||||
ts := httptest.NewServer(srv.Handler())
|
ts := httptest.NewServer(srv.Handler())
|
||||||
defer ts.Close()
|
defer ts.Close()
|
||||||
@@ -247,6 +248,20 @@ func TestAPIRefreshApplyJobsBundle(t *testing.T) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
t.Run("rollback forbidden for editor", func(t *testing.T) {
|
||||||
|
req, _ := http.NewRequest(http.MethodPost, base+"/v1/revisions/"+rev+"/rollback", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer edkey")
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusForbidden {
|
||||||
|
b, _ := io.ReadAll(resp.Body)
|
||||||
|
t.Fatalf("status %d want 403: %s", resp.StatusCode, b)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
t.Run("rollback queues job", func(t *testing.T) {
|
t.Run("rollback queues job", func(t *testing.T) {
|
||||||
req, _ := http.NewRequest(http.MethodPost, base+"/v1/revisions/"+rev+"/rollback", nil)
|
req, _ := http.NewRequest(http.MethodPost, base+"/v1/revisions/"+rev+"/rollback", nil)
|
||||||
req.Header.Set("Authorization", "Bearer opkey")
|
req.Header.Set("Authorization", "Bearer opkey")
|
||||||
@@ -357,3 +372,60 @@ evobgp_p_01234567 BGP master start 2026-04-09 Connect
|
|||||||
t.Fatalf("expected protocol state Connect, got %q", got["evobgp_p_01234567"])
|
t.Fatalf("expected protocol state Connect, got %q", got["evobgp_p_01234567"])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestVersionEndpoints(t *testing.T) {
|
||||||
|
srv, err := New(Options{
|
||||||
|
InsecureDev: true,
|
||||||
|
SeedDemo: true,
|
||||||
|
BundleSeedHex: testBundleSeed,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
oldVer := version.Version
|
||||||
|
oldSHA := version.GitSHA
|
||||||
|
oldTime := version.BuildTime
|
||||||
|
version.Version = "1.2.3"
|
||||||
|
version.GitSHA = "abc123def456"
|
||||||
|
version.BuildTime = "2026-05-20T12:00:00Z"
|
||||||
|
t.Cleanup(func() {
|
||||||
|
version.Version = oldVer
|
||||||
|
version.GitSHA = oldSHA
|
||||||
|
version.BuildTime = oldTime
|
||||||
|
})
|
||||||
|
|
||||||
|
ts := httptest.NewServer(srv.Handler())
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
for _, path := range []string{"/version", "/v1/version"} {
|
||||||
|
t.Run(path, func(t *testing.T) {
|
||||||
|
resp, err := http.Get(ts.URL + path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
b, _ := io.ReadAll(resp.Body)
|
||||||
|
t.Fatalf("status %d: %s", resp.StatusCode, b)
|
||||||
|
}
|
||||||
|
var body map[string]string
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if body["version"] != "1.2.3" {
|
||||||
|
t.Fatalf("version=%q want 1.2.3", body["version"])
|
||||||
|
}
|
||||||
|
if body["api_version"] != "1.2.3" {
|
||||||
|
t.Fatalf("api_version=%q want 1.2.3", body["api_version"])
|
||||||
|
}
|
||||||
|
if body["git_sha"] != "abc123def456" {
|
||||||
|
t.Fatalf("git_sha=%q", body["git_sha"])
|
||||||
|
}
|
||||||
|
if body["build_time"] != "2026-05-20T12:00:00Z" {
|
||||||
|
t.Fatalf("build_time=%q", body["build_time"])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
package importer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/csv"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"evobgp/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Result of importing module entries from CSV.
|
||||||
|
type Result struct {
|
||||||
|
Imported int
|
||||||
|
ModuleType string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ImportModuleEntriesCSV parses and creates entries for AS_PREFIXES, DOMAINS, or IP_RANGES modules.
|
||||||
|
func ImportModuleEntriesCSV(st store.Backend, tenantID, moduleID string, body io.Reader) (*Result, error) {
|
||||||
|
mod, err := st.GetModule(tenantID, moduleID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
cr := csv.NewReader(io.LimitReader(body, 8<<20))
|
||||||
|
cr.TrimLeadingSpace = true
|
||||||
|
cr.FieldsPerRecord = -1
|
||||||
|
rows, err := cr.ReadAll()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("importer: invalid csv: %w", err)
|
||||||
|
}
|
||||||
|
if len(rows) == 0 {
|
||||||
|
return nil, fmt.Errorf("importer: %w", store.ErrInvalidInput)
|
||||||
|
}
|
||||||
|
|
||||||
|
communities, err := st.ListCommunities(tenantID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
communityIDByID := make(map[string]string, len(communities))
|
||||||
|
communityIDByValue := make(map[string]string, len(communities))
|
||||||
|
for _, c := range communities {
|
||||||
|
communityIDByID[c.ID] = c.ID
|
||||||
|
communityIDByValue[strings.TrimSpace(c.Community)] = c.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
resolveCommunity := func(raw string, required bool) (*string, error) {
|
||||||
|
v := strings.TrimSpace(raw)
|
||||||
|
if v == "" {
|
||||||
|
if required {
|
||||||
|
return nil, fmt.Errorf("community is required")
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if id, ok := communityIDByID[v]; ok {
|
||||||
|
return &id, nil
|
||||||
|
}
|
||||||
|
if id, ok := communityIDByValue[v]; ok {
|
||||||
|
return &id, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("unknown community %q", v)
|
||||||
|
}
|
||||||
|
|
||||||
|
start := 0
|
||||||
|
if len(rows[0]) >= 2 {
|
||||||
|
key := strings.ToLower(strings.TrimSpace(rows[0][0]))
|
||||||
|
switch key {
|
||||||
|
case "asn", "domain", "iprange":
|
||||||
|
start = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
imported := 0
|
||||||
|
switch mod.Type {
|
||||||
|
case "AS_PREFIXES":
|
||||||
|
for i := start; i < len(rows); i++ {
|
||||||
|
rec := rows[i]
|
||||||
|
if len(rec) == 0 || (strings.TrimSpace(rec[0]) == "" && (len(rec) < 2 || strings.TrimSpace(rec[1]) == "")) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(rec) < 2 {
|
||||||
|
return nil, fmt.Errorf("importer: line %d: expected 2 columns", i+1)
|
||||||
|
}
|
||||||
|
asn, err := strconv.ParseInt(strings.TrimSpace(rec[0]), 10, 64)
|
||||||
|
if err != nil || asn <= 0 {
|
||||||
|
return nil, fmt.Errorf("importer: line %d: invalid asn", i+1)
|
||||||
|
}
|
||||||
|
cid, err := resolveCommunity(rec[1], false)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("importer: line %d: %w", i+1, err)
|
||||||
|
}
|
||||||
|
if _, err := st.CreateASEntry(tenantID, moduleID, &store.ASEntry{ASN: asn, CommunityID: cid}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
imported++
|
||||||
|
}
|
||||||
|
case "DOMAINS":
|
||||||
|
for i := start; i < len(rows); i++ {
|
||||||
|
rec := rows[i]
|
||||||
|
if len(rec) == 0 || (strings.TrimSpace(rec[0]) == "" && (len(rec) < 2 || strings.TrimSpace(rec[1]) == "")) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(rec) < 2 {
|
||||||
|
return nil, fmt.Errorf("importer: line %d: expected 2 columns", i+1)
|
||||||
|
}
|
||||||
|
fqdn := strings.TrimSpace(rec[0])
|
||||||
|
if fqdn == "" {
|
||||||
|
return nil, fmt.Errorf("importer: line %d: domain is required", i+1)
|
||||||
|
}
|
||||||
|
cid, err := resolveCommunity(rec[1], false)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("importer: line %d: %w", i+1, err)
|
||||||
|
}
|
||||||
|
if _, err := st.CreateDomainEntry(tenantID, moduleID, &store.DomainEntry{FQDN: fqdn, CommunityID: cid}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
imported++
|
||||||
|
}
|
||||||
|
case "IP_RANGES":
|
||||||
|
for i := start; i < len(rows); i++ {
|
||||||
|
rec := rows[i]
|
||||||
|
if len(rec) == 0 || (strings.TrimSpace(rec[0]) == "" && (len(rec) < 2 || strings.TrimSpace(rec[1]) == "")) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(rec) < 2 {
|
||||||
|
return nil, fmt.Errorf("importer: line %d: expected 2 columns", i+1)
|
||||||
|
}
|
||||||
|
prefix := strings.TrimSpace(rec[0])
|
||||||
|
if prefix == "" {
|
||||||
|
return nil, fmt.Errorf("importer: line %d: ipRange is required", i+1)
|
||||||
|
}
|
||||||
|
cid, err := resolveCommunity(rec[1], true)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("importer: line %d: %w", i+1, err)
|
||||||
|
}
|
||||||
|
if _, err := st.CreateIPRangeEntry(tenantID, moduleID, &store.IPRangeEntry{Prefix: prefix, CommunityID: cid}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
imported++
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("importer: csv import/export is supported only for AS_PREFIXES, DOMAINS, IP_RANGES")
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Result{Imported: imported, ModuleType: mod.Type}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
package nodecli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/ed25519"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"evobgp/internal/birdfmt"
|
||||||
|
"evobgp/internal/bundle"
|
||||||
|
"evobgp/internal/signing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CmdPullBundle downloads a signed bundle from the control plane API.
|
||||||
|
func CmdPullBundle(args []string) int {
|
||||||
|
fs := flag.NewFlagSet("pull-bundle", flag.ExitOnError)
|
||||||
|
base := fs.String("base-url", "", "control plane base URL, e.g. http://localhost:8080")
|
||||||
|
token := fs.String("token", "", "Bearer token (node role)")
|
||||||
|
speaker := fs.String("speaker-id", "", "bgp_speaker id")
|
||||||
|
revision := fs.String("revision-id", "", "revision to fetch (empty = latest pointer)")
|
||||||
|
out := fs.String("o", "bundle.tar.gz", "output file")
|
||||||
|
_ = fs.Parse(args)
|
||||||
|
|
||||||
|
if strings.TrimSpace(*base) == "" || *token == "" || *speaker == "" {
|
||||||
|
fmt.Fprintln(os.Stderr, "pull-bundle: -base-url, -token, -speaker-id are required")
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
rev := strings.TrimSpace(*revision)
|
||||||
|
if rev == "" {
|
||||||
|
var err error
|
||||||
|
rev, err = fetchLatestRevision(*base, *token, *speaker)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
body, err := fetchBundle(*base, *token, *speaker, rev)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(*out, body, 0o644); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
fmt.Fprintf(os.Stderr, "wrote %s (revision %s)\n", *out, rev)
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchLatestRevision(base, token, speaker string) (string, error) {
|
||||||
|
u := strings.TrimRight(base, "/") + "/v1/speakers/" + speaker + "/revisions/latest"
|
||||||
|
req, err := http.NewRequest(http.MethodGet, u, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer 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)))
|
||||||
|
}
|
||||||
|
var out struct {
|
||||||
|
RevisionID string `json:"revision_id"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if out.RevisionID == "" {
|
||||||
|
return "", fmt.Errorf("empty revision_id in response")
|
||||||
|
}
|
||||||
|
return out.RevisionID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchBundle(base, token, speaker, revision string) ([]byte, error) {
|
||||||
|
u := strings.TrimRight(base, "/") + "/v1/speakers/" + speaker + "/bundle/" + revision
|
||||||
|
req, err := http.NewRequest(http.MethodGet, u, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer 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)))
|
||||||
|
}
|
||||||
|
return io.ReadAll(resp.Body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadPubKey(pubB64, pubHex string) (ed25519.PublicKey, error) {
|
||||||
|
switch {
|
||||||
|
case strings.TrimSpace(pubB64) != "":
|
||||||
|
raw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(pubB64))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(raw) != ed25519.PublicKeySize {
|
||||||
|
return nil, fmt.Errorf("pubkey-base64 must decode to %d bytes", ed25519.PublicKeySize)
|
||||||
|
}
|
||||||
|
return ed25519.PublicKey(raw), nil
|
||||||
|
case strings.TrimSpace(pubHex) != "":
|
||||||
|
return bundle.ParsePublicKeyHex(pubHex)
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("public key required")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CmdVerifyBundle checks bundle signature and manifest.
|
||||||
|
func CmdVerifyBundle(args []string) int {
|
||||||
|
fs := flag.NewFlagSet("verify-bundle", flag.ExitOnError)
|
||||||
|
path := fs.String("f", "", "path to bundle.tar.gz")
|
||||||
|
pubB64 := fs.String("pubkey-base64", "", "Ed25519 public key (base64)")
|
||||||
|
pubHex := fs.String("pubkey-hex", "", "Ed25519 public key (64 hex chars)")
|
||||||
|
_ = fs.Parse(args)
|
||||||
|
|
||||||
|
if *path == "" {
|
||||||
|
fmt.Fprintln(os.Stderr, "verify-bundle: -f required")
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
pub, err := loadPubKey(*pubB64, *pubHex)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
raw, err := os.ReadFile(*path)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
v, err := signing.VerifyGzippedTar(raw, pub)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
fmt.Fprintf(os.Stderr, "ok: revision %s, %d payload files\n", v.Manifest.RevisionID, len(v.Files))
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// CmdApplyBundle verifies, extracts, parse-checks, and configures BIRD from a bundle.
|
||||||
|
func CmdApplyBundle(args []string) int {
|
||||||
|
fs := flag.NewFlagSet("apply-bundle", flag.ExitOnError)
|
||||||
|
path := fs.String("f", "", "path to bundle.tar.gz")
|
||||||
|
dir := fs.String("extract-dir", "", "directory to extract into")
|
||||||
|
pubB64 := fs.String("pubkey-base64", "", "Ed25519 public key (base64)")
|
||||||
|
pubHex := fs.String("pubkey-hex", "", "Ed25519 public key (64 hex chars)")
|
||||||
|
bird := fs.String("bird", "", "bird binary (default PATH)")
|
||||||
|
birdc := fs.String("birdc", "", "birdc binary (default PATH)")
|
||||||
|
socket := fs.String("socket", "", "birdc -s socket path")
|
||||||
|
timeout := fs.Duration("timeout", 30*time.Second, "timeout for bird/birdc")
|
||||||
|
_ = fs.Parse(args)
|
||||||
|
|
||||||
|
if *path == "" || *dir == "" {
|
||||||
|
fmt.Fprintln(os.Stderr, "apply-bundle: -f and -extract-dir required")
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
pub, err := loadPubKey(*pubB64, *pubHex)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
raw, err := os.ReadFile(*path)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
v, err := signing.VerifyGzippedTar(raw, pub)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
root := filepath.Clean(*dir)
|
||||||
|
if err := os.MkdirAll(root, 0o755); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
if err := bundle.WriteExtractedFiles(root, v); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
mainRel := v.FindMainBirdConf()
|
||||||
|
if mainRel == "" {
|
||||||
|
fmt.Fprintln(os.Stderr, "bundle has no bird.conf path in manifest")
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
mainPath := filepath.Join(root, filepath.FromSlash(strings.TrimPrefix(mainRel, "/")))
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), *timeout)
|
||||||
|
defer cancel()
|
||||||
|
ctl := &birdfmt.BirdCtl{Bird: *bird, Birdc: *birdc, Socket: *socket}
|
||||||
|
if err := ctl.ParseCheck(ctx, mainPath); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
if err := ctl.Configure(ctx); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
fmt.Fprintf(os.Stderr, "applied revision %s (main config %s)\n", v.Manifest.RevisionID, mainPath)
|
||||||
|
return 0
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
// Package nodecli implements evobgp-node subcommands (pull/verify/apply bundle).
|
||||||
|
package nodecli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Run executes a subcommand from args (without program name). Returns exit code.
|
||||||
|
func Run(args []string) int {
|
||||||
|
if len(args) < 1 {
|
||||||
|
Usage(os.Stderr)
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
switch args[0] {
|
||||||
|
case "pull-bundle":
|
||||||
|
return CmdPullBundle(args[1:])
|
||||||
|
case "verify-bundle":
|
||||||
|
return CmdVerifyBundle(args[1:])
|
||||||
|
case "apply-bundle":
|
||||||
|
return CmdApplyBundle(args[1:])
|
||||||
|
default:
|
||||||
|
Usage(os.Stderr)
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usage prints CLI help to w.
|
||||||
|
func Usage(w interface{ Write([]byte) (int, error) }) {
|
||||||
|
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)
|
||||||
|
[-bird PATH] [-birdc PATH] [-socket PATH] [-timeout DURATION]
|
||||||
|
|
||||||
|
apply-bundle verifies, extracts, runs bird -p on main bird.conf, then birdc configure.
|
||||||
|
`)
|
||||||
|
}
|
||||||
@@ -59,8 +59,42 @@ var (
|
|||||||
Name: "build_info",
|
Name: "build_info",
|
||||||
Help: "Build metadata (value always 1).",
|
Help: "Build metadata (value always 1).",
|
||||||
}, []string{"version", "git_sha"})
|
}, []string{"version", "git_sha"})
|
||||||
|
|
||||||
|
prefixAggregationDuration = promauto.NewHistogram(prometheus.HistogramOpts{
|
||||||
|
Namespace: namespace,
|
||||||
|
Name: "prefix_aggregation_duration_seconds",
|
||||||
|
Help: "Time spent in smartAggregatePrefixRows during tenant render.",
|
||||||
|
Buckets: prometheus.ExponentialBuckets(0.0001, 2, 16),
|
||||||
|
})
|
||||||
|
|
||||||
|
prefixAggregationRawCount = promauto.NewHistogram(prometheus.HistogramOpts{
|
||||||
|
Namespace: namespace,
|
||||||
|
Name: "prefix_aggregation_raw_count",
|
||||||
|
Help: "Prefix row count before CIDR aggregation on tenant render.",
|
||||||
|
Buckets: prometheus.ExponentialBuckets(1, 2, 16),
|
||||||
|
})
|
||||||
|
|
||||||
|
prefixAggregationAggregatedCount = promauto.NewHistogram(prometheus.HistogramOpts{
|
||||||
|
Namespace: namespace,
|
||||||
|
Name: "prefix_aggregation_aggregated_count",
|
||||||
|
Help: "Prefix row count after CIDR aggregation on tenant render.",
|
||||||
|
Buckets: prometheus.ExponentialBuckets(1, 2, 16),
|
||||||
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// RecordPrefixAggregation records tenant render CIDR aggregation stats.
|
||||||
|
func RecordPrefixAggregation(rawCount, aggregatedCount int, duration time.Duration) {
|
||||||
|
if rawCount < 0 {
|
||||||
|
rawCount = 0
|
||||||
|
}
|
||||||
|
if aggregatedCount < 0 {
|
||||||
|
aggregatedCount = 0
|
||||||
|
}
|
||||||
|
prefixAggregationDuration.Observe(duration.Seconds())
|
||||||
|
prefixAggregationRawCount.Observe(float64(rawCount))
|
||||||
|
prefixAggregationAggregatedCount.Observe(float64(aggregatedCount))
|
||||||
|
}
|
||||||
|
|
||||||
// RecordJobTerminal increments jobs_finished_total for terminal statuses.
|
// RecordJobTerminal increments jobs_finished_total for terminal statuses.
|
||||||
func RecordJobTerminal(kind, status string) {
|
func RecordJobTerminal(kind, status string) {
|
||||||
switch status {
|
switch status {
|
||||||
|
|||||||
@@ -97,3 +97,96 @@ func TestCollectModulePrefixRows_CDN304UsesSnapshot(t *testing.T) {
|
|||||||
t.Fatalf("want cached prefix on 304, got %+v", collected)
|
t.Fatalf("want cached prefix on 304, got %+v", collected)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRefreshModuleIngest_CDN304UsesStoredSnapshot(t *testing.T) {
|
||||||
|
m := store.NewMemory()
|
||||||
|
m.SeedDemo()
|
||||||
|
tenant, _, _, _, _ := m.DemoIDs()
|
||||||
|
|
||||||
|
mod, err := m.CreateModule(tenant, &store.Module{
|
||||||
|
Type: "CDN_CIDRS",
|
||||||
|
Name: "cdn-304-ingest",
|
||||||
|
Enabled: true,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusNotModified)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
etag := "etag-stable"
|
||||||
|
src, err := m.CreateCDNSource(tenant, mod.ID, &store.CDNSource{
|
||||||
|
SourceKind: "txt",
|
||||||
|
URL: srv.URL,
|
||||||
|
Etag: etag,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
prior := []store.PrefixRow{{
|
||||||
|
Prefix: "203.0.113.0/24",
|
||||||
|
Source: cdnSourceKey(src.ID),
|
||||||
|
}}
|
||||||
|
if err := mergeCDNSourceIntoModuleSnapshot(m, tenant, mod, src.ID, prior); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := RefreshModuleIngest(context.Background(), m, srv.Client(), tenant, mod.ID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCollectModulePrefixRows_CDN304RetriesWithoutETag(t *testing.T) {
|
||||||
|
m := store.NewMemory()
|
||||||
|
m.SeedDemo()
|
||||||
|
tenant, _, _, _, _ := m.DemoIDs()
|
||||||
|
|
||||||
|
mod, err := m.CreateModule(tenant, &store.Module{
|
||||||
|
Type: "CDN_CIDRS",
|
||||||
|
Name: "cdn-304-retry",
|
||||||
|
Enabled: true,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var calls int
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
calls++
|
||||||
|
if calls == 1 {
|
||||||
|
if got := strings.TrimSpace(r.Header.Get("If-None-Match")); got != "etag-stable" {
|
||||||
|
t.Fatalf("first request want If-None-Match etag-stable, got %q", got)
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNotModified)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if got := strings.TrimSpace(r.Header.Get("If-None-Match")); got != "" {
|
||||||
|
t.Fatalf("retry must omit If-None-Match, got %q", got)
|
||||||
|
}
|
||||||
|
w.Header().Set("ETag", "etag-stable")
|
||||||
|
_, _ = w.Write([]byte("198.51.100.0/24\n"))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
if _, err := m.CreateCDNSource(tenant, mod.ID, &store.CDNSource{
|
||||||
|
SourceKind: "txt",
|
||||||
|
URL: srv.URL,
|
||||||
|
Etag: "etag-stable",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
collected, err := collectModulePrefixRows(context.Background(), m, srv.Client(), tenant, mod, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if calls != 2 {
|
||||||
|
t.Fatalf("want 2 HTTP calls (304 then 200), got %d", calls)
|
||||||
|
}
|
||||||
|
if len(collected) != 1 || collected[0].Prefix != "198.51.100.0/24" {
|
||||||
|
t.Fatalf("unexpected collected rows: %+v", collected)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,23 @@ func cdnSourceKey(sourceID string) string {
|
|||||||
return "cdn:" + strings.TrimSpace(sourceID)
|
return "cdn:" + strings.TrimSpace(sourceID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func cachedCDNPrefixRows(st store.Backend, tenantID, moduleID string, priorSnapshot []store.PrefixRow, sourceKey string) []store.PrefixRow {
|
||||||
|
if cached := prefixRowsForSource(priorSnapshot, sourceKey); len(cached) > 0 {
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
if st != nil {
|
||||||
|
if snap, ok, _ := st.GetModulePrefixSnapshot(tenantID, moduleID); ok && snap != nil {
|
||||||
|
if cached := prefixRowsForSource(snap.Prefixes, sourceKey); len(cached) > 0 {
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if cached := latestCDNRowsBySource(st, tenantID)[sourceKey]; len(cached) > 0 {
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func mergeSnapshotDropSource(rows []store.PrefixRow, sourceKey string) []store.PrefixRow {
|
func mergeSnapshotDropSource(rows []store.PrefixRow, sourceKey string) []store.PrefixRow {
|
||||||
if len(rows) == 0 {
|
if len(rows) == 0 {
|
||||||
return nil
|
return nil
|
||||||
@@ -88,14 +105,29 @@ func applyCDNSourceHTTPResult(ctx context.Context, st store.Backend, hc *http.Cl
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("cdn fetch %s: %w", u, err)
|
return nil, fmt.Errorf("cdn fetch %s: %w", u, err)
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode == http.StatusNotModified {
|
if resp.StatusCode == http.StatusNotModified {
|
||||||
if cached := prefixRowsForSource(priorSnapshot, sourceKey); len(cached) > 0 {
|
if cached := cachedCDNPrefixRows(st, tenantID, moduleID, priorSnapshot, sourceKey); len(cached) > 0 {
|
||||||
|
_ = resp.Body.Close()
|
||||||
return cached, nil
|
return cached, nil
|
||||||
}
|
}
|
||||||
|
// ETag is known but local snapshot is empty — force a full download.
|
||||||
|
_ = resp.Body.Close()
|
||||||
|
req2, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
resp, err = hc.Do(req2)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("cdn fetch %s: %w", u, err)
|
||||||
|
}
|
||||||
|
if resp.StatusCode == http.StatusNotModified {
|
||||||
|
_ = resp.Body.Close()
|
||||||
return nil, fmt.Errorf("cdn url %s: 304 without cached prefixes", u)
|
return nil, fmt.Errorf("cdn url %s: 304 without cached prefixes", u)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
_, _ = io.Copy(io.Discard, resp.Body)
|
_, _ = io.Copy(io.Discard, resp.Body)
|
||||||
return nil, fmt.Errorf("cdn url %s: %s", u, resp.Status)
|
return nil, fmt.Errorf("cdn url %s: %s", u, resp.Status)
|
||||||
|
|||||||
@@ -175,7 +175,7 @@ func collectCDNPrefixRows(ctx context.Context, st store.Backend, hc *http.Client
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func collectDomainPrefixRows(ctx context.Context, hc *http.Client, mod *store.Module, profile *store.DohProfile, entries []*store.DomainEntry) ([]store.PrefixRow, error) {
|
func collectDomainPrefixRows(ctx context.Context, hc *http.Client, mod *store.Module, profiles []*store.DohProfile, policy string, entries []*store.DomainEntry) ([]store.PrefixRow, error) {
|
||||||
var validDom []*store.DomainEntry
|
var validDom []*store.DomainEntry
|
||||||
for _, e := range entries {
|
for _, e := range entries {
|
||||||
if e != nil {
|
if e != nil {
|
||||||
@@ -202,7 +202,7 @@ func collectDomainPrefixRows(ctx context.Context, hc *http.Client, mod *store.Mo
|
|||||||
c := *mod.DefaultCommunityID
|
c := *mod.DefaultCommunityID
|
||||||
comm = &c
|
comm = &c
|
||||||
}
|
}
|
||||||
addrs, err := resolveDomainIPs(ctx, hc, profile, entry.FQDN)
|
addrs, err := resolveDomainIPsWithPolicy(ctx, hc, profiles, policy, entry.FQDN)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
results[idx] = domResult{err: fmt.Errorf("resolve domain %q: %w", entry.FQDN, err)}
|
results[idx] = domResult{err: fmt.Errorf("resolve domain %q: %w", entry.FQDN, err)}
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
// Package pipeline ingests module inputs (CDN, ASN, domains, IP ranges), materializes
|
||||||
|
// prefixes, and creates BIRD preview revisions via store.
|
||||||
|
package pipeline
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
package pipeline
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/netip"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"evobgp/internal/store"
|
||||||
|
|
||||||
|
"github.com/miekg/dns"
|
||||||
|
)
|
||||||
|
|
||||||
|
func loadModuleDohProfiles(st store.Backend, tenantID string, mod *store.Module) ([]*store.DohProfile, string, error) {
|
||||||
|
if mod == nil {
|
||||||
|
return nil, store.DohPolicyPrimaryOnly, nil
|
||||||
|
}
|
||||||
|
policy := store.NormalizeDohResolverPolicy(mod.DohResolverPolicy)
|
||||||
|
var profiles []*store.DohProfile
|
||||||
|
for _, id := range mod.EffectiveDohProfileIDs() {
|
||||||
|
prof, err := st.GetDohProfile(tenantID, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", fmt.Errorf("get doh profile %s: %w", id, err)
|
||||||
|
}
|
||||||
|
profiles = append(profiles, prof)
|
||||||
|
}
|
||||||
|
return profiles, policy, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveDomainIPsWithPolicy(ctx context.Context, hc *http.Client, profiles []*store.DohProfile, policy, fqdn string) ([]netip.Addr, error) {
|
||||||
|
policy = store.NormalizeDohResolverPolicy(policy)
|
||||||
|
if len(profiles) == 0 {
|
||||||
|
return resolveDomainIPs(ctx, hc, nil, fqdn)
|
||||||
|
}
|
||||||
|
if len(profiles) == 1 {
|
||||||
|
return resolveDomainIPs(ctx, hc, profiles[0], fqdn)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch policy {
|
||||||
|
case store.DohPolicyUnion:
|
||||||
|
return resolveDomainIPsUnion(ctx, hc, profiles, fqdn)
|
||||||
|
case store.DohPolicyFailover:
|
||||||
|
return resolveDomainIPsFailover(ctx, hc, profiles, fqdn)
|
||||||
|
default:
|
||||||
|
return resolveDomainIPs(ctx, hc, profiles[0], fqdn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveDomainIPsUnion(ctx context.Context, hc *http.Client, profiles []*store.DohProfile, fqdn string) ([]netip.Addr, error) {
|
||||||
|
var merged []netip.Addr
|
||||||
|
var errs []error
|
||||||
|
for _, prof := range profiles {
|
||||||
|
if prof == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ips, err := resolveDomainIPsNoSystemFallback(ctx, hc, prof, fqdn)
|
||||||
|
if err != nil {
|
||||||
|
errs = append(errs, fmt.Errorf("%s: %w", strings.TrimSpace(prof.URL), err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
merged = append(merged, ips...)
|
||||||
|
}
|
||||||
|
merged = uniqAddrs(merged)
|
||||||
|
if len(merged) > 0 {
|
||||||
|
return merged, nil
|
||||||
|
}
|
||||||
|
if len(errs) > 0 {
|
||||||
|
return nil, fmt.Errorf("doh union failed: %v", errs)
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveDomainIPsFailover(ctx context.Context, hc *http.Client, profiles []*store.DohProfile, fqdn string) ([]netip.Addr, error) {
|
||||||
|
var lastErr error
|
||||||
|
for _, prof := range profiles {
|
||||||
|
if prof == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ips, err := resolveDomainIPsNoSystemFallback(ctx, hc, prof, fqdn)
|
||||||
|
if err != nil {
|
||||||
|
lastErr = err
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(ips) > 0 {
|
||||||
|
return ips, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if lastErr != nil {
|
||||||
|
return nil, lastErr
|
||||||
|
}
|
||||||
|
return resolveDomainIPs(ctx, hc, nil, fqdn)
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveDomainIPsNoSystemFallback queries one DoH profile without falling back to OS resolver.
|
||||||
|
func resolveDomainIPsNoSystemFallback(ctx context.Context, hc *http.Client, profile *store.DohProfile, fqdn string) ([]netip.Addr, error) {
|
||||||
|
host := strings.TrimSpace(strings.TrimSuffix(fqdn, "."))
|
||||||
|
if host == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if profile == nil || strings.TrimSpace(profile.URL) == "" {
|
||||||
|
return nil, fmt.Errorf("empty doh profile")
|
||||||
|
}
|
||||||
|
|
||||||
|
timeout := dohProfileTimeout(profile)
|
||||||
|
dctx, cancel := context.WithTimeout(ctx, timeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
baseURL := strings.TrimSpace(profile.URL)
|
||||||
|
v4, err4 := resolveDomainWithDOHMessage(dctx, hc, baseURL, host, dns.TypeA)
|
||||||
|
v6, err6 := resolveDomainWithDOHMessage(dctx, hc, baseURL, host, dns.TypeAAAA)
|
||||||
|
if err4 != nil {
|
||||||
|
v4, err4 = resolveDomainWithDOHJSON(dctx, hc, baseURL, host, "A")
|
||||||
|
}
|
||||||
|
if err6 != nil {
|
||||||
|
v6, err6 = resolveDomainWithDOHJSON(dctx, hc, baseURL, host, "AAAA")
|
||||||
|
}
|
||||||
|
if err4 != nil && err6 != nil {
|
||||||
|
return nil, fmt.Errorf("doh failed for A and AAAA: %v; %v", err4, err6)
|
||||||
|
}
|
||||||
|
return uniqAddrs(append(v4, v6...)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func dohProfileTimeout(profile *store.DohProfile) time.Duration {
|
||||||
|
timeout := 10 * time.Second
|
||||||
|
if profile != nil && profile.TimeoutMs != nil && *profile.TimeoutMs > 0 {
|
||||||
|
timeout = time.Duration(*profile.TimeoutMs) * time.Millisecond
|
||||||
|
}
|
||||||
|
return timeout
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
package pipeline
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"evobgp/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestResolveDomainIPsWithPolicy_Union(t *testing.T) {
|
||||||
|
srvRU := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_, _ = w.Write([]byte(`{"Answer":[{"type":1,"data":"198.51.100.1"}]}`))
|
||||||
|
}))
|
||||||
|
defer srvRU.Close()
|
||||||
|
srvEU := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_, _ = w.Write([]byte(`{"Answer":[{"type":1,"data":"203.0.113.1"}]}`))
|
||||||
|
}))
|
||||||
|
defer srvEU.Close()
|
||||||
|
|
||||||
|
profiles := []*store.DohProfile{
|
||||||
|
{URL: srvRU.URL},
|
||||||
|
{URL: srvEU.URL},
|
||||||
|
}
|
||||||
|
ips, err := resolveDomainIPsWithPolicy(context.Background(), srvRU.Client(), profiles, store.DohPolicyUnion, "example.com")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(ips) != 2 {
|
||||||
|
t.Fatalf("want 2 ips, got %v", ips)
|
||||||
|
}
|
||||||
|
seen := map[string]bool{ips[0].String(): true, ips[1].String(): true}
|
||||||
|
if !seen["198.51.100.1"] || !seen["203.0.113.1"] {
|
||||||
|
t.Fatalf("unexpected ips: %v", ips)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveDomainIPsWithPolicy_Failover(t *testing.T) {
|
||||||
|
var calls int
|
||||||
|
srvBad := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
calls++
|
||||||
|
http.Error(w, "fail", http.StatusBadGateway)
|
||||||
|
}))
|
||||||
|
defer srvBad.Close()
|
||||||
|
srvOK := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
calls++
|
||||||
|
_, _ = w.Write([]byte(`{"Answer":[{"type":1,"data":"198.51.100.5"}]}`))
|
||||||
|
}))
|
||||||
|
defer srvOK.Close()
|
||||||
|
|
||||||
|
profiles := []*store.DohProfile{
|
||||||
|
{URL: srvBad.URL},
|
||||||
|
{URL: srvOK.URL},
|
||||||
|
}
|
||||||
|
ips, err := resolveDomainIPsWithPolicy(context.Background(), srvBad.Client(), profiles, store.DohPolicyFailover, "example.com")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(ips) != 1 || ips[0].String() != "198.51.100.5" {
|
||||||
|
t.Fatalf("unexpected ips: %v", ips)
|
||||||
|
}
|
||||||
|
if calls < 2 {
|
||||||
|
t.Fatalf("want at least 2 resolver calls, got %d", calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveDomainIPsWithPolicy_PrimaryOnly(t *testing.T) {
|
||||||
|
var secondCalled bool
|
||||||
|
srv1 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_, _ = w.Write([]byte(`{"Answer":[{"type":1,"data":"198.51.100.9"}]}`))
|
||||||
|
}))
|
||||||
|
defer srv1.Close()
|
||||||
|
srv2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
secondCalled = true
|
||||||
|
_, _ = w.Write([]byte(`{"Answer":[{"type":1,"data":"203.0.113.9"}]}`))
|
||||||
|
}))
|
||||||
|
defer srv2.Close()
|
||||||
|
|
||||||
|
profiles := []*store.DohProfile{
|
||||||
|
{URL: srv1.URL},
|
||||||
|
{URL: srv2.URL},
|
||||||
|
}
|
||||||
|
ips, err := resolveDomainIPsWithPolicy(context.Background(), srv1.Client(), profiles, store.DohPolicyPrimaryOnly, "example.com")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(ips) != 1 || ips[0].String() != "198.51.100.9" {
|
||||||
|
t.Fatalf("unexpected ips: %v", ips)
|
||||||
|
}
|
||||||
|
if secondCalled {
|
||||||
|
t.Fatal("secondary resolver must not be queried in primary_only mode")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCollectModulePrefixRows_DohUnion(t *testing.T) {
|
||||||
|
m := store.NewMemory()
|
||||||
|
m.SeedDemo()
|
||||||
|
tenant, _, _, _, _ := m.DemoIDs()
|
||||||
|
|
||||||
|
mod, err := m.CreateModule(tenant, &store.Module{
|
||||||
|
Type: "DOMAINS",
|
||||||
|
Name: "domains-union",
|
||||||
|
Enabled: true,
|
||||||
|
DohResolverPolicy: store.DohPolicyUnion,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
srvRU := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_, _ = w.Write([]byte(`{"Answer":[{"type":1,"data":"198.51.100.2"}]}`))
|
||||||
|
}))
|
||||||
|
defer srvRU.Close()
|
||||||
|
srvEU := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_, _ = w.Write([]byte(`{"Answer":[{"type":1,"data":"203.0.113.2"}]}`))
|
||||||
|
}))
|
||||||
|
defer srvEU.Close()
|
||||||
|
|
||||||
|
ru, err := m.CreateDohProfile(tenant, &store.DohProfile{Name: "ru", URL: srvRU.URL})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
eu, err := m.CreateDohProfile(tenant, &store.DohProfile{Name: "eu", URL: srvEU.URL})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := m.UpdateModule(tenant, mod.ID, &store.ModulePatch{
|
||||||
|
DohProfileIDs: &[]string{ru.ID, eu.ID},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
mod, err = m.GetModule(tenant, mod.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := m.CreateDomainEntry(tenant, mod.ID, &store.DomainEntry{FQDN: "svc.example.com"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := collectModulePrefixRows(context.Background(), m, srvRU.Client(), tenant, mod, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(rows) != 2 {
|
||||||
|
t.Fatalf("want 2 prefix rows, got %+v", rows)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,7 +13,7 @@ import (
|
|||||||
// moduleIngestInputHash fingerprints module config and child entries so snapshots invalidate on CRUD.
|
// moduleIngestInputHash fingerprints module config and child entries so snapshots invalidate on CRUD.
|
||||||
func moduleIngestInputHash(st store.Backend, tenantID string, mod *store.Module) (string, error) {
|
func moduleIngestInputHash(st store.Backend, tenantID string, mod *store.Module) (string, error) {
|
||||||
if st == nil || mod == nil {
|
if st == nil || mod == nil {
|
||||||
return "", fmt.Errorf("module hash: missing store or module")
|
return "", fmt.Errorf("pipeline: module hash: missing store or module")
|
||||||
}
|
}
|
||||||
h := sha256.New()
|
h := sha256.New()
|
||||||
_, _ = fmt.Fprintf(h, "type=%s\n", strings.TrimSpace(mod.Type))
|
_, _ = fmt.Fprintf(h, "type=%s\n", strings.TrimSpace(mod.Type))
|
||||||
@@ -21,9 +21,9 @@ func moduleIngestInputHash(st store.Backend, tenantID string, mod *store.Module)
|
|||||||
if mod.DefaultCommunityID != nil {
|
if mod.DefaultCommunityID != nil {
|
||||||
_, _ = fmt.Fprintf(h, "default_community=%s\n", strings.TrimSpace(*mod.DefaultCommunityID))
|
_, _ = fmt.Fprintf(h, "default_community=%s\n", strings.TrimSpace(*mod.DefaultCommunityID))
|
||||||
}
|
}
|
||||||
if mod.DohProfileID != nil {
|
_, _ = fmt.Fprintf(h, "doh_policy=%s\n", store.NormalizeDohResolverPolicy(mod.DohResolverPolicy))
|
||||||
_, _ = fmt.Fprintf(h, "doh_profile=%s\n", strings.TrimSpace(*mod.DohProfileID))
|
for _, pid := range mod.EffectiveDohProfileIDs() {
|
||||||
if pid := strings.TrimSpace(*mod.DohProfileID); pid != "" {
|
_, _ = fmt.Fprintf(h, "doh_profile=%s\n", pid)
|
||||||
if prof, err := st.GetDohProfile(tenantID, pid); err == nil && prof != nil {
|
if prof, err := st.GetDohProfile(tenantID, pid); err == nil && prof != nil {
|
||||||
_, _ = fmt.Fprintf(h, "doh_url=%s\n", strings.TrimSpace(prof.URL))
|
_, _ = fmt.Fprintf(h, "doh_url=%s\n", strings.TrimSpace(prof.URL))
|
||||||
if prof.TimeoutMs != nil {
|
if prof.TimeoutMs != nil {
|
||||||
@@ -31,7 +31,6 @@ func moduleIngestInputHash(st store.Backend, tenantID string, mod *store.Module)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
switch mod.Type {
|
switch mod.Type {
|
||||||
case "IP_RANGES":
|
case "IP_RANGES":
|
||||||
|
|||||||
+171
-58
@@ -8,6 +8,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"math/big"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
@@ -18,6 +19,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"evobgp/internal/birdfmt"
|
"evobgp/internal/birdfmt"
|
||||||
|
"evobgp/internal/observability"
|
||||||
"evobgp/internal/store"
|
"evobgp/internal/store"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
@@ -50,10 +52,14 @@ func RefreshModuleIngest(ctx context.Context, st store.Backend, hc *http.Client,
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if !mod.Enabled {
|
if !mod.Enabled {
|
||||||
return fmt.Errorf("module disabled")
|
return fmt.Errorf("pipeline: module disabled")
|
||||||
}
|
}
|
||||||
|
|
||||||
rows, err := collectModulePrefixRows(ctx, st, hc, tenantID, mod, nil)
|
var prior []store.PrefixRow
|
||||||
|
if snap, ok, _ := st.GetModulePrefixSnapshot(tenantID, moduleID); ok && snap != nil {
|
||||||
|
prior = snap.Prefixes
|
||||||
|
}
|
||||||
|
rows, err := collectModulePrefixRows(ctx, st, hc, tenantID, mod, prior)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -75,11 +81,14 @@ func RenderTenantRevision(ctx context.Context, st store.Backend, hc *http.Client
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
rawCount := len(agg)
|
||||||
|
aggStart := time.Now()
|
||||||
|
agg = smartAggregatePrefixRows(agg)
|
||||||
|
observability.RecordPrefixAggregation(rawCount, len(agg), time.Since(aggStart))
|
||||||
hash := hashAggregatedMaterializationWithPeers(st, tenantID, agg)
|
hash := hashAggregatedMaterializationWithPeers(st, tenantID, agg)
|
||||||
if prev := latestTenantRevision(st, tenantID); prev != nil && prev.ContentHash == hash {
|
if prev := latestTenantRevision(st, tenantID); prev != nil && prev.ContentHash == hash {
|
||||||
return prev.ID, nil
|
return prev.ID, nil
|
||||||
}
|
}
|
||||||
agg = smartAggregatePrefixRows(agg)
|
|
||||||
|
|
||||||
revisionID = uuid.NewString()
|
revisionID = uuid.NewString()
|
||||||
parent := parentRevision(st, tenantID, triggerModuleID)
|
parent := parentRevision(st, tenantID, triggerModuleID)
|
||||||
@@ -102,11 +111,14 @@ func RenderTenantRevisionFromPrefixes(ctx context.Context, st store.Backend, hc
|
|||||||
hc = http.DefaultClient
|
hc = http.DefaultClient
|
||||||
}
|
}
|
||||||
agg := append([]store.PrefixRow(nil), rows...)
|
agg := append([]store.PrefixRow(nil), rows...)
|
||||||
|
rawCount := len(agg)
|
||||||
|
aggStart := time.Now()
|
||||||
|
agg = smartAggregatePrefixRows(agg)
|
||||||
|
observability.RecordPrefixAggregation(rawCount, len(agg), time.Since(aggStart))
|
||||||
hash := hashAggregatedMaterializationWithPeers(st, tenantID, agg)
|
hash := hashAggregatedMaterializationWithPeers(st, tenantID, agg)
|
||||||
if prev := latestTenantRevision(st, tenantID); prev != nil && prev.ContentHash == hash {
|
if prev := latestTenantRevision(st, tenantID); prev != nil && prev.ContentHash == hash {
|
||||||
return prev.ID, nil
|
return prev.ID, nil
|
||||||
}
|
}
|
||||||
agg = smartAggregatePrefixRows(agg)
|
|
||||||
|
|
||||||
revisionID = uuid.NewString()
|
revisionID = uuid.NewString()
|
||||||
parent := parentRevision(st, tenantID, triggerModuleID)
|
parent := parentRevision(st, tenantID, triggerModuleID)
|
||||||
@@ -167,16 +179,13 @@ func collectModulePrefixRows(ctx context.Context, st store.Backend, hc *http.Cli
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
var profile *store.DohProfile
|
profiles, policy, err := loadModuleDohProfiles(st, tenantID, mod)
|
||||||
if mod.DohProfileID != nil && strings.TrimSpace(*mod.DohProfileID) != "" {
|
|
||||||
profile, err = st.GetDohProfile(tenantID, strings.TrimSpace(*mod.DohProfileID))
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("get doh profile: %w", err)
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
return collectDomainPrefixRows(ctx, hc, mod, profiles, policy, entries)
|
||||||
return collectDomainPrefixRows(ctx, hc, mod, profile, entries)
|
|
||||||
default:
|
default:
|
||||||
return nil, fmt.Errorf("unknown module type %q", mod.Type)
|
return nil, fmt.Errorf("pipeline: unknown module type %q", mod.Type)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -425,14 +434,20 @@ type prefixGroupKey struct {
|
|||||||
source string
|
source string
|
||||||
}
|
}
|
||||||
|
|
||||||
// smartAggregatePrefixRows performs "safe" IPv4 CIDR aggregation after full tenant materialization.
|
// smartAggregatePrefixRows performs "safe" IPv4/IPv6 CIDR aggregation after full tenant materialization.
|
||||||
// We aggregate only inside identical community/source groups to preserve BIRD attributes semantics.
|
// We aggregate only inside identical community/source groups to preserve BIRD attributes semantics.
|
||||||
func smartAggregatePrefixRows(rows []store.PrefixRow) []store.PrefixRow {
|
func smartAggregatePrefixRows(rows []store.PrefixRow) []store.PrefixRow {
|
||||||
grouped := make(map[prefixGroupKey][]store.PrefixRow)
|
groupedV4 := make(map[prefixGroupKey][]store.PrefixRow)
|
||||||
|
groupedV6 := make(map[prefixGroupKey][]store.PrefixRow)
|
||||||
var passthrough []store.PrefixRow
|
var passthrough []store.PrefixRow
|
||||||
for _, row := range rows {
|
for _, row := range rows {
|
||||||
pfx, err := netip.ParsePrefix(strings.TrimSpace(row.Prefix))
|
p := strings.TrimSpace(row.Prefix)
|
||||||
if err != nil || !pfx.Addr().Is4() {
|
if strings.HasPrefix(p, "as:") {
|
||||||
|
passthrough = append(passthrough, row)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
pfx, err := netip.ParsePrefix(p)
|
||||||
|
if err != nil {
|
||||||
passthrough = append(passthrough, row)
|
passthrough = append(passthrough, row)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -442,17 +457,73 @@ func smartAggregatePrefixRows(rows []store.PrefixRow) []store.PrefixRow {
|
|||||||
}
|
}
|
||||||
r := row
|
r := row
|
||||||
r.Prefix = pfx.Masked().String()
|
r.Prefix = pfx.Masked().String()
|
||||||
grouped[k] = append(grouped[k], r)
|
switch {
|
||||||
|
case pfx.Addr().Is4():
|
||||||
|
groupedV4[k] = append(groupedV4[k], r)
|
||||||
|
case pfx.Addr().Is6():
|
||||||
|
groupedV6[k] = append(groupedV6[k], r)
|
||||||
|
default:
|
||||||
|
passthrough = append(passthrough, row)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
out := append([]store.PrefixRow{}, passthrough...)
|
out := append([]store.PrefixRow{}, passthrough...)
|
||||||
for _, grp := range grouped {
|
out = append(out, aggregateGroupedRows(groupedV4, aggregateIPv4Group)...)
|
||||||
out = append(out, aggregateIPv4Group(grp)...)
|
out = append(out, aggregateGroupedRows(groupedV6, aggregateIPv6Group)...)
|
||||||
|
sortPrefixRows(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func aggregateGroupedRows(grouped map[prefixGroupKey][]store.PrefixRow, aggregateFn func([]store.PrefixRow) []store.PrefixRow) []store.PrefixRow {
|
||||||
|
if len(grouped) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
keys := make([]prefixGroupKey, 0, len(grouped))
|
||||||
|
for k := range grouped {
|
||||||
|
keys = append(keys, k)
|
||||||
|
}
|
||||||
|
sort.Slice(keys, func(i, j int) bool {
|
||||||
|
if keys[i].community != keys[j].community {
|
||||||
|
return keys[i].community < keys[j].community
|
||||||
|
}
|
||||||
|
return keys[i].source < keys[j].source
|
||||||
|
})
|
||||||
|
var out []store.PrefixRow
|
||||||
|
for _, k := range keys {
|
||||||
|
out = append(out, aggregateFn(grouped[k])...)
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func sortPrefixRows(rows []store.PrefixRow) {
|
||||||
|
sort.Slice(rows, func(i, j int) bool {
|
||||||
|
if rows[i].Prefix != rows[j].Prefix {
|
||||||
|
return rows[i].Prefix < rows[j].Prefix
|
||||||
|
}
|
||||||
|
ci, cj := prefixRowCommunity(rows[i]), prefixRowCommunity(rows[j])
|
||||||
|
if ci != cj {
|
||||||
|
return ci < cj
|
||||||
|
}
|
||||||
|
return rows[i].Source < rows[j].Source
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func prefixRowCommunity(r store.PrefixRow) string {
|
||||||
|
if r.CommunityID != nil {
|
||||||
|
return *r.CommunityID
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
func aggregateIPv4Group(rows []store.PrefixRow) []store.PrefixRow {
|
func aggregateIPv4Group(rows []store.PrefixRow) []store.PrefixRow {
|
||||||
|
return aggregateCIDRGroup(rows, mergeSiblingPrefixesIPv4)
|
||||||
|
}
|
||||||
|
|
||||||
|
func aggregateIPv6Group(rows []store.PrefixRow) []store.PrefixRow {
|
||||||
|
return aggregateCIDRGroup(rows, mergeSiblingPrefixesIPv6)
|
||||||
|
}
|
||||||
|
|
||||||
|
func aggregateCIDRGroup(rows []store.PrefixRow, mergeFn func(map[string]store.PrefixRow) bool) []store.PrefixRow {
|
||||||
if len(rows) <= 1 {
|
if len(rows) <= 1 {
|
||||||
return rows
|
return rows
|
||||||
}
|
}
|
||||||
@@ -462,7 +533,7 @@ func aggregateIPv4Group(rows []store.PrefixRow) []store.PrefixRow {
|
|||||||
}
|
}
|
||||||
pruneCoveredPrefixes(set)
|
pruneCoveredPrefixes(set)
|
||||||
for {
|
for {
|
||||||
if !mergeSiblingPrefixes(set) {
|
if !mergeFn(set) {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
pruneCoveredPrefixes(set)
|
pruneCoveredPrefixes(set)
|
||||||
@@ -471,6 +542,7 @@ func aggregateIPv4Group(rows []store.PrefixRow) []store.PrefixRow {
|
|||||||
for _, row := range set {
|
for _, row := range set {
|
||||||
out = append(out, row)
|
out = append(out, row)
|
||||||
}
|
}
|
||||||
|
sortPrefixRows(out)
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -483,7 +555,7 @@ func pruneCoveredPrefixes(set map[string]store.PrefixRow) {
|
|||||||
items := make([]item, 0, len(set))
|
items := make([]item, 0, len(set))
|
||||||
for k := range set {
|
for k := range set {
|
||||||
p, err := netip.ParsePrefix(k)
|
p, err := netip.ParsePrefix(k)
|
||||||
if err != nil || !p.Addr().Is4() {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
items = append(items, item{key: k, pfx: p, bits: p.Bits()})
|
items = append(items, item{key: k, pfx: p, bits: p.Bits()})
|
||||||
@@ -506,7 +578,7 @@ func pruneCoveredPrefixes(set map[string]store.PrefixRow) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func mergeSiblingPrefixes(set map[string]store.PrefixRow) bool {
|
func mergeSiblingPrefixesIPv4(set map[string]store.PrefixRow) bool {
|
||||||
merged := false
|
merged := false
|
||||||
seen := make(map[string]struct{}, len(set))
|
seen := make(map[string]struct{}, len(set))
|
||||||
for key, row := range set {
|
for key, row := range set {
|
||||||
@@ -554,6 +626,60 @@ func u32ToIPv4(v uint32) netip.Addr {
|
|||||||
return netip.AddrFrom4([4]byte{byte(v >> 24), byte(v >> 16), byte(v >> 8), byte(v)})
|
return netip.AddrFrom4([4]byte{byte(v >> 24), byte(v >> 16), byte(v >> 8), byte(v)})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func mergeSiblingPrefixesIPv6(set map[string]store.PrefixRow) bool {
|
||||||
|
merged := false
|
||||||
|
seen := make(map[string]struct{}, len(set))
|
||||||
|
for key, row := range set {
|
||||||
|
if _, done := seen[key]; done {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
pfx, err := netip.ParsePrefix(key)
|
||||||
|
if err != nil || !pfx.Addr().Is6() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
bits := pfx.Bits()
|
||||||
|
if bits <= 16 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
netNum := ipv6PrefixNetwork(pfx)
|
||||||
|
blockSize := new(big.Int).Lsh(big.NewInt(1), uint(128-bits))
|
||||||
|
siblingNet := new(big.Int).Xor(netNum, blockSize)
|
||||||
|
siblingPfx := ipv6PrefixFromBigInt(siblingNet, bits).String()
|
||||||
|
if _, ok := set[siblingPfx]; !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
parentBits := bits - 1
|
||||||
|
parentBlock := new(big.Int).Lsh(big.NewInt(1), uint(128-parentBits))
|
||||||
|
mask := new(big.Int).Sub(parentBlock, big.NewInt(1))
|
||||||
|
mask.Not(mask)
|
||||||
|
parentNet := new(big.Int).And(netNum, mask)
|
||||||
|
parentPfx := ipv6PrefixFromBigInt(parentNet, parentBits).String()
|
||||||
|
delete(set, key)
|
||||||
|
delete(set, siblingPfx)
|
||||||
|
parentRow := row
|
||||||
|
parentRow.Prefix = parentPfx
|
||||||
|
set[parentPfx] = parentRow
|
||||||
|
seen[key] = struct{}{}
|
||||||
|
seen[siblingPfx] = struct{}{}
|
||||||
|
merged = true
|
||||||
|
}
|
||||||
|
return merged
|
||||||
|
}
|
||||||
|
|
||||||
|
func ipv6PrefixNetwork(p netip.Prefix) *big.Int {
|
||||||
|
a := p.Masked().Addr().As16()
|
||||||
|
n := new(big.Int)
|
||||||
|
n.SetBytes(a[:])
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
func ipv6PrefixFromBigInt(n *big.Int, bits int) netip.Prefix {
|
||||||
|
b := n.Bytes()
|
||||||
|
var a [16]byte
|
||||||
|
copy(a[16-len(b):], b)
|
||||||
|
return netip.PrefixFrom(netip.AddrFrom16(a), bits).Masked()
|
||||||
|
}
|
||||||
|
|
||||||
func parentRevision(st store.Backend, tenantID, moduleID string) *string {
|
func parentRevision(st store.Backend, tenantID, moduleID string) *string {
|
||||||
items, _, _ := st.ListRevisions(tenantID, moduleID, "", 1)
|
items, _, _ := st.ListRevisions(tenantID, moduleID, "", 1)
|
||||||
if len(items) == 0 {
|
if len(items) == 0 {
|
||||||
@@ -572,16 +698,19 @@ func latestTenantRevision(st store.Backend, tenantID string) *store.Revision {
|
|||||||
return items[0]
|
return items[0]
|
||||||
}
|
}
|
||||||
|
|
||||||
// hashAggregatedMaterialization hashes the full tenant-wide prefix set used for BIRD (all enabled modules).
|
type prefixHashLine struct{ p, c, s string }
|
||||||
func hashAggregatedMaterialization(tenantID string, rows []store.PrefixRow) string {
|
|
||||||
type line struct{ p, c, s string }
|
func dedupeSortedPrefixLines(rows []store.PrefixRow) []prefixHashLine {
|
||||||
var lines []line
|
seen := make(map[string]struct{}, len(rows))
|
||||||
|
lines := make([]prefixHashLine, 0, len(rows))
|
||||||
for _, r := range rows {
|
for _, r := range rows {
|
||||||
c := ""
|
c := prefixRowCommunity(r)
|
||||||
if r.CommunityID != nil {
|
key := r.Prefix + "\x00" + c + "\x00" + r.Source
|
||||||
c = *r.CommunityID
|
if _, ok := seen[key]; ok {
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
lines = append(lines, line{r.Prefix, c, r.Source})
|
seen[key] = struct{}{}
|
||||||
|
lines = append(lines, prefixHashLine{r.Prefix, c, r.Source})
|
||||||
}
|
}
|
||||||
sort.Slice(lines, func(i, j int) bool {
|
sort.Slice(lines, func(i, j int) bool {
|
||||||
if lines[i].p != lines[j].p {
|
if lines[i].p != lines[j].p {
|
||||||
@@ -592,7 +721,10 @@ func hashAggregatedMaterialization(tenantID string, rows []store.PrefixRow) stri
|
|||||||
}
|
}
|
||||||
return lines[i].s < lines[j].s
|
return lines[i].s < lines[j].s
|
||||||
})
|
})
|
||||||
h := sha256.New()
|
return lines
|
||||||
|
}
|
||||||
|
|
||||||
|
func writePrefixLinesHash(h interface{ Write([]byte) (int, error) }, tenantID string, lines []prefixHashLine) {
|
||||||
h.Write([]byte(strings.TrimSpace(tenantID)))
|
h.Write([]byte(strings.TrimSpace(tenantID)))
|
||||||
h.Write([]byte{0})
|
h.Write([]byte{0})
|
||||||
for _, l := range lines {
|
for _, l := range lines {
|
||||||
@@ -603,39 +735,20 @@ func hashAggregatedMaterialization(tenantID string, rows []store.PrefixRow) stri
|
|||||||
h.Write([]byte(l.s))
|
h.Write([]byte(l.s))
|
||||||
h.Write([]byte{0})
|
h.Write([]byte{0})
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// hashAggregatedMaterialization hashes the post-aggregation tenant-wide prefix set used for BIRD.
|
||||||
|
func hashAggregatedMaterialization(tenantID string, rows []store.PrefixRow) string {
|
||||||
|
lines := dedupeSortedPrefixLines(rows)
|
||||||
|
h := sha256.New()
|
||||||
|
writePrefixLinesHash(h, tenantID, lines)
|
||||||
return fmt.Sprintf("sha256:%x", h.Sum(nil))
|
return fmt.Sprintf("sha256:%x", h.Sum(nil))
|
||||||
}
|
}
|
||||||
|
|
||||||
func hashAggregatedMaterializationWithPeers(st store.Backend, tenantID string, rows []store.PrefixRow) string {
|
func hashAggregatedMaterializationWithPeers(st store.Backend, tenantID string, rows []store.PrefixRow) string {
|
||||||
type line struct{ p, c, s string }
|
lines := dedupeSortedPrefixLines(rows)
|
||||||
var lines []line
|
|
||||||
for _, r := range rows {
|
|
||||||
c := ""
|
|
||||||
if r.CommunityID != nil {
|
|
||||||
c = *r.CommunityID
|
|
||||||
}
|
|
||||||
lines = append(lines, line{r.Prefix, c, r.Source})
|
|
||||||
}
|
|
||||||
sort.Slice(lines, func(i, j int) bool {
|
|
||||||
if lines[i].p != lines[j].p {
|
|
||||||
return lines[i].p < lines[j].p
|
|
||||||
}
|
|
||||||
if lines[i].c != lines[j].c {
|
|
||||||
return lines[i].c < lines[j].c
|
|
||||||
}
|
|
||||||
return lines[i].s < lines[j].s
|
|
||||||
})
|
|
||||||
h := sha256.New()
|
h := sha256.New()
|
||||||
h.Write([]byte(strings.TrimSpace(tenantID)))
|
writePrefixLinesHash(h, tenantID, lines)
|
||||||
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("peers"))
|
h.Write([]byte("peers"))
|
||||||
h.Write([]byte{0})
|
h.Write([]byte{0})
|
||||||
peers := st.ListPeers(tenantID)
|
peers := st.ListPeers(tenantID)
|
||||||
|
|||||||
@@ -8,6 +8,15 @@ import (
|
|||||||
"evobgp/internal/store"
|
"evobgp/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func prefixRowSet(rows []store.PrefixRow) map[string]struct{} {
|
||||||
|
got := make(map[string]struct{}, len(rows))
|
||||||
|
for _, r := range rows {
|
||||||
|
c := prefixRowCommunity(r)
|
||||||
|
got[r.Prefix+"|"+c+"|"+r.Source] = struct{}{}
|
||||||
|
}
|
||||||
|
return got
|
||||||
|
}
|
||||||
|
|
||||||
func TestRefreshModule_AggregatesAllEnabledModules(t *testing.T) {
|
func TestRefreshModule_AggregatesAllEnabledModules(t *testing.T) {
|
||||||
t.Setenv("EVOBGP_ASN_RESOLVE", "0")
|
t.Setenv("EVOBGP_ASN_RESOLVE", "0")
|
||||||
|
|
||||||
@@ -119,3 +128,144 @@ func TestSmartAggregatePrefixRows_RespectsCommunityAndSource(t *testing.T) {
|
|||||||
t.Fatalf("expected 3 resulting rows, got %d: %+v", len(out), out)
|
t.Fatalf("expected 3 resulting rows, got %d: %+v", len(out), out)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSmartAggregatePrefixRows_PruneCoveredPrefixes(t *testing.T) {
|
||||||
|
rows := []store.PrefixRow{
|
||||||
|
{Prefix: "10.0.0.0/16", Source: "ip_range"},
|
||||||
|
{Prefix: "10.0.0.0/24", Source: "ip_range"},
|
||||||
|
{Prefix: "10.0.1.0/24", Source: "ip_range"},
|
||||||
|
}
|
||||||
|
out := smartAggregatePrefixRows(rows)
|
||||||
|
got := prefixRowSet(out)
|
||||||
|
if len(out) != 1 {
|
||||||
|
t.Fatalf("expected 1 row after covered prune, got %d: %+v", len(out), out)
|
||||||
|
}
|
||||||
|
if _, ok := got["10.0.0.0/16||ip_range"]; !ok {
|
||||||
|
t.Fatalf("expected /16 only, got: %+v", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSmartAggregatePrefixRows_MergeChainFourSlash24(t *testing.T) {
|
||||||
|
rows := []store.PrefixRow{
|
||||||
|
{Prefix: "10.0.0.0/24", Source: "ip_range"},
|
||||||
|
{Prefix: "10.0.1.0/24", Source: "ip_range"},
|
||||||
|
{Prefix: "10.0.2.0/24", Source: "ip_range"},
|
||||||
|
{Prefix: "10.0.3.0/24", Source: "ip_range"},
|
||||||
|
}
|
||||||
|
out := smartAggregatePrefixRows(rows)
|
||||||
|
got := prefixRowSet(out)
|
||||||
|
if _, ok := got["10.0.0.0/22||ip_range"]; !ok {
|
||||||
|
t.Fatalf("expected merged /22, got: %+v", out)
|
||||||
|
}
|
||||||
|
if len(out) != 1 {
|
||||||
|
t.Fatalf("expected 1 row, got %d: %+v", len(out), out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSmartAggregatePrefixRows_DoesNotMergeAdjacentSlash8(t *testing.T) {
|
||||||
|
rows := []store.PrefixRow{
|
||||||
|
{Prefix: "10.0.0.0/8", Source: "ip_range"},
|
||||||
|
{Prefix: "11.0.0.0/8", Source: "ip_range"},
|
||||||
|
}
|
||||||
|
out := smartAggregatePrefixRows(rows)
|
||||||
|
if len(out) != 2 {
|
||||||
|
t.Fatalf("expected two /8 prefixes, got %d: %+v", len(out), out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSmartAggregatePrefixRows_MergesIPv6Slash64(t *testing.T) {
|
||||||
|
rows := []store.PrefixRow{
|
||||||
|
{Prefix: "2001:db8:0:0::/64", Source: "ip_range"},
|
||||||
|
{Prefix: "2001:db8:0:1::/64", Source: "ip_range"},
|
||||||
|
}
|
||||||
|
out := smartAggregatePrefixRows(rows)
|
||||||
|
got := prefixRowSet(out)
|
||||||
|
if _, ok := got["2001:db8::/63||ip_range"]; !ok {
|
||||||
|
t.Fatalf("expected merged IPv6 /63, got: %+v", out)
|
||||||
|
}
|
||||||
|
if len(out) != 1 {
|
||||||
|
t.Fatalf("expected 1 row, got %d: %+v", len(out), out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSmartAggregatePrefixRows_StableOutputOrder(t *testing.T) {
|
||||||
|
rows := []store.PrefixRow{
|
||||||
|
{Prefix: "192.168.0.0/24", Source: "cdn:b"},
|
||||||
|
{Prefix: "10.0.0.0/24", Source: "ip_range"},
|
||||||
|
{Prefix: "10.0.1.0/24", Source: "ip_range"},
|
||||||
|
}
|
||||||
|
out1 := smartAggregatePrefixRows(rows)
|
||||||
|
out2 := smartAggregatePrefixRows(rows)
|
||||||
|
if len(out1) != len(out2) {
|
||||||
|
t.Fatalf("length mismatch: %d vs %d", len(out1), len(out2))
|
||||||
|
}
|
||||||
|
for i := range out1 {
|
||||||
|
if out1[i].Prefix != out2[i].Prefix || out1[i].Source != out2[i].Source {
|
||||||
|
t.Fatalf("order not stable at %d: %+v vs %+v", i, out1[i], out2[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderTenantRevisionFromPrefixes_SkipsWhenRedundantRawRows(t *testing.T) {
|
||||||
|
t.Setenv("EVOBGP_ASN_RESOLVE", "0")
|
||||||
|
|
||||||
|
m := store.NewMemory()
|
||||||
|
m.SeedDemo()
|
||||||
|
tenant, _, modIP, _, _ := m.DemoIDs()
|
||||||
|
for _, mod := range m.ListModules(tenant) {
|
||||||
|
if mod == nil || mod.ID == modIP {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
disabled := false
|
||||||
|
if _, err := m.UpdateModule(tenant, mod.ID, &store.ModulePatch{Enabled: &disabled}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
baseRows := []store.PrefixRow{{Prefix: "10.0.0.0/16", Source: "ip_range"}}
|
||||||
|
rev1, err := RenderTenantRevisionFromPrefixes(ctx, m, http.DefaultClient, tenant, modIP, baseRows)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rev2, err := RenderTenantRevisionFromPrefixes(ctx, m, http.DefaultClient, tenant, modIP, append(baseRows,
|
||||||
|
store.PrefixRow{Prefix: "10.0.0.0/24", Source: "ip_range"},
|
||||||
|
))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if rev2 != rev1 {
|
||||||
|
t.Fatalf("expected hash skip for redundant covered prefix, got rev1=%s rev2=%s", rev1, rev2)
|
||||||
|
}
|
||||||
|
|
||||||
|
before, _, _ := m.ListRevisions(tenant, "", "", 200)
|
||||||
|
rev3, err := RenderTenantRevisionFromPrefixes(ctx, m, http.DefaultClient, tenant, modIP, append(baseRows,
|
||||||
|
store.PrefixRow{Prefix: "10.0.0.0/24", Source: "ip_range"},
|
||||||
|
store.PrefixRow{Prefix: "10.0.0.0/24", Source: "ip_range"},
|
||||||
|
))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if rev3 != rev1 {
|
||||||
|
t.Fatalf("expected hash skip for duplicate raw rows, got rev1=%s rev3=%s", rev1, rev3)
|
||||||
|
}
|
||||||
|
after, _, _ := m.ListRevisions(tenant, "", "", 200)
|
||||||
|
if len(after) != len(before) {
|
||||||
|
t.Fatalf("duplicate raw rows should not create revision, before=%d after=%d", len(before), len(after))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHashAggregatedMaterialization_DedupesIdenticalRows(t *testing.T) {
|
||||||
|
rows := []store.PrefixRow{
|
||||||
|
{Prefix: "10.0.0.0/24", Source: "ip_range"},
|
||||||
|
{Prefix: "10.0.0.0/24", Source: "ip_range"},
|
||||||
|
}
|
||||||
|
h1 := hashAggregatedMaterialization("tenant-a", rows)
|
||||||
|
h2 := hashAggregatedMaterialization("tenant-a", []store.PrefixRow{
|
||||||
|
{Prefix: "10.0.0.0/24", Source: "ip_range"},
|
||||||
|
})
|
||||||
|
if h1 != h2 {
|
||||||
|
t.Fatalf("expected deduped hash to match, got %s vs %s", h1, h2)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -76,5 +76,5 @@ func PickTenantRefreshTriggerModule(st store.Backend, tenantID string, moduleIDs
|
|||||||
return mod.ID, nil
|
return mod.ID, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return "", fmt.Errorf("no enabled module for tenant refresh")
|
return "", fmt.Errorf("pipeline: no enabled module for tenant refresh")
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
package reports
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"evobgp/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RouterListsCatalog is the aggregated router-lists payload for GET /router-lists/catalog.
|
||||||
|
type RouterListsCatalog struct {
|
||||||
|
Modules []map[string]any
|
||||||
|
Domains []map[string]any
|
||||||
|
ASNs []map[string]any
|
||||||
|
IPRanges []map[string]any
|
||||||
|
Communities []map[string]any
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildRouterListsCatalog loads modules and entries for the tenant catalog endpoint.
|
||||||
|
func BuildRouterListsCatalog(st store.Backend, tenantID string) (*RouterListsCatalog, error) {
|
||||||
|
mods := st.ListModules(tenantID)
|
||||||
|
out := &RouterListsCatalog{
|
||||||
|
Modules: make([]map[string]any, 0),
|
||||||
|
Domains: make([]map[string]any, 0),
|
||||||
|
ASNs: make([]map[string]any, 0),
|
||||||
|
IPRanges: make([]map[string]any, 0),
|
||||||
|
Communities: make([]map[string]any, 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, mod := range mods {
|
||||||
|
switch mod.Type {
|
||||||
|
case "DOMAINS", "AS_PREFIXES", "IP_RANGES":
|
||||||
|
out.Modules = append(out.Modules, moduleMap(mod))
|
||||||
|
default:
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
switch mod.Type {
|
||||||
|
case "DOMAINS":
|
||||||
|
list, err := st.ListDomainEntries(tenantID, mod.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, x := range list {
|
||||||
|
out.Domains = append(out.Domains, map[string]any{
|
||||||
|
"module_id": mod.ID,
|
||||||
|
"entry": domainEntryMap(x),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
case "AS_PREFIXES":
|
||||||
|
list, err := st.ListASEntries(tenantID, mod.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, x := range list {
|
||||||
|
out.ASNs = append(out.ASNs, map[string]any{
|
||||||
|
"module_id": mod.ID,
|
||||||
|
"entry": asEntryMap(x),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
case "IP_RANGES":
|
||||||
|
list, err := st.ListIPRangeEntries(tenantID, mod.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, x := range list {
|
||||||
|
out.IPRanges = append(out.IPRanges, map[string]any{
|
||||||
|
"module_id": mod.ID,
|
||||||
|
"entry": ipRangeMap(x),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
comms, err := st.ListCommunities(tenantID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, c := range comms {
|
||||||
|
out.Communities = append(out.Communities, map[string]any{
|
||||||
|
"id": c.ID,
|
||||||
|
"community": c.Community,
|
||||||
|
"title": c.Title,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func moduleMap(mod *store.Module) map[string]any {
|
||||||
|
m := map[string]any{
|
||||||
|
"id": mod.ID,
|
||||||
|
"type": mod.Type,
|
||||||
|
"name": mod.Name,
|
||||||
|
"enabled": mod.Enabled,
|
||||||
|
"priority": mod.Priority,
|
||||||
|
"refresh_interval_sec": mod.RefreshIntervalSec,
|
||||||
|
"cron_expr": mod.CronExpr,
|
||||||
|
}
|
||||||
|
if mod.LastRefreshedAt != nil {
|
||||||
|
m["last_refreshed_at"] = mod.LastRefreshedAt.UTC().Format(time.RFC3339Nano)
|
||||||
|
} else {
|
||||||
|
m["last_refreshed_at"] = nil
|
||||||
|
}
|
||||||
|
if mod.DefaultCommunityID != nil {
|
||||||
|
m["default_community_id"] = *mod.DefaultCommunityID
|
||||||
|
} else {
|
||||||
|
m["default_community_id"] = nil
|
||||||
|
}
|
||||||
|
ids := mod.EffectiveDohProfileIDs()
|
||||||
|
if len(ids) > 0 {
|
||||||
|
m["doh_profile_ids"] = ids
|
||||||
|
} else {
|
||||||
|
m["doh_profile_ids"] = []string{}
|
||||||
|
}
|
||||||
|
m["doh_resolver_policy"] = store.NormalizeDohResolverPolicy(mod.DohResolverPolicy)
|
||||||
|
if mod.DohProfileID != nil {
|
||||||
|
m["doh_profile_id"] = *mod.DohProfileID
|
||||||
|
} else {
|
||||||
|
m["doh_profile_id"] = nil
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func domainEntryMap(x *store.DomainEntry) map[string]any {
|
||||||
|
m := map[string]any{"id": x.ID, "fqdn": x.FQDN}
|
||||||
|
if x.CommunityID != nil {
|
||||||
|
m["community_id"] = *x.CommunityID
|
||||||
|
} else {
|
||||||
|
m["community_id"] = nil
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func asEntryMap(x *store.ASEntry) map[string]any {
|
||||||
|
m := map[string]any{"id": x.ID, "asn": x.ASN}
|
||||||
|
if x.CommunityID != nil {
|
||||||
|
m["community_id"] = *x.CommunityID
|
||||||
|
} else {
|
||||||
|
m["community_id"] = nil
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func ipRangeMap(x *store.IPRangeEntry) map[string]any {
|
||||||
|
m := map[string]any{"id": x.ID, "prefix": x.Prefix}
|
||||||
|
if x.CommunityID != nil {
|
||||||
|
m["community_id"] = *x.CommunityID
|
||||||
|
} else {
|
||||||
|
m["community_id"] = nil
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
package reports
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"evobgp/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RevisionDiagnosticLog is plain-text diagnostic output for a revision.
|
||||||
|
type RevisionDiagnosticLog struct {
|
||||||
|
Body []byte
|
||||||
|
Filename string
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildRevisionDiagnosticLog aggregates revision prefixes by source and community.
|
||||||
|
func BuildRevisionDiagnosticLog(st store.Backend, tenantID string, rev *store.Revision) (*RevisionDiagnosticLog, error) {
|
||||||
|
moduleType := ""
|
||||||
|
moduleName := ""
|
||||||
|
if strings.TrimSpace(rev.ModuleID) != "" {
|
||||||
|
if mod, modErr := st.GetModule(tenantID, rev.ModuleID); modErr == nil && mod != nil {
|
||||||
|
moduleType = strings.TrimSpace(mod.Type)
|
||||||
|
moduleName = strings.TrimSpace(mod.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
communityLabels := map[string]string{}
|
||||||
|
if communities, listErr := st.ListCommunities(tenantID); listErr == nil {
|
||||||
|
for _, c := range communities {
|
||||||
|
if c == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
label := strings.TrimSpace(c.Title)
|
||||||
|
if label == "" {
|
||||||
|
label = strings.TrimSpace(c.Community)
|
||||||
|
}
|
||||||
|
if label == "" {
|
||||||
|
label = c.ID
|
||||||
|
}
|
||||||
|
communityLabels[c.ID] = label
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type rawRow struct {
|
||||||
|
Prefix string
|
||||||
|
Source string
|
||||||
|
Kind string
|
||||||
|
SourceName string
|
||||||
|
SourceDetail string
|
||||||
|
CommunityID string
|
||||||
|
CommunityLabel string
|
||||||
|
}
|
||||||
|
type summary struct {
|
||||||
|
Kind string
|
||||||
|
Source string
|
||||||
|
SourceDetail string
|
||||||
|
CommunityID string
|
||||||
|
CommunityLabel string
|
||||||
|
Count int
|
||||||
|
Sample []string
|
||||||
|
}
|
||||||
|
|
||||||
|
cdnSourceURLByID := map[string]string{}
|
||||||
|
if moduleType == "CDN_CIDRS" && strings.TrimSpace(rev.ModuleID) != "" {
|
||||||
|
if sources, srcErr := st.ListCDNSources(tenantID, rev.ModuleID); srcErr == nil {
|
||||||
|
for _, src := range sources {
|
||||||
|
if src == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
id := strings.TrimSpace(src.ID)
|
||||||
|
url := strings.TrimSpace(src.URL)
|
||||||
|
if id != "" && url != "" {
|
||||||
|
cdnSourceURLByID[id] = url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rows := make([]rawRow, 0, rev.MaterializedPrefixCount)
|
||||||
|
byGroup := map[string]*summary{}
|
||||||
|
cursor := ""
|
||||||
|
for {
|
||||||
|
page, next, more := st.ListRevisionPrefixes(tenantID, rev.ID, cursor, 2000)
|
||||||
|
for _, p := range page {
|
||||||
|
kind, sourceName := classifyRevisionSource(p.Source)
|
||||||
|
sourceDetail := sourceName
|
||||||
|
if kind == "cdn" {
|
||||||
|
if url, ok := cdnSourceURLByID[sourceName]; ok && strings.TrimSpace(url) != "" {
|
||||||
|
sourceDetail = url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
communityID := "none"
|
||||||
|
if p.CommunityID != nil && strings.TrimSpace(*p.CommunityID) != "" {
|
||||||
|
communityID = strings.TrimSpace(*p.CommunityID)
|
||||||
|
}
|
||||||
|
communityLabel := "без community"
|
||||||
|
if communityID != "none" {
|
||||||
|
if lbl, ok := communityLabels[communityID]; ok && strings.TrimSpace(lbl) != "" {
|
||||||
|
communityLabel = lbl
|
||||||
|
} else {
|
||||||
|
communityLabel = communityID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rows = append(rows, rawRow{
|
||||||
|
Prefix: p.Prefix,
|
||||||
|
Source: p.Source,
|
||||||
|
Kind: kind,
|
||||||
|
SourceName: sourceName,
|
||||||
|
SourceDetail: sourceDetail,
|
||||||
|
CommunityID: communityID,
|
||||||
|
CommunityLabel: communityLabel,
|
||||||
|
})
|
||||||
|
groupKey := kind + "|" + sourceName + "|" + communityID
|
||||||
|
g, ok := byGroup[groupKey]
|
||||||
|
if !ok {
|
||||||
|
g = &summary{
|
||||||
|
Kind: kind,
|
||||||
|
Source: sourceName,
|
||||||
|
SourceDetail: sourceDetail,
|
||||||
|
CommunityID: communityID,
|
||||||
|
CommunityLabel: communityLabel,
|
||||||
|
Sample: make([]string, 0, 5),
|
||||||
|
}
|
||||||
|
byGroup[groupKey] = g
|
||||||
|
}
|
||||||
|
g.Count++
|
||||||
|
if len(g.Sample) < 5 {
|
||||||
|
g.Sample = append(g.Sample, p.Prefix)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !more || strings.TrimSpace(next) == "" {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
cursor = next
|
||||||
|
}
|
||||||
|
|
||||||
|
summaryKeys := make([]string, 0, len(byGroup))
|
||||||
|
for k := range byGroup {
|
||||||
|
summaryKeys = append(summaryKeys, k)
|
||||||
|
}
|
||||||
|
sort.Strings(summaryKeys)
|
||||||
|
|
||||||
|
sort.Slice(rows, func(i, j int) bool {
|
||||||
|
if rows[i].Kind != rows[j].Kind {
|
||||||
|
return rows[i].Kind < rows[j].Kind
|
||||||
|
}
|
||||||
|
if rows[i].SourceName != rows[j].SourceName {
|
||||||
|
return rows[i].SourceName < rows[j].SourceName
|
||||||
|
}
|
||||||
|
if rows[i].CommunityID != rows[j].CommunityID {
|
||||||
|
return rows[i].CommunityID < rows[j].CommunityID
|
||||||
|
}
|
||||||
|
return rows[i].Prefix < rows[j].Prefix
|
||||||
|
})
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString("EvoBGP revision diagnostic log\n")
|
||||||
|
b.WriteString("generated_at=" + time.Now().UTC().Format(time.RFC3339Nano) + "\n")
|
||||||
|
b.WriteString("tenant_id=" + tenantID + "\n")
|
||||||
|
b.WriteString("revision_id=" + rev.ID + "\n")
|
||||||
|
b.WriteString("revision_created_at=" + rev.CreatedAt.UTC().Format(time.RFC3339Nano) + "\n")
|
||||||
|
b.WriteString("content_hash=" + rev.ContentHash + "\n")
|
||||||
|
b.WriteString("materialized_prefix_count=" + strconv.Itoa(rev.MaterializedPrefixCount) + "\n")
|
||||||
|
if rev.ModuleID != "" {
|
||||||
|
b.WriteString("module_id=" + rev.ModuleID + "\n")
|
||||||
|
} else {
|
||||||
|
b.WriteString("module_id=\n")
|
||||||
|
}
|
||||||
|
b.WriteString("module_type=" + moduleType + "\n")
|
||||||
|
b.WriteString("module_name=" + moduleName + "\n")
|
||||||
|
b.WriteString("fetched_rows=" + strconv.Itoa(len(rows)) + "\n\n")
|
||||||
|
|
||||||
|
b.WriteString("## Aggregation by source and community\n")
|
||||||
|
for _, k := range summaryKeys {
|
||||||
|
g := byGroup[k]
|
||||||
|
b.WriteString("- kind=" + g.Kind +
|
||||||
|
" source=" + g.Source +
|
||||||
|
" source_detail=" + g.SourceDetail +
|
||||||
|
" community_id=" + g.CommunityID +
|
||||||
|
" community_label=" + g.CommunityLabel +
|
||||||
|
" count=" + strconv.Itoa(g.Count))
|
||||||
|
if len(g.Sample) > 0 {
|
||||||
|
b.WriteString(" sample=" + strings.Join(g.Sample, ","))
|
||||||
|
}
|
||||||
|
b.WriteByte('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
b.WriteString("\n## Raw rows\n")
|
||||||
|
b.WriteString("prefix\tkind\tsource\tsource_detail\tcommunity_id\tcommunity_label\n")
|
||||||
|
for _, row := range rows {
|
||||||
|
b.WriteString(row.Prefix)
|
||||||
|
b.WriteByte('\t')
|
||||||
|
b.WriteString(row.Kind)
|
||||||
|
b.WriteByte('\t')
|
||||||
|
b.WriteString(row.Source)
|
||||||
|
b.WriteByte('\t')
|
||||||
|
b.WriteString(strings.ReplaceAll(row.SourceDetail, "\t", " "))
|
||||||
|
b.WriteByte('\t')
|
||||||
|
b.WriteString(row.CommunityID)
|
||||||
|
b.WriteByte('\t')
|
||||||
|
b.WriteString(strings.ReplaceAll(row.CommunityLabel, "\t", " "))
|
||||||
|
b.WriteByte('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
filename := fmt.Sprintf("revision-%s-diagnostic.log", shortRevisionID(rev.ID))
|
||||||
|
return &RevisionDiagnosticLog{Body: []byte(b.String()), Filename: filename}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func classifyRevisionSource(src string) (kind, sourceName string) {
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(src, "as:"):
|
||||||
|
return "asn", strings.TrimPrefix(src, "as:")
|
||||||
|
case strings.HasPrefix(src, "domain:"):
|
||||||
|
return "domain", strings.TrimPrefix(src, "domain:")
|
||||||
|
case strings.HasPrefix(src, "cdn:"):
|
||||||
|
return "cdn", strings.TrimPrefix(src, "cdn:")
|
||||||
|
case src == "ip_range":
|
||||||
|
return "ip_range", "manual_ranges"
|
||||||
|
default:
|
||||||
|
if strings.TrimSpace(src) == "" {
|
||||||
|
return "unknown", "unknown"
|
||||||
|
}
|
||||||
|
return "source", strings.TrimSpace(src)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func shortRevisionID(id string) string {
|
||||||
|
s := strings.TrimSpace(id)
|
||||||
|
if len(s) <= 8 {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return s[:8]
|
||||||
|
}
|
||||||
@@ -113,7 +113,8 @@ func (p *Postgres) PeerSessionCountsByState() map[string]int {
|
|||||||
func (p *Postgres) ListModules(tenantID string) []*store.Module {
|
func (p *Postgres) ListModules(tenantID string) []*store.Module {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
rows, err := p.pool.Query(ctx, `
|
rows, err := p.pool.Query(ctx, `
|
||||||
SELECT id, type, name, enabled, priority, doh_profile_id::text, refresh_interval_sec, cron_expr, default_community_id::text, last_refreshed_at
|
SELECT id, type, name, enabled, priority, doh_profile_id::text, doh_resolver_policy,
|
||||||
|
refresh_interval_sec, cron_expr, default_community_id::text, last_refreshed_at
|
||||||
FROM module WHERE tenant_id = $1 AND deleted_at IS NULL ORDER BY priority, name`, tenantID)
|
FROM module WHERE tenant_id = $1 AND deleted_at IS NULL ORDER BY priority, name`, tenantID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil
|
return nil
|
||||||
@@ -126,9 +127,10 @@ func (p *Postgres) ListModules(tenantID string) []*store.Module {
|
|||||||
var doh, dc, cron *string
|
var doh, dc, cron *string
|
||||||
var refresh *int32
|
var refresh *int32
|
||||||
var last *time.Time
|
var last *time.Time
|
||||||
if err := rows.Scan(&m.ID, &m.Type, &m.Name, &m.Enabled, &m.Priority, &doh, &refresh, &cron, &dc, &last); err != nil {
|
if err := rows.Scan(&m.ID, &m.Type, &m.Name, &m.Enabled, &m.Priority, &doh, &m.DohResolverPolicy, &refresh, &cron, &dc, &last); err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
m.DohResolverPolicy = store.NormalizeDohResolverPolicy(m.DohResolverPolicy)
|
||||||
if refresh != nil {
|
if refresh != nil {
|
||||||
m.RefreshIntervalSec = int(*refresh)
|
m.RefreshIntervalSec = int(*refresh)
|
||||||
}
|
}
|
||||||
@@ -145,6 +147,9 @@ func (p *Postgres) ListModules(tenantID string) []*store.Module {
|
|||||||
t := last.UTC()
|
t := last.UTC()
|
||||||
m.LastRefreshedAt = &t
|
m.LastRefreshedAt = &t
|
||||||
}
|
}
|
||||||
|
if err := p.fillModuleDohFields(ctx, &m); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
out = append(out, &m)
|
out = append(out, &m)
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
@@ -158,9 +163,10 @@ func (p *Postgres) GetModule(tenantID, moduleID string) (*store.Module, error) {
|
|||||||
var refresh *int32
|
var refresh *int32
|
||||||
var last *time.Time
|
var last *time.Time
|
||||||
err := p.pool.QueryRow(ctx, `
|
err := p.pool.QueryRow(ctx, `
|
||||||
SELECT id, type, name, enabled, priority, doh_profile_id::text, refresh_interval_sec, cron_expr, default_community_id::text, last_refreshed_at
|
SELECT id, type, name, enabled, priority, doh_profile_id::text, doh_resolver_policy,
|
||||||
|
refresh_interval_sec, cron_expr, default_community_id::text, last_refreshed_at
|
||||||
FROM module WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL`, moduleID, tenantID).Scan(
|
FROM module WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL`, moduleID, tenantID).Scan(
|
||||||
&m.ID, &m.Type, &m.Name, &m.Enabled, &m.Priority, &doh, &refresh, &cron, &dc, &last)
|
&m.ID, &m.Type, &m.Name, &m.Enabled, &m.Priority, &doh, &m.DohResolverPolicy, &refresh, &cron, &dc, &last)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return nil, store.ErrNotFound
|
return nil, store.ErrNotFound
|
||||||
@@ -183,6 +189,10 @@ func (p *Postgres) GetModule(tenantID, moduleID string) (*store.Module, error) {
|
|||||||
t := last.UTC()
|
t := last.UTC()
|
||||||
m.LastRefreshedAt = &t
|
m.LastRefreshedAt = &t
|
||||||
}
|
}
|
||||||
|
m.DohResolverPolicy = store.NormalizeDohResolverPolicy(m.DohResolverPolicy)
|
||||||
|
if err := p.fillModuleDohFields(ctx, &m); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
return &m, nil
|
return &m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,6 +200,7 @@ func (p *Postgres) CreateModule(tenantID string, in *store.Module) (*store.Modul
|
|||||||
if in == nil {
|
if in == nil {
|
||||||
return nil, store.ErrInvalidInput
|
return nil, store.ErrInvalidInput
|
||||||
}
|
}
|
||||||
|
store.NormalizeModuleDoh(in)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
id := uuid.NewString()
|
id := uuid.NewString()
|
||||||
var doh, dc any
|
var doh, dc any
|
||||||
@@ -211,13 +222,17 @@ func (p *Postgres) CreateModule(tenantID string, in *store.Module) (*store.Modul
|
|||||||
if in.LastRefreshedAt != nil {
|
if in.LastRefreshedAt != nil {
|
||||||
lastArg = in.LastRefreshedAt.UTC()
|
lastArg = in.LastRefreshedAt.UTC()
|
||||||
}
|
}
|
||||||
|
policy := store.NormalizeDohResolverPolicy(in.DohResolverPolicy)
|
||||||
_, err := p.pool.Exec(ctx, `
|
_, err := p.pool.Exec(ctx, `
|
||||||
INSERT INTO module (id, tenant_id, type, name, enabled, priority, doh_profile_id, refresh_interval_sec, cron_expr, default_community_id, last_refreshed_at)
|
INSERT INTO module (id, tenant_id, type, name, enabled, priority, doh_profile_id, doh_resolver_policy, refresh_interval_sec, cron_expr, default_community_id, last_refreshed_at)
|
||||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)`,
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)`,
|
||||||
id, tenantID, in.Type, in.Name, in.Enabled, in.Priority, doh, ri, cronArg, dc, lastArg)
|
id, tenantID, in.Type, in.Name, in.Enabled, in.Priority, doh, policy, ri, cronArg, dc, lastArg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if err := p.setModuleDohProfiles(ctx, id, in.DohProfileIDs); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
return p.GetModule(tenantID, id)
|
return p.GetModule(tenantID, id)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -230,77 +245,68 @@ func (p *Postgres) UpdateModule(tenantID, moduleID string, patch *store.ModulePa
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
name := base.Name
|
work := *base
|
||||||
en := base.Enabled
|
|
||||||
pr := base.Priority
|
|
||||||
ri := base.RefreshIntervalSec
|
|
||||||
cron := base.CronExpr
|
|
||||||
var dc, doh *string
|
|
||||||
dc = base.DefaultCommunityID
|
|
||||||
doh = base.DohProfileID
|
|
||||||
last := base.LastRefreshedAt
|
|
||||||
if patch.Name != nil {
|
if patch.Name != nil {
|
||||||
name = strings.TrimSpace(*patch.Name)
|
work.Name = strings.TrimSpace(*patch.Name)
|
||||||
}
|
}
|
||||||
if patch.Enabled != nil {
|
if patch.Enabled != nil {
|
||||||
en = *patch.Enabled
|
work.Enabled = *patch.Enabled
|
||||||
}
|
}
|
||||||
if patch.Priority != nil {
|
if patch.Priority != nil {
|
||||||
pr = *patch.Priority
|
work.Priority = *patch.Priority
|
||||||
}
|
}
|
||||||
if patch.RefreshIntervalSec != nil {
|
if patch.RefreshIntervalSec != nil {
|
||||||
ri = *patch.RefreshIntervalSec
|
work.RefreshIntervalSec = *patch.RefreshIntervalSec
|
||||||
}
|
}
|
||||||
if patch.CronExpr != nil {
|
if patch.CronExpr != nil {
|
||||||
cron = *patch.CronExpr
|
work.CronExpr = *patch.CronExpr
|
||||||
}
|
}
|
||||||
if patch.DefaultCommunityID != nil {
|
if patch.DefaultCommunityID != nil {
|
||||||
v := strings.TrimSpace(*patch.DefaultCommunityID)
|
v := strings.TrimSpace(*patch.DefaultCommunityID)
|
||||||
if v == "" {
|
if v == "" {
|
||||||
dc = nil
|
work.DefaultCommunityID = nil
|
||||||
} else {
|
} else {
|
||||||
dc = &v
|
work.DefaultCommunityID = &v
|
||||||
}
|
|
||||||
}
|
|
||||||
if patch.DohProfileID != nil {
|
|
||||||
v := strings.TrimSpace(*patch.DohProfileID)
|
|
||||||
if v == "" {
|
|
||||||
doh = nil
|
|
||||||
} else {
|
|
||||||
doh = &v
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
store.ApplyModuleDohPatch(&work, patch)
|
||||||
if patch.LastRefreshedAt != nil {
|
if patch.LastRefreshedAt != nil {
|
||||||
t := patch.LastRefreshedAt.UTC()
|
t := patch.LastRefreshedAt.UTC()
|
||||||
last = &t
|
work.LastRefreshedAt = &t
|
||||||
}
|
}
|
||||||
var dcArg, dohArg any
|
var dcArg, dohArg any
|
||||||
if dc != nil {
|
if work.DefaultCommunityID != nil {
|
||||||
dcArg = *dc
|
dcArg = *work.DefaultCommunityID
|
||||||
}
|
}
|
||||||
if doh != nil {
|
if work.DohProfileID != nil {
|
||||||
dohArg = *doh
|
dohArg = *work.DohProfileID
|
||||||
}
|
}
|
||||||
var riArg any
|
var riArg any
|
||||||
if ri != 0 {
|
if work.RefreshIntervalSec != 0 {
|
||||||
riArg = ri
|
riArg = work.RefreshIntervalSec
|
||||||
}
|
}
|
||||||
var cronArg any
|
var cronArg any
|
||||||
if strings.TrimSpace(cron) != "" {
|
if strings.TrimSpace(work.CronExpr) != "" {
|
||||||
cronArg = strings.TrimSpace(cron)
|
cronArg = strings.TrimSpace(work.CronExpr)
|
||||||
}
|
}
|
||||||
var lastArg any
|
var lastArg any
|
||||||
if last != nil {
|
if work.LastRefreshedAt != nil {
|
||||||
lastArg = last.UTC()
|
lastArg = work.LastRefreshedAt.UTC()
|
||||||
}
|
}
|
||||||
|
policy := store.NormalizeDohResolverPolicy(work.DohResolverPolicy)
|
||||||
_, err = p.pool.Exec(ctx, `
|
_, err = p.pool.Exec(ctx, `
|
||||||
UPDATE module SET name=$3, enabled=$4, priority=$5, refresh_interval_sec=$6, cron_expr=$7,
|
UPDATE module SET name=$3, enabled=$4, priority=$5, refresh_interval_sec=$6, cron_expr=$7,
|
||||||
default_community_id=$8, doh_profile_id=$9, last_refreshed_at=$10, updated_at=now()
|
default_community_id=$8, doh_profile_id=$9, doh_resolver_policy=$10, last_refreshed_at=$11, updated_at=now()
|
||||||
WHERE id=$1 AND tenant_id=$2 AND deleted_at IS NULL`,
|
WHERE id=$1 AND tenant_id=$2 AND deleted_at IS NULL`,
|
||||||
moduleID, tenantID, name, en, pr, riArg, cronArg, dcArg, dohArg, lastArg)
|
moduleID, tenantID, work.Name, work.Enabled, work.Priority, riArg, cronArg, dcArg, dohArg, policy, lastArg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if patch.DohProfileIDs != nil || patch.DohProfileID != nil {
|
||||||
|
if err := p.setModuleDohProfiles(ctx, moduleID, work.DohProfileIDs); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
return p.GetModule(tenantID, moduleID)
|
return p.GetModule(tenantID, moduleID)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1041,6 +1047,13 @@ func (p *Postgres) UpdateDohProfile(tenantID, id string, patch *store.DohProfile
|
|||||||
|
|
||||||
func (p *Postgres) DeleteDohProfile(tenantID, id string) error {
|
func (p *Postgres) DeleteDohProfile(tenantID, id string) error {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
inUse, err := p.moduleDohProfileInUse(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if inUse {
|
||||||
|
return store.ErrInvalidInput
|
||||||
|
}
|
||||||
var n int
|
var n int
|
||||||
_ = p.pool.QueryRow(ctx, `SELECT COUNT(*) FROM module WHERE doh_profile_id=$1::uuid AND deleted_at IS NULL`, id).Scan(&n)
|
_ = p.pool.QueryRow(ctx, `SELECT COUNT(*) FROM module WHERE doh_profile_id=$1::uuid AND deleted_at IS NULL`, id).Scan(&n)
|
||||||
if n > 0 {
|
if n > 0 {
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"evobgp/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (p *Postgres) fillModuleDohFields(ctx context.Context, m *store.Module) error {
|
||||||
|
if m == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
rows, err := p.pool.Query(ctx, `
|
||||||
|
SELECT doh_profile_id::text
|
||||||
|
FROM module_doh_profile
|
||||||
|
WHERE module_id = $1
|
||||||
|
ORDER BY sort_order, doh_profile_id`, m.ID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var ids []string
|
||||||
|
for rows.Next() {
|
||||||
|
var id string
|
||||||
|
if err := rows.Scan(&id); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
ids = append(ids, id)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
m.DohProfileIDs = store.NormalizeDohProfileIDList(ids)
|
||||||
|
m.SyncLegacyDohProfileID()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Postgres) setModuleDohProfiles(ctx context.Context, moduleID string, ids []string) error {
|
||||||
|
ids = store.NormalizeDohProfileIDList(ids)
|
||||||
|
if _, err := p.pool.Exec(ctx, `DELETE FROM module_doh_profile WHERE module_id = $1`, moduleID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for i, id := range ids {
|
||||||
|
if _, err := p.pool.Exec(ctx, `
|
||||||
|
INSERT INTO module_doh_profile (module_id, doh_profile_id, sort_order)
|
||||||
|
VALUES ($1, $2, $3)`, moduleID, id, i); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var dohArg any
|
||||||
|
if len(ids) > 0 {
|
||||||
|
dohArg = ids[0]
|
||||||
|
}
|
||||||
|
_, err := p.pool.Exec(ctx, `UPDATE module SET doh_profile_id = $2, updated_at = now() WHERE id = $1`, moduleID, dohArg)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Postgres) moduleDohProfileInUse(ctx context.Context, dohProfileID string) (bool, error) {
|
||||||
|
var n int
|
||||||
|
if err := p.pool.QueryRow(ctx, `
|
||||||
|
SELECT COUNT(*) FROM module_doh_profile mdp
|
||||||
|
JOIN module m ON m.id = mdp.module_id
|
||||||
|
WHERE mdp.doh_profile_id = $1::uuid AND m.deleted_at IS NULL`, dohProfileID).Scan(&n); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return n > 0, nil
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ type Backend interface {
|
|||||||
// ListTenantIDs returns distinct tenant identifiers (for background workers).
|
// ListTenantIDs returns distinct tenant identifiers (for background workers).
|
||||||
ListTenantIDs() ([]string, error)
|
ListTenantIDs() ([]string, error)
|
||||||
|
|
||||||
|
// ListModules returns all modules for a tenant (control plane may paginate in httpapi).
|
||||||
ListModules(tenantID string) []*Module
|
ListModules(tenantID string) []*Module
|
||||||
GetModule(tenantID, moduleID string) (*Module, error)
|
GetModule(tenantID, moduleID string) (*Module, error)
|
||||||
CreateModule(tenantID string, in *Module) (*Module, error)
|
CreateModule(tenantID string, in *Module) (*Module, error)
|
||||||
@@ -53,6 +54,7 @@ type Backend interface {
|
|||||||
UpdateCommunity(tenantID, id string, patch *CommunityPatch) (*Community, error)
|
UpdateCommunity(tenantID, id string, patch *CommunityPatch) (*Community, error)
|
||||||
DeleteCommunity(tenantID, id string) error
|
DeleteCommunity(tenantID, id string) error
|
||||||
|
|
||||||
|
// ListPeers returns all BGP peers for a tenant (control plane may paginate in httpapi).
|
||||||
ListPeers(tenantID string) []*BGPPeer
|
ListPeers(tenantID string) []*BGPPeer
|
||||||
GetPeer(tenantID, id string) (*BGPPeer, error)
|
GetPeer(tenantID, id string) (*BGPPeer, error)
|
||||||
CreatePeer(tenantID string, in *BGPPeer) (*BGPPeer, error)
|
CreatePeer(tenantID string, in *BGPPeer) (*BGPPeer, error)
|
||||||
@@ -114,6 +116,8 @@ type ModulePatch struct {
|
|||||||
CronExpr *string `json:"cron_expr,omitempty"`
|
CronExpr *string `json:"cron_expr,omitempty"`
|
||||||
DefaultCommunityID *string `json:"default_community_id,omitempty"`
|
DefaultCommunityID *string `json:"default_community_id,omitempty"`
|
||||||
DohProfileID *string `json:"doh_profile_id,omitempty"`
|
DohProfileID *string `json:"doh_profile_id,omitempty"`
|
||||||
|
DohProfileIDs *[]string `json:"doh_profile_ids,omitempty"`
|
||||||
|
DohResolverPolicy *string `json:"doh_resolver_policy,omitempty"`
|
||||||
LastRefreshedAt *time.Time `json:"-"`
|
LastRefreshedAt *time.Time `json:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
|
const (
|
||||||
|
DohPolicyPrimaryOnly = "primary_only"
|
||||||
|
DohPolicyFailover = "failover"
|
||||||
|
DohPolicyUnion = "union"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NormalizeDohResolverPolicy returns a supported resolver policy name.
|
||||||
|
func NormalizeDohResolverPolicy(policy string) string {
|
||||||
|
switch strings.TrimSpace(policy) {
|
||||||
|
case DohPolicyFailover, DohPolicyUnion:
|
||||||
|
return strings.TrimSpace(policy)
|
||||||
|
default:
|
||||||
|
return DohPolicyPrimaryOnly
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NormalizeDohProfileIDList deduplicates profile ids preserving order.
|
||||||
|
func NormalizeDohProfileIDList(ids []string) []string {
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
seen := make(map[string]struct{}, len(ids))
|
||||||
|
out := make([]string, 0, len(ids))
|
||||||
|
for _, id := range ids {
|
||||||
|
id = strings.TrimSpace(id)
|
||||||
|
if id == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := seen[id]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[id] = struct{}{}
|
||||||
|
out = append(out, id)
|
||||||
|
}
|
||||||
|
if len(out) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// EffectiveDohProfileIDs returns ordered DoH profile ids for a module.
|
||||||
|
func (m *Module) EffectiveDohProfileIDs() []string {
|
||||||
|
if m == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if ids := NormalizeDohProfileIDList(m.DohProfileIDs); len(ids) > 0 {
|
||||||
|
return ids
|
||||||
|
}
|
||||||
|
if m.DohProfileID != nil {
|
||||||
|
if id := strings.TrimSpace(*m.DohProfileID); id != "" {
|
||||||
|
return []string{id}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SyncLegacyDohProfileID keeps deprecated doh_profile_id aligned with the first profile.
|
||||||
|
func (m *Module) SyncLegacyDohProfileID() {
|
||||||
|
if m == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ids := NormalizeDohProfileIDList(m.DohProfileIDs)
|
||||||
|
if len(ids) == 0 {
|
||||||
|
m.DohProfileID = nil
|
||||||
|
return
|
||||||
|
}
|
||||||
|
first := ids[0]
|
||||||
|
m.DohProfileID = &first
|
||||||
|
}
|
||||||
|
|
||||||
|
// NormalizeModuleDoh fills doh_profile_ids, policy and legacy id from module input.
|
||||||
|
func NormalizeModuleDoh(m *Module) {
|
||||||
|
if m == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ids := NormalizeDohProfileIDList(m.DohProfileIDs)
|
||||||
|
if len(ids) == 0 && m.DohProfileID != nil {
|
||||||
|
if id := strings.TrimSpace(*m.DohProfileID); id != "" {
|
||||||
|
ids = []string{id}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m.DohProfileIDs = ids
|
||||||
|
m.DohResolverPolicy = NormalizeDohResolverPolicy(m.DohResolverPolicy)
|
||||||
|
m.SyncLegacyDohProfileID()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ApplyModuleDohPatch merges DoH-related fields from patch into mod.
|
||||||
|
func ApplyModuleDohPatch(mod *Module, patch *ModulePatch) {
|
||||||
|
if mod == nil || patch == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if patch.DohProfileIDs != nil {
|
||||||
|
mod.DohProfileIDs = NormalizeDohProfileIDList(*patch.DohProfileIDs)
|
||||||
|
} else if patch.DohProfileID != nil {
|
||||||
|
v := strings.TrimSpace(*patch.DohProfileID)
|
||||||
|
if v == "" {
|
||||||
|
mod.DohProfileIDs = nil
|
||||||
|
} else {
|
||||||
|
mod.DohProfileIDs = []string{v}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if patch.DohResolverPolicy != nil {
|
||||||
|
mod.DohResolverPolicy = NormalizeDohResolverPolicy(*patch.DohResolverPolicy)
|
||||||
|
}
|
||||||
|
mod.SyncLegacyDohProfileID()
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestNormalizeDohProfileIDList(t *testing.T) {
|
||||||
|
got := NormalizeDohProfileIDList([]string{" a ", "b", "a", "", "b"})
|
||||||
|
if len(got) != 2 || got[0] != "a" || got[1] != "b" {
|
||||||
|
t.Fatalf("unexpected: %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyModuleDohPatch(t *testing.T) {
|
||||||
|
mod := &Module{DohProfileIDs: []string{"one"}, DohResolverPolicy: DohPolicyPrimaryOnly}
|
||||||
|
ids := []string{"ru", "eu"}
|
||||||
|
policy := DohPolicyUnion
|
||||||
|
ApplyModuleDohPatch(mod, &ModulePatch{DohProfileIDs: &ids, DohResolverPolicy: &policy})
|
||||||
|
if len(mod.DohProfileIDs) != 2 || mod.DohResolverPolicy != DohPolicyUnion {
|
||||||
|
t.Fatalf("unexpected module doh fields: %+v", mod)
|
||||||
|
}
|
||||||
|
if mod.DohProfileID == nil || *mod.DohProfileID != "ru" {
|
||||||
|
t.Fatalf("legacy id not synced: %+v", mod.DohProfileID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEffectiveDohProfileIDs_LegacyField(t *testing.T) {
|
||||||
|
id := "legacy-id"
|
||||||
|
mod := &Module{DohProfileID: &id}
|
||||||
|
got := mod.EffectiveDohProfileIDs()
|
||||||
|
if len(got) != 1 || got[0] != "legacy-id" {
|
||||||
|
t.Fatalf("unexpected: %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -72,7 +72,9 @@ type Module struct {
|
|||||||
CronExpr string // optional cron for scheduler (display / future use)
|
CronExpr string // optional cron for scheduler (display / future use)
|
||||||
Priority int
|
Priority int
|
||||||
DefaultCommunityID *string
|
DefaultCommunityID *string
|
||||||
DohProfileID *string
|
DohProfileID *string // deprecated: first id in DohProfileIDs
|
||||||
|
DohProfileIDs []string
|
||||||
|
DohResolverPolicy string
|
||||||
LastRefreshedAt *time.Time
|
LastRefreshedAt *time.Time
|
||||||
DeletedAt *time.Time
|
DeletedAt *time.Time
|
||||||
}
|
}
|
||||||
@@ -325,13 +327,22 @@ func (m *Memory) CreateRenderRevision(revisionID, tenantID, moduleID string, par
|
|||||||
frag[k] = v
|
frag[k] = v
|
||||||
}
|
}
|
||||||
parent := parentRevisionID
|
parent := parentRevisionID
|
||||||
|
createdAt := time.Now().UTC()
|
||||||
|
for _, r := range m.revisions {
|
||||||
|
if r == nil || r.TenantID != tenantID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !r.CreatedAt.Before(createdAt) {
|
||||||
|
createdAt = r.CreatedAt.Add(time.Microsecond)
|
||||||
|
}
|
||||||
|
}
|
||||||
m.revisions[revisionID] = &Revision{
|
m.revisions[revisionID] = &Revision{
|
||||||
ID: revisionID,
|
ID: revisionID,
|
||||||
TenantID: tenantID,
|
TenantID: tenantID,
|
||||||
ModuleID: moduleID,
|
ModuleID: moduleID,
|
||||||
ContentHash: contentHash,
|
ContentHash: contentHash,
|
||||||
ParentRevisionID: parent,
|
ParentRevisionID: parent,
|
||||||
CreatedAt: time.Now().UTC(),
|
CreatedAt: createdAt,
|
||||||
MaterializedPrefixCount: len(prefixes),
|
MaterializedPrefixCount: len(prefixes),
|
||||||
PreviewFragments: frag,
|
PreviewFragments: frag,
|
||||||
}
|
}
|
||||||
@@ -655,6 +666,9 @@ func (m *Memory) ListRevisions(tenantID, moduleID string, cursor string, limit i
|
|||||||
all = append(all, r)
|
all = append(all, r)
|
||||||
}
|
}
|
||||||
sort.Slice(all, func(i, j int) bool {
|
sort.Slice(all, func(i, j int) bool {
|
||||||
|
if all[i].CreatedAt.Equal(all[j].CreatedAt) {
|
||||||
|
return all[i].ID > all[j].ID
|
||||||
|
}
|
||||||
return all[i].CreatedAt.After(all[j].CreatedAt)
|
return all[i].CreatedAt.After(all[j].CreatedAt)
|
||||||
})
|
})
|
||||||
off := 0
|
off := 0
|
||||||
|
|||||||
@@ -31,9 +31,11 @@ func (m *Memory) CreateModule(tenantID string, in *Module) (*Module, error) {
|
|||||||
RefreshIntervalSec: in.RefreshIntervalSec,
|
RefreshIntervalSec: in.RefreshIntervalSec,
|
||||||
CronExpr: in.CronExpr,
|
CronExpr: in.CronExpr,
|
||||||
DefaultCommunityID: in.DefaultCommunityID,
|
DefaultCommunityID: in.DefaultCommunityID,
|
||||||
DohProfileID: in.DohProfileID,
|
DohProfileIDs: append([]string(nil), in.DohProfileIDs...),
|
||||||
|
DohResolverPolicy: in.DohResolverPolicy,
|
||||||
LastRefreshedAt: in.LastRefreshedAt,
|
LastRefreshedAt: in.LastRefreshedAt,
|
||||||
}
|
}
|
||||||
|
NormalizeModuleDoh(mod)
|
||||||
m.modules[id] = mod
|
m.modules[id] = mod
|
||||||
return mod, nil
|
return mod, nil
|
||||||
}
|
}
|
||||||
@@ -71,14 +73,7 @@ func (m *Memory) UpdateModule(tenantID, moduleID string, patch *ModulePatch) (*M
|
|||||||
mod.DefaultCommunityID = &v
|
mod.DefaultCommunityID = &v
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if patch.DohProfileID != nil {
|
ApplyModuleDohPatch(mod, patch)
|
||||||
v := strings.TrimSpace(*patch.DohProfileID)
|
|
||||||
if v == "" {
|
|
||||||
mod.DohProfileID = nil
|
|
||||||
} else {
|
|
||||||
mod.DohProfileID = &v
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if patch.LastRefreshedAt != nil {
|
if patch.LastRefreshedAt != nil {
|
||||||
t := patch.LastRefreshedAt.UTC()
|
t := patch.LastRefreshedAt.UTC()
|
||||||
mod.LastRefreshedAt = &t
|
mod.LastRefreshedAt = &t
|
||||||
@@ -555,10 +550,15 @@ func (m *Memory) DeleteDohProfile(tenantID, id string) error {
|
|||||||
return ErrNotFound
|
return ErrNotFound
|
||||||
}
|
}
|
||||||
for _, mod := range m.modules {
|
for _, mod := range m.modules {
|
||||||
if mod.DohProfileID != nil && *mod.DohProfileID == id {
|
if mod == nil || mod.DeletedAt != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, pid := range mod.EffectiveDohProfileIDs() {
|
||||||
|
if pid == id {
|
||||||
return ErrInvalidInput
|
return ErrInvalidInput
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
delete(m.dohProfiles, id)
|
delete(m.dohProfiles, id)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import "strconv"
|
||||||
|
|
||||||
|
// PaginateOffset returns a page from a pre-sorted slice using numeric string cursors (same scheme as ListRevisions).
|
||||||
|
func PaginateOffset[T any](all []T, cursor string, limit int) (page []T, nextCursor string, hasMore bool) {
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 50
|
||||||
|
}
|
||||||
|
off := 0
|
||||||
|
if cursor != "" {
|
||||||
|
if n, err := strconv.Atoi(cursor); err == nil && n >= 0 {
|
||||||
|
off = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if off > len(all) {
|
||||||
|
off = len(all)
|
||||||
|
}
|
||||||
|
end := off + limit
|
||||||
|
if end > len(all) {
|
||||||
|
end = len(all)
|
||||||
|
}
|
||||||
|
page = all[off:end]
|
||||||
|
if end < len(all) {
|
||||||
|
nextCursor = strconv.Itoa(end)
|
||||||
|
hasMore = true
|
||||||
|
}
|
||||||
|
return page, nextCursor, hasMore
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
// Package version holds build metadata injected at link time via -ldflags.
|
||||||
|
package version
|
||||||
|
|
||||||
|
// Link-time variables (defaults for local development).
|
||||||
|
var (
|
||||||
|
Version = "dev"
|
||||||
|
GitSHA = "unknown"
|
||||||
|
BuildTime = ""
|
||||||
|
)
|
||||||
|
|
||||||
|
// Info returns build metadata, substituting safe defaults for empty values.
|
||||||
|
func Info() (ver, gitSHA, buildTime string) {
|
||||||
|
ver = Version
|
||||||
|
if ver == "" {
|
||||||
|
ver = "dev"
|
||||||
|
}
|
||||||
|
gitSHA = GitSHA
|
||||||
|
if gitSHA == "" {
|
||||||
|
gitSHA = "unknown"
|
||||||
|
}
|
||||||
|
buildTime = BuildTime
|
||||||
|
return ver, gitSHA, buildTime
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
DROP TABLE IF EXISTS module_doh_profile;
|
||||||
|
|
||||||
|
ALTER TABLE module DROP CONSTRAINT IF EXISTS module_doh_resolver_policy_chk;
|
||||||
|
ALTER TABLE module DROP COLUMN IF EXISTS doh_resolver_policy;
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
-- Module DoH resolver policy and ordered profile list.
|
||||||
|
ALTER TABLE module
|
||||||
|
ADD COLUMN doh_resolver_policy TEXT NOT NULL DEFAULT 'primary_only'
|
||||||
|
CONSTRAINT module_doh_resolver_policy_chk CHECK (
|
||||||
|
doh_resolver_policy IN ('primary_only', 'failover', 'union')
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE module_doh_profile (
|
||||||
|
module_id UUID NOT NULL REFERENCES module (id) ON DELETE CASCADE,
|
||||||
|
doh_profile_id UUID NOT NULL REFERENCES doh_profile (id) ON DELETE CASCADE,
|
||||||
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (module_id, doh_profile_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_module_doh_profile_doh ON module_doh_profile (doh_profile_id);
|
||||||
|
|
||||||
|
INSERT INTO module_doh_profile (module_id, doh_profile_id, sort_order)
|
||||||
|
SELECT id, doh_profile_id, 0
|
||||||
|
FROM module
|
||||||
|
WHERE doh_profile_id IS NOT NULL
|
||||||
|
AND deleted_at IS NULL;
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
DROP TABLE IF EXISTS module_doh_profile;
|
||||||
|
|
||||||
|
ALTER TABLE module DROP COLUMN doh_resolver_policy;
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
ALTER TABLE module
|
||||||
|
ADD COLUMN doh_resolver_policy TEXT NOT NULL DEFAULT 'primary_only'
|
||||||
|
CHECK (doh_resolver_policy IN ('primary_only', 'failover', 'union'));
|
||||||
|
|
||||||
|
CREATE TABLE module_doh_profile (
|
||||||
|
module_id TEXT NOT NULL REFERENCES module (id) ON DELETE CASCADE,
|
||||||
|
doh_profile_id TEXT NOT NULL REFERENCES doh_profile (id) ON DELETE CASCADE,
|
||||||
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (module_id, doh_profile_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_module_doh_profile_doh ON module_doh_profile (doh_profile_id);
|
||||||
|
|
||||||
|
INSERT INTO module_doh_profile (module_id, doh_profile_id, sort_order)
|
||||||
|
SELECT id, doh_profile_id, 0
|
||||||
|
FROM module
|
||||||
|
WHERE doh_profile_id IS NOT NULL
|
||||||
|
AND deleted_at IS NULL;
|
||||||
Generated
+7720
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"name": "evobgp-release",
|
||||||
|
"private": true,
|
||||||
|
"devDependencies": {
|
||||||
|
"@commitlint/cli": "^19.8.1",
|
||||||
|
"@commitlint/config-conventional": "^19.8.1",
|
||||||
|
"@markwylde/semantic-release-gitea": "^2.2.0",
|
||||||
|
"@semantic-release/changelog": "^6.0.3",
|
||||||
|
"@semantic-release/commit-analyzer": "^13.0.1",
|
||||||
|
"@semantic-release/exec": "^7.0.0",
|
||||||
|
"@semantic-release/release-notes-generator": "^14.0.3",
|
||||||
|
"semantic-release": "^25.0.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
#Requires -Version 5.1
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Staged git changes grouped by EvoBGP scope for Conventional Commit generation.
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
Outputs JSON to stdout: file groups, --stat, and truncated patch per group.
|
||||||
|
Exit 1 if nothing is staged. Does not write commit messages.
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
powershell -NoProfile -File scripts/commit/staged-context.ps1
|
||||||
|
powershell -NoProfile -File scripts/commit/staged-context.ps1 | ConvertFrom-Json
|
||||||
|
#>
|
||||||
|
Set-StrictMode -Version Latest
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
|
||||||
|
$MaxLinesPerGroup = 120
|
||||||
|
$MaxLinesTotal = 500
|
||||||
|
|
||||||
|
function Test-GitRepository {
|
||||||
|
$null = git rev-parse --git-dir 2>$null
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
Write-Error 'staged-context: not a git repository'
|
||||||
|
exit 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-ScopeForPath {
|
||||||
|
param([string]$Path)
|
||||||
|
$p = $Path -replace '\\', '/'
|
||||||
|
|
||||||
|
# Longest / most specific prefixes first
|
||||||
|
$rules = @(
|
||||||
|
@{ Prefix = 'docs/openapi.yaml'; Scope = 'openapi' }
|
||||||
|
@{ Prefix = 'redocly.yaml'; Scope = 'openapi' }
|
||||||
|
@{ Prefix = 'internal/httpapi/'; Scope = 'httpapi' }
|
||||||
|
@{ Prefix = 'internal/store/'; Scope = 'store' }
|
||||||
|
@{ Prefix = 'internal/repository/'; Scope = 'store' }
|
||||||
|
@{ Prefix = 'internal/db/'; Scope = 'store' }
|
||||||
|
@{ Prefix = 'internal/jobs/'; Scope = 'jobs' }
|
||||||
|
@{ Prefix = 'internal/pipeline/'; Scope = 'pipeline' }
|
||||||
|
@{ Prefix = 'internal/birdfmt/'; Scope = 'birdfmt' }
|
||||||
|
@{ Prefix = 'internal/birddeploy/'; Scope = 'birddeploy' }
|
||||||
|
@{ Prefix = 'internal/bundle/'; Scope = 'bundle' }
|
||||||
|
@{ Prefix = 'internal/signing/'; Scope = 'bundle' }
|
||||||
|
@{ Prefix = 'cmd/'; Scope = 'cmd' }
|
||||||
|
@{ Prefix = 'web/'; Scope = 'web' }
|
||||||
|
@{ Prefix = 'migrations/'; Scope = 'db' }
|
||||||
|
@{ Prefix = 'docs/'; Scope = 'docs' }
|
||||||
|
@{ Prefix = '.gitea/'; Scope = 'ci' }
|
||||||
|
@{ Prefix = 'deploy/'; Scope = 'deploy' }
|
||||||
|
@{ Prefix = '.cursor/'; Scope = 'chore' }
|
||||||
|
)
|
||||||
|
|
||||||
|
foreach ($rule in $rules) {
|
||||||
|
if ($p -eq $rule.Prefix.TrimEnd('/') -or $p.StartsWith($rule.Prefix)) {
|
||||||
|
return $rule.Scope
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 'chore'
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-ScopeSortOrder {
|
||||||
|
param([string]$Scope)
|
||||||
|
$order = @{
|
||||||
|
openapi = 10
|
||||||
|
httpapi = 20
|
||||||
|
store = 21
|
||||||
|
jobs = 22
|
||||||
|
pipeline = 30
|
||||||
|
birdfmt = 31
|
||||||
|
birddeploy = 32
|
||||||
|
bundle = 33
|
||||||
|
web = 40
|
||||||
|
ci = 50
|
||||||
|
deploy = 51
|
||||||
|
db = 52
|
||||||
|
docs = 60
|
||||||
|
cmd = 70
|
||||||
|
chore = 80
|
||||||
|
}
|
||||||
|
if ($order.ContainsKey($Scope)) { return $order[$Scope] }
|
||||||
|
return 99
|
||||||
|
}
|
||||||
|
|
||||||
|
function Invoke-Git {
|
||||||
|
param([string[]]$GitArgs)
|
||||||
|
$out = & git @GitArgs 2>&1
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
$msg = ($out | Out-String).Trim()
|
||||||
|
throw "git $($GitArgs -join ' ') failed: $msg"
|
||||||
|
}
|
||||||
|
return ($out | Out-String).TrimEnd()
|
||||||
|
}
|
||||||
|
|
||||||
|
function New-GitArgs {
|
||||||
|
param([string[]]$Base, [string[]]$Paths)
|
||||||
|
if ($Paths.Count -eq 0) { return $Base }
|
||||||
|
return $Base + '--' + $Paths
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-TruncatedDiff {
|
||||||
|
param(
|
||||||
|
[string[]]$Files,
|
||||||
|
[int]$MaxLines,
|
||||||
|
[ref]$TotalLinesUsed
|
||||||
|
)
|
||||||
|
if ($Files.Count -eq 0) { return '' }
|
||||||
|
|
||||||
|
$remaining = $MaxLinesTotal - $TotalLinesUsed.Value
|
||||||
|
if ($remaining -le 0) {
|
||||||
|
return '[diff truncated: global line budget exceeded]'
|
||||||
|
}
|
||||||
|
|
||||||
|
$cap = [Math]::Min($MaxLines, $remaining)
|
||||||
|
$diff = Invoke-Git -GitArgs (New-GitArgs -Base @(
|
||||||
|
'diff', '--cached', '--no-color', '--unified=3'
|
||||||
|
) -Paths $Files)
|
||||||
|
if ([string]::IsNullOrWhiteSpace($diff)) { return '' }
|
||||||
|
|
||||||
|
$lines = $diff -split "`n", -1
|
||||||
|
if ($lines.Count -le $cap) {
|
||||||
|
$TotalLinesUsed.Value += $lines.Count
|
||||||
|
return $diff
|
||||||
|
}
|
||||||
|
|
||||||
|
$truncated = ($lines[0..($cap - 1)] -join "`n") + "`n... [truncated: $($lines.Count - $cap) more lines]"
|
||||||
|
$TotalLinesUsed.Value += $cap
|
||||||
|
return $truncated
|
||||||
|
}
|
||||||
|
|
||||||
|
Test-GitRepository
|
||||||
|
|
||||||
|
$stagedRaw = Invoke-Git -GitArgs @('diff', '--cached', '--name-only')
|
||||||
|
if ([string]::IsNullOrWhiteSpace($stagedRaw)) {
|
||||||
|
[Console]::Error.WriteLine('staged-context: no staged changes (git index is empty)')
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
$stagedFiles = @(
|
||||||
|
$stagedRaw -split "`n" | ForEach-Object { $_.Trim() } | Where-Object { $_ -ne '' }
|
||||||
|
)
|
||||||
|
$stagedFiles = $stagedFiles | Sort-Object -Unique
|
||||||
|
|
||||||
|
$scopeBuckets = @{}
|
||||||
|
foreach ($f in $stagedFiles) {
|
||||||
|
$scope = Get-ScopeForPath -Path $f
|
||||||
|
if (-not $scopeBuckets.ContainsKey($scope)) {
|
||||||
|
$scopeBuckets[$scope] = [System.Collections.Generic.List[string]]::new()
|
||||||
|
}
|
||||||
|
$scopeBuckets[$scope].Add($f) | Out-Null
|
||||||
|
}
|
||||||
|
|
||||||
|
$totalLinesUsed = 0
|
||||||
|
$groups = New-Object System.Collections.Generic.List[object]
|
||||||
|
|
||||||
|
$scopeKeys = @($scopeBuckets.Keys | Sort-Object { Get-ScopeSortOrder $_ })
|
||||||
|
|
||||||
|
foreach ($scope in $scopeKeys) {
|
||||||
|
$files = @($scopeBuckets[$scope] | Sort-Object)
|
||||||
|
$stat = Invoke-Git -GitArgs (New-GitArgs -Base @('diff', '--cached', '--stat') -Paths $files)
|
||||||
|
$diffExcerpt = Get-TruncatedDiff -Files $files -MaxLines $MaxLinesPerGroup -TotalLinesUsed ([ref]$totalLinesUsed)
|
||||||
|
|
||||||
|
$groups.Add([ordered]@{
|
||||||
|
scope = $scope
|
||||||
|
files = $files
|
||||||
|
stat = $stat
|
||||||
|
diff_excerpt = $diffExcerpt
|
||||||
|
}) | Out-Null
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = [ordered]@{
|
||||||
|
staged_count = $stagedFiles.Count
|
||||||
|
groups = $groups.ToArray()
|
||||||
|
}
|
||||||
|
|
||||||
|
$json = $result | ConvertTo-Json -Depth 6 -Compress:$false
|
||||||
|
# UTF-8 stdout for agents / ConvertFrom-Json
|
||||||
|
[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false)
|
||||||
|
Write-Output $json
|
||||||
|
exit 0
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
#!/usr/bin/env sh
|
||||||
|
# Engineering gates for internal/httpapi (ERR-01, ARCH-01).
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
ROOT="$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)"
|
||||||
|
cd "$ROOT"
|
||||||
|
|
||||||
|
HTTPAPI="internal/httpapi"
|
||||||
|
FAIL=0
|
||||||
|
|
||||||
|
echo "==> ERR-01: no err.Error() in 5xx problem details"
|
||||||
|
if grep -rE 'writeProblem\(w, http\.StatusInternalServerError.*err\.Error\(\)' "$HTTPAPI" 2>/dev/null; then
|
||||||
|
FAIL=1
|
||||||
|
fi
|
||||||
|
if grep -rE 'writeProblem\(w, http\.StatusBadGateway.*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
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$FAIL" -ne 0 ]; then
|
||||||
|
echo "lint-httpapi: failed" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "lint-httpapi: ok"
|
||||||
+1
-4
@@ -3,10 +3,7 @@
|
|||||||
"singleQuote": true,
|
"singleQuote": true,
|
||||||
"trailingComma": "none",
|
"trailingComma": "none",
|
||||||
"printWidth": 100,
|
"printWidth": 100,
|
||||||
"plugins": [
|
"plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"],
|
||||||
"prettier-plugin-svelte",
|
|
||||||
"prettier-plugin-tailwindcss"
|
|
||||||
],
|
|
||||||
"tailwindStylesheet": "./src/routes/layout.css",
|
"tailwindStylesheet": "./src/routes/layout.css",
|
||||||
"overrides": [
|
"overrides": [
|
||||||
{
|
{
|
||||||
|
|||||||
Vendored
+1
-5
@@ -1,7 +1,3 @@
|
|||||||
{
|
{
|
||||||
"recommendations": [
|
"recommendations": ["svelte.svelte-vscode", "bradlc.vscode-tailwindcss", "esbenp.prettier-vscode"]
|
||||||
"svelte.svelte-vscode",
|
|
||||||
"bradlc.vscode-tailwindcss",
|
|
||||||
"esbenp.prettier-vscode"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
+16
-30
@@ -1,44 +1,30 @@
|
|||||||
# sv
|
# EvoBGP WebUI
|
||||||
|
|
||||||
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
|
SvelteKit-приложение панели управления EvoBGP. Запуск — [docs/quickstart.md](../docs/quickstart.md).
|
||||||
|
|
||||||
Это веб-интерфейс **EvoBGP**. Запуск вместе с API, CORS и портами — в [../docs/quickstart.md](../docs/quickstart.md); доступ и ключи — [../docs/access.md](../docs/access.md).
|
Разработка UI **обязательно** следует официальной документации [shadcn-svelte](https://shadcn-svelte.com/docs) и правилам репозитория [.cursor/rules/web-shadcn.mdc](../.cursor/rules/web-shadcn.mdc): максимум нативных компонентов из registry, установка только через CLI в `src/lib/ui/core/`.
|
||||||
|
|
||||||
## Creating a project
|
## UI-архитектура
|
||||||
|
|
||||||
If you're seeing this, you've probably already done this step. Congrats!
|
| Путь | Назначение |
|
||||||
|
| ------------------------ | ------------------------------------------------------------------- |
|
||||||
|
| `src/lib/ui/core/` | shadcn-svelte (только через `npx shadcn-svelte@latest add … -y -o`) |
|
||||||
|
| `src/lib/ui/app/` | Layout, PageHeader, toast (`notify`) |
|
||||||
|
| `src/lib/ui/patterns/` | FormField, AppDataTable, ConfirmDialog, EmptyState |
|
||||||
|
| `src/lib/components/ui/` | Re-export на `ui/core` (совместимость) |
|
||||||
|
|
||||||
```sh
|
Токены темы: `src/routes/layout.css`. Справочник: `src/lib/ui/app/tokens.md`.
|
||||||
# create a new project
|
|
||||||
npx sv create my-app
|
|
||||||
```
|
|
||||||
|
|
||||||
To recreate this project with the same configuration:
|
## Разработка
|
||||||
|
|
||||||
```sh
|
|
||||||
# recreate this project
|
|
||||||
npx sv@0.14.0 create --template minimal --types ts --add tailwindcss="plugins:none" sveltekit-adapter="adapter:static" prettier --no-download-check --install npm web
|
|
||||||
```
|
|
||||||
|
|
||||||
## Developing
|
|
||||||
|
|
||||||
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
|
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
npm install
|
||||||
npm run dev
|
npm run dev
|
||||||
|
npm run check
|
||||||
# or start the server and open the app in a new browser tab
|
|
||||||
npm run dev -- --open
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Building
|
Добавление компонентов shadcn (из каталога `web/`):
|
||||||
|
|
||||||
To create a production version of your app:
|
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
npm run build
|
npx shadcn-svelte@latest add <component> -y -o
|
||||||
```
|
```
|
||||||
|
|
||||||
You can preview the production build with `npm run preview`.
|
|
||||||
|
|
||||||
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
|
|
||||||
+1
-1
@@ -7,7 +7,7 @@
|
|||||||
"aliases": {
|
"aliases": {
|
||||||
"components": "$lib/components",
|
"components": "$lib/components",
|
||||||
"utils": "$lib/utils",
|
"utils": "$lib/utils",
|
||||||
"ui": "$lib/components/ui",
|
"ui": "$lib/ui/core",
|
||||||
"hooks": "$lib/hooks",
|
"hooks": "$lib/hooks",
|
||||||
"lib": "$lib"
|
"lib": "$lib"
|
||||||
},
|
},
|
||||||
|
|||||||
Generated
+641
-4
@@ -18,22 +18,54 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@internationalized/date": "^3.12.0",
|
"@internationalized/date": "^3.12.0",
|
||||||
"@lucide/svelte": "^1.7.0",
|
"@lucide/svelte": "^1.16.0",
|
||||||
"@sveltejs/adapter-static": "^3.0.10",
|
"@sveltejs/adapter-static": "^3.0.10",
|
||||||
"@sveltejs/kit": "^2.50.2",
|
"@sveltejs/kit": "^2.50.2",
|
||||||
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||||
"@tailwindcss/vite": "^4.1.18",
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
"@types/node": "^22.15.0",
|
"@types/node": "^22.15.0",
|
||||||
|
"formsnap": "^2.0.1",
|
||||||
"prettier": "^3.8.1",
|
"prettier": "^3.8.1",
|
||||||
"prettier-plugin-svelte": "^3.4.1",
|
"prettier-plugin-svelte": "^3.4.1",
|
||||||
"prettier-plugin-tailwindcss": "^0.7.2",
|
"prettier-plugin-tailwindcss": "^0.7.2",
|
||||||
"svelte": "^5.54.0",
|
"svelte": "^5.54.0",
|
||||||
"svelte-check": "^4.4.2",
|
"svelte-check": "^4.4.2",
|
||||||
|
"sveltekit-superforms": "^2.30.1",
|
||||||
"tailwindcss": "^4.1.18",
|
"tailwindcss": "^4.1.18",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"vite": "^7.3.1"
|
"vite": "^7.3.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@ark/schema": {
|
||||||
|
"version": "0.56.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@ark/schema/-/schema-0.56.0.tgz",
|
||||||
|
"integrity": "sha512-ECg3hox/6Z/nLajxXqNhgPtNdHWC9zNsDyskwO28WinoFEnWow4IsERNz9AnXRhTZJnYIlAJ4uGn3nlLk65vZA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@ark/util": "0.56.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@ark/util": {
|
||||||
|
"version": "0.56.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@ark/util/-/util-0.56.0.tgz",
|
||||||
|
"integrity": "sha512-BghfRC8b9pNs3vBoDJhcta0/c1J1rsoS1+HgVUreMFPdhz/CRAKReAu57YEllNaSy98rWAdY1gE+gFup7OXpgA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"node_modules/@babel/runtime": {
|
||||||
|
"version": "7.29.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz",
|
||||||
|
"integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.9.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@esbuild/aix-ppc64": {
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
"version": "0.27.7",
|
"version": "0.27.7",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz",
|
||||||
@@ -450,6 +482,14 @@
|
|||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@exodus/schemasafe": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@exodus/schemasafe/-/schemasafe-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-5Aap/GaRupgNx/feGBwLLTVv8OQFfv3pq2lPRzPg9R+IOBnDgghTGW7l7EuVXOvg5cc/xSAlRW8rBrjIC3Nvqw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
"node_modules/@floating-ui/core": {
|
"node_modules/@floating-ui/core": {
|
||||||
"version": "1.7.5",
|
"version": "1.7.5",
|
||||||
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz",
|
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz",
|
||||||
@@ -475,6 +515,25 @@
|
|||||||
"integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==",
|
"integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@hapi/hoek": {
|
||||||
|
"version": "9.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz",
|
||||||
|
"integrity": "sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"node_modules/@hapi/topo": {
|
||||||
|
"version": "5.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-5.1.0.tgz",
|
||||||
|
"integrity": "sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@hapi/hoek": "^9.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@internationalized/date": {
|
"node_modules/@internationalized/date": {
|
||||||
"version": "3.12.0",
|
"version": "3.12.0",
|
||||||
"resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.12.0.tgz",
|
"resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.12.0.tgz",
|
||||||
@@ -530,9 +589,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@lucide/svelte": {
|
"node_modules/@lucide/svelte": {
|
||||||
"version": "1.7.0",
|
"version": "1.16.0",
|
||||||
"resolved": "https://registry.npmjs.org/@lucide/svelte/-/svelte-1.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/@lucide/svelte/-/svelte-1.16.0.tgz",
|
||||||
"integrity": "sha512-YytBKOUBGox7yWcykZnYxOkn5WpR5G1qYXLYXV/j1B79SOTTEKzB+s5yF5Rq9l9OkweDStNH2b4yTqfvhEhV8g==",
|
"integrity": "sha512-AvvPJnaWxeiNkAljI5MsSEc84yHPLMaWQIAJOcbX7k9au/f9ITS7cxTTQiautDiOFKVOXiYdZ+d6mtl88J+Kbg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
@@ -546,6 +605,14 @@
|
|||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@poppinss/macroable": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@poppinss/macroable/-/macroable-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-FAVBRzzWhYP5mA3lCwLH1A0fKBqq5anyjGet90Z81aRK5c/+LTGUE1zJhZrErjaenBSOOI9BVUs3WVmotneFQA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
"node_modules/@rollup/rollup-android-arm-eabi": {
|
"node_modules/@rollup/rollup-android-arm-eabi": {
|
||||||
"version": "4.60.1",
|
"version": "4.60.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz",
|
||||||
@@ -871,6 +938,33 @@
|
|||||||
"win32"
|
"win32"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"node_modules/@sideway/address": {
|
||||||
|
"version": "4.1.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.5.tgz",
|
||||||
|
"integrity": "sha512-IqO/DUQHUkPeixNQ8n0JA6102hT9CmaljNTPmQ1u8MEhBo/R4Q8eKLN/vGZxuebwOroDB4cbpjheD4+/sKFK4Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@hapi/hoek": "^9.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@sideway/formula": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@sideway/formula/-/formula-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-/poHZJJVjx3L+zVD6g9KgHfYnb443oi7wLu/XKojDviHy6HOEOA6z1Trk5aR1dGcmPenJEgb2sK2I80LeS3MIg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"node_modules/@sideway/pinpoint": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@sideway/pinpoint/-/pinpoint-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
"node_modules/@standard-schema/spec": {
|
"node_modules/@standard-schema/spec": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||||
@@ -1301,6 +1395,49 @@
|
|||||||
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/validator": {
|
||||||
|
"version": "13.15.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.15.10.tgz",
|
||||||
|
"integrity": "sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"node_modules/@typeschema/class-validator": {
|
||||||
|
"version": "0.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typeschema/class-validator/-/class-validator-0.3.0.tgz",
|
||||||
|
"integrity": "sha512-OJSFeZDIQ8EK1HTljKLT5CItM2wsbgczLN8tMEfz3I1Lmhc5TBfkZ0eikFzUC16tI3d1Nag7um6TfCgp2I2Bww==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@typeschema/core": "0.14.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"class-validator": "^0.14.1"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"class-validator": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typeschema/core": {
|
||||||
|
"version": "0.14.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typeschema/core/-/core-0.14.0.tgz",
|
||||||
|
"integrity": "sha512-Ia6PtZHcL3KqsAWXjMi5xIyZ7XMH4aSnOQes8mfMLx+wGFGtGRNlwe6Y7cYvX+WfNK67OL0/HSe9t8QDygV0/w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/json-schema": "^7.0.15"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/json-schema": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@typescript-eslint/types": {
|
"node_modules/@typescript-eslint/types": {
|
||||||
"version": "8.58.0",
|
"version": "8.58.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.58.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.58.0.tgz",
|
||||||
@@ -1314,6 +1451,49 @@
|
|||||||
"url": "https://opencollective.com/typescript-eslint"
|
"url": "https://opencollective.com/typescript-eslint"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@valibot/to-json-schema": {
|
||||||
|
"version": "1.7.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@valibot/to-json-schema/-/to-json-schema-1.7.0.tgz",
|
||||||
|
"integrity": "sha512-Y3pPVibbIOHzohrlxSINvO7w/bvXkoYS3BQHoImV9ynE+bXKf171bdMucPurV2zp7gdmt0L1HCcNAsbo7cFRQw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"peerDependencies": {
|
||||||
|
"valibot": "^1.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vinejs/compiler": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vinejs/compiler/-/compiler-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-v9Lsv59nR56+bmy2p0+czjZxsLHwaibJ+SV5iK9JJfehlJMa501jUJQqqz4X/OqKXrxtE3uTQmSqjUqzF3B2mw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vinejs/vine": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vinejs/vine/-/vine-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-ZtvYkYpZOYdvbws3uaOAvTFuvFXoQGAtmzeiXu+XSMGxi5GVsODpoI9Xu9TplEMuD/5fmAtBbKb9cQHkWkLXDQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@poppinss/macroable": "^1.0.4",
|
||||||
|
"@types/validator": "^13.12.2",
|
||||||
|
"@vinejs/compiler": "^3.0.0",
|
||||||
|
"camelcase": "^8.0.0",
|
||||||
|
"dayjs": "^1.11.13",
|
||||||
|
"dlv": "^1.1.3",
|
||||||
|
"normalize-url": "^8.0.1",
|
||||||
|
"validator": "^13.12.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.16.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/acorn": {
|
"node_modules/acorn": {
|
||||||
"version": "8.16.0",
|
"version": "8.16.0",
|
||||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
||||||
@@ -1335,6 +1515,30 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/arkregex": {
|
||||||
|
"version": "0.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/arkregex/-/arkregex-0.0.5.tgz",
|
||||||
|
"integrity": "sha512-ncYjBdLlh5/QnVsAA8De16Tc9EqmYM7y/WU9j+236KcyYNUXogpz3sC4ATIZYzzLxwI+0sEOaQLEmLmRleaEXw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@ark/util": "0.56.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/arktype": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/arktype/-/arktype-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-t54MZ7ti5BhOEvzEkgKnWvqj+UbDfWig+DHr5I34xatymPusKLS0lQpNJd8M6DzmIto2QGszHfNKoFIT8tMCZQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@ark/schema": "0.56.0",
|
||||||
|
"@ark/util": "0.56.0",
|
||||||
|
"arkregex": "0.0.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/axobject-query": {
|
"node_modules/axobject-query": {
|
||||||
"version": "4.1.0",
|
"version": "4.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
|
||||||
@@ -1368,6 +1572,20 @@
|
|||||||
"svelte": "^5.33.0"
|
"svelte": "^5.33.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/camelcase": {
|
||||||
|
"version": "8.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-8.0.0.tgz",
|
||||||
|
"integrity": "sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/chokidar": {
|
"node_modules/chokidar": {
|
||||||
"version": "4.0.3",
|
"version": "4.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
|
||||||
@@ -1384,6 +1602,19 @@
|
|||||||
"url": "https://paulmillr.com/funding/"
|
"url": "https://paulmillr.com/funding/"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/class-validator": {
|
||||||
|
"version": "0.14.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.14.4.tgz",
|
||||||
|
"integrity": "sha512-AwNusCCam51q703dW82x95tOqQp6oC9HNUl724KxJJOfnKscI8dOloXFgyez7LbTTKWuRBA37FScqVbJEoq8Yw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@types/validator": "^13.15.3",
|
||||||
|
"libphonenumber-js": "^1.11.1",
|
||||||
|
"validator": "^13.15.22"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/clsx": {
|
"node_modules/clsx": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||||
@@ -1403,6 +1634,14 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/dayjs": {
|
||||||
|
"version": "1.11.20",
|
||||||
|
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.20.tgz",
|
||||||
|
"integrity": "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
"node_modules/deepmerge": {
|
"node_modules/deepmerge": {
|
||||||
"version": "4.3.1",
|
"version": "4.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
|
||||||
@@ -1438,6 +1677,26 @@
|
|||||||
"integrity": "sha512-Gp6rDldRsFh/7XuouDbxMH3Mx8GMCcgzIb1pDTvNyn8pZGQ22u+Wa+lGV9dQCltFQ7uVw0MhRyb8XDskNFOReA==",
|
"integrity": "sha512-Gp6rDldRsFh/7XuouDbxMH3Mx8GMCcgzIb1pDTvNyn8pZGQ22u+Wa+lGV9dQCltFQ7uVw0MhRyb8XDskNFOReA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/dlv": {
|
||||||
|
"version": "1.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz",
|
||||||
|
"integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"node_modules/effect": {
|
||||||
|
"version": "3.21.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/effect/-/effect-3.21.2.tgz",
|
||||||
|
"integrity": "sha512-rXd2FGDM8KdjSIrc+mqEELo7ScW7xTVxEf1iInmPSpIde9/nyGuFM710cjTo7/EreGXiUX2MOonPpprbz2XHCg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@standard-schema/spec": "^1.0.0",
|
||||||
|
"fast-check": "^3.23.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/enhanced-resolve": {
|
"node_modules/enhanced-resolve": {
|
||||||
"version": "5.20.1",
|
"version": "5.20.1",
|
||||||
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz",
|
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz",
|
||||||
@@ -1510,6 +1769,30 @@
|
|||||||
"@typescript-eslint/types": "^8.2.0"
|
"@typescript-eslint/types": "^8.2.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/fast-check": {
|
||||||
|
"version": "3.23.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz",
|
||||||
|
"integrity": "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==",
|
||||||
|
"dev": true,
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "individual",
|
||||||
|
"url": "https://github.com/sponsors/dubzzz"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/fast-check"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"pure-rand": "^6.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/fdir": {
|
"node_modules/fdir": {
|
||||||
"version": "6.5.0",
|
"version": "6.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||||
@@ -1528,6 +1811,47 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/formsnap": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/formsnap/-/formsnap-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-iJSe4YKd/W6WhLwKDVJU9FQeaJRpEFuolhju7ZXlRpUVyDdqFdMP8AUBICgnVvQPyP41IPAlBa/v0Eo35iE6wQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"svelte-toolbelt": "^0.5.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18",
|
||||||
|
"pnpm": ">=8.7.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/huntabyte"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"svelte": "^5.0.0",
|
||||||
|
"sveltekit-superforms": "^2.19.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/formsnap/node_modules/svelte-toolbelt": {
|
||||||
|
"version": "0.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/svelte-toolbelt/-/svelte-toolbelt-0.5.0.tgz",
|
||||||
|
"integrity": "sha512-t3tenZcnfQoIeRuQf/jBU7bvTeT3TGkcEE+1EUr5orp0lR7NEpprflpuie3x9Dn0W9nOKqs3HwKGJeeN5Ok1sQ==",
|
||||||
|
"dev": true,
|
||||||
|
"funding": [
|
||||||
|
"https://github.com/sponsors/huntabyte"
|
||||||
|
],
|
||||||
|
"dependencies": {
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"style-to-object": "^1.0.8"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18",
|
||||||
|
"pnpm": ">=8.7.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"svelte": "^5.0.0-next.126"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/fsevents": {
|
"node_modules/fsevents": {
|
||||||
"version": "2.3.3",
|
"version": "2.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||||
@@ -1574,6 +1898,36 @@
|
|||||||
"jiti": "lib/jiti-cli.mjs"
|
"jiti": "lib/jiti-cli.mjs"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/joi": {
|
||||||
|
"version": "17.13.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/joi/-/joi-17.13.3.tgz",
|
||||||
|
"integrity": "sha512-otDA4ldcIx+ZXsKHWmp0YizCweVRZG96J10b0FevjfuncLO1oX59THoAmHkNubYJ+9gWsYsp5k8v4ib6oDv1fA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@hapi/hoek": "^9.3.0",
|
||||||
|
"@hapi/topo": "^5.1.0",
|
||||||
|
"@sideway/address": "^4.1.5",
|
||||||
|
"@sideway/formula": "^3.0.1",
|
||||||
|
"@sideway/pinpoint": "^2.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/json-schema-to-ts": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.18.3",
|
||||||
|
"ts-algebra": "^2.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/kleur": {
|
"node_modules/kleur": {
|
||||||
"version": "4.1.5",
|
"version": "4.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz",
|
||||||
@@ -1584,6 +1938,14 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/libphonenumber-js": {
|
||||||
|
"version": "1.13.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.13.2.tgz",
|
||||||
|
"integrity": "sha512-S3kmBrptp3yRTm83NUcHy9g1vbwiWMzI8WvY22+koBJ6zkRteLnedBL2VX0MIAGwx2yiyxX4J85pceZyQ6ffgg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
"node_modules/lightningcss": {
|
"node_modules/lightningcss": {
|
||||||
"version": "1.32.0",
|
"version": "1.32.0",
|
||||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
|
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
|
||||||
@@ -1858,6 +2220,13 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/memoize-weak": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/memoize-weak/-/memoize-weak-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-gj39xkrjEw7nCn4nJ1M5ms6+MyMlyiGmttzsqAUsAKn6bYKwuTHh/AO3cKPF8IBrTIYTxb0wWXFs3E//Y8VoWQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/mri": {
|
"node_modules/mri": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz",
|
||||||
@@ -1897,6 +2266,20 @@
|
|||||||
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/normalize-url": {
|
||||||
|
"version": "8.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-8.1.1.tgz",
|
||||||
|
"integrity": "sha512-JYc0DPlpGWB40kH5g07gGTrYuMqV653k3uBKY6uITPWds3M0ov3GaWGp9lbE3Bzngx8+XkfzgvASb9vk9JDFXQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.16"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/obug": {
|
"node_modules/obug": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
|
||||||
@@ -2063,6 +2446,32 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/property-expr": {
|
||||||
|
"version": "2.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/property-expr/-/property-expr-2.0.6.tgz",
|
||||||
|
"integrity": "sha512-SVtmxhRE/CGkn3eZY1T6pC8Nln6Fr/lu1mKSgRud0eC73whjGfoAogbn78LkD8aFL0zz3bAFerKSnOl7NlErBA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"node_modules/pure-rand": {
|
||||||
|
"version": "6.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz",
|
||||||
|
"integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==",
|
||||||
|
"dev": true,
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "individual",
|
||||||
|
"url": "https://github.com/sponsors/dubzzz"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/fast-check"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
"node_modules/readdirp": {
|
"node_modules/readdirp": {
|
||||||
"version": "4.1.2",
|
"version": "4.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
|
||||||
@@ -2200,6 +2609,17 @@
|
|||||||
"inline-style-parser": "0.2.7"
|
"inline-style-parser": "0.2.7"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/superstruct": {
|
||||||
|
"version": "2.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/superstruct/-/superstruct-2.0.2.tgz",
|
||||||
|
"integrity": "sha512-uV+TFRZdXsqXTL2pRvujROjdZQ4RAlBUS5BTh9IGm+jTqQntYThciG/qu57Gs69yjnVUSqdxF9YLmSnpupBW9A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/svelte": {
|
"node_modules/svelte": {
|
||||||
"version": "5.55.1",
|
"version": "5.55.1",
|
||||||
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.55.1.tgz",
|
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.55.1.tgz",
|
||||||
@@ -2299,6 +2719,104 @@
|
|||||||
"svelte": "^5.30.2"
|
"svelte": "^5.30.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/sveltekit-superforms": {
|
||||||
|
"version": "2.30.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/sveltekit-superforms/-/sveltekit-superforms-2.30.1.tgz",
|
||||||
|
"integrity": "sha512-wBzyqsE0idvEJWuNJ+HCiAtdxa7Z55GZ8jmtlVHJfonrk9bRYC49MoPaloYyFoYuU3QPy6Omna/Qzn1kaIkgew==",
|
||||||
|
"dev": true,
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ciscoheat"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "ko-fi",
|
||||||
|
"url": "https://ko-fi.com/ciscoheat"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "paypal",
|
||||||
|
"url": "https://www.paypal.com/donate/?hosted_button_id=NY7F5ALHHSVQS"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"devalue": "^5.6.4",
|
||||||
|
"memoize-weak": "^1.0.2",
|
||||||
|
"ts-deepmerge": "^7.0.3"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@exodus/schemasafe": "^1.3.0",
|
||||||
|
"@standard-schema/spec": "^1.1.0",
|
||||||
|
"@typeschema/class-validator": "^0.3.0",
|
||||||
|
"@valibot/to-json-schema": "^1.6.0",
|
||||||
|
"@vinejs/vine": "^3.0.1",
|
||||||
|
"arktype": "^2.2.0",
|
||||||
|
"class-validator": "^0.14.4",
|
||||||
|
"effect": "^3.21.0",
|
||||||
|
"joi": "^17.13.3",
|
||||||
|
"json-schema-to-ts": "^3.1.1",
|
||||||
|
"superstruct": "^2.0.2",
|
||||||
|
"typebox": "^1.1.6",
|
||||||
|
"valibot": "^1.3.1",
|
||||||
|
"yup": "^1.7.1",
|
||||||
|
"zod": "^4.3.6",
|
||||||
|
"zod-v3-to-json-schema": "^4.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@exodus/schemasafe": "^1.3.0",
|
||||||
|
"@sveltejs/kit": "1.x || 2.x",
|
||||||
|
"@typeschema/class-validator": "^0.3.0",
|
||||||
|
"@vinejs/vine": "^1.8.0 || ^2.0.0 || ^3.0.0",
|
||||||
|
"arktype": ">=2.0.0-rc.23",
|
||||||
|
"class-validator": "^0.14.1",
|
||||||
|
"effect": "^3.21.0",
|
||||||
|
"joi": "^17.13.1",
|
||||||
|
"superstruct": "^2.0.2",
|
||||||
|
"svelte": "3.x || 4.x || >=5.0.0-next.51",
|
||||||
|
"typebox": "^1.0.36",
|
||||||
|
"valibot": "^1.2.0",
|
||||||
|
"yup": "^1.4.0",
|
||||||
|
"zod": "^3.25.0 || ^4.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@exodus/schemasafe": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@typeschema/class-validator": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@vinejs/vine": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"arktype": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"class-validator": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"effect": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"joi": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"superstruct": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"typebox": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"valibot": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"yup": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"zod": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/tabbable": {
|
"node_modules/tabbable": {
|
||||||
"version": "6.4.0",
|
"version": "6.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.4.0.tgz",
|
||||||
@@ -2354,6 +2872,14 @@
|
|||||||
"url": "https://opencollective.com/webpack"
|
"url": "https://opencollective.com/webpack"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tiny-case": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/tiny-case/-/tiny-case-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
"node_modules/tinyglobby": {
|
"node_modules/tinyglobby": {
|
||||||
"version": "0.2.15",
|
"version": "0.2.15",
|
||||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
||||||
@@ -2371,6 +2897,14 @@
|
|||||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
"url": "https://github.com/sponsors/SuperchupuDev"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/toposort": {
|
||||||
|
"version": "2.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/toposort/-/toposort-2.0.2.tgz",
|
||||||
|
"integrity": "sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
"node_modules/totalist": {
|
"node_modules/totalist": {
|
||||||
"version": "3.0.1",
|
"version": "3.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz",
|
||||||
@@ -2381,6 +2915,24 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ts-algebra": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"node_modules/ts-deepmerge": {
|
||||||
|
"version": "7.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/ts-deepmerge/-/ts-deepmerge-7.0.3.tgz",
|
||||||
|
"integrity": "sha512-Du/ZW2RfwV/D4cmA5rXafYjBQVuvu4qGiEEla4EmEHVHgRdx68Gftx7i66jn2bzHPwSVZY36Ae6OuDn9el4ZKA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.13.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/tslib": {
|
"node_modules/tslib": {
|
||||||
"version": "2.8.1",
|
"version": "2.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||||
@@ -2396,6 +2948,28 @@
|
|||||||
"url": "https://github.com/sponsors/Wombosvideo"
|
"url": "https://github.com/sponsors/Wombosvideo"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/type-fest": {
|
||||||
|
"version": "2.19.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz",
|
||||||
|
"integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "(MIT OR CC0-1.0)",
|
||||||
|
"optional": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.20"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/typebox": {
|
||||||
|
"version": "1.1.38",
|
||||||
|
"resolved": "https://registry.npmjs.org/typebox/-/typebox-1.1.38.tgz",
|
||||||
|
"integrity": "sha512-pZ0aQPmMmXoUvSbeuWf/Hzsc+avNw/Zd6VeE8CFgkVGWyuHPJvqeJJDeJqLve+K70LvjYIoleGcoJHPT17cWoA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
"node_modules/typescript": {
|
"node_modules/typescript": {
|
||||||
"version": "5.9.3",
|
"version": "5.9.3",
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||||
@@ -2417,6 +2991,33 @@
|
|||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/valibot": {
|
||||||
|
"version": "1.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/valibot/-/valibot-1.4.0.tgz",
|
||||||
|
"integrity": "sha512-iC/x7fVcSyOwlm/VSt7RlHnzNGLGvR9GnxdifUeWoCJo0q4ZZvrVkIHC6faTlkxG47I2Y4UrFquPuVHCrOnrLg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"peerDependencies": {
|
||||||
|
"typescript": ">=5"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"typescript": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/validator": {
|
||||||
|
"version": "13.15.35",
|
||||||
|
"resolved": "https://registry.npmjs.org/validator/-/validator-13.15.35.tgz",
|
||||||
|
"integrity": "sha512-TQ5pAGhd5whStmqWvYF4OjQROlmv9SMFVt37qoCBdqRffuuklWYQlCNnEs2ZaIBD1kZRNnikiZOS1eqgkar0iw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "7.3.1",
|
"version": "7.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
|
||||||
@@ -2512,11 +3113,47 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/yup": {
|
||||||
|
"version": "1.7.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/yup/-/yup-1.7.1.tgz",
|
||||||
|
"integrity": "sha512-GKHFX2nXul2/4Dtfxhozv701jLQHdf6J34YDh2cEkpqoo8le5Mg6/LrdseVLrFarmFygZTlfIhHx/QKfb/QWXw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"property-expr": "^2.0.5",
|
||||||
|
"tiny-case": "^1.0.3",
|
||||||
|
"toposort": "^2.0.2",
|
||||||
|
"type-fest": "^2.19.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/zimmerframe": {
|
"node_modules/zimmerframe": {
|
||||||
"version": "1.1.4",
|
"version": "1.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz",
|
||||||
"integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==",
|
"integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/zod": {
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/zod-v3-to-json-schema": {
|
||||||
|
"version": "4.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/zod-v3-to-json-schema/-/zod-v3-to-json-schema-4.0.0.tgz",
|
||||||
|
"integrity": "sha512-KixLrhX/uPmRFnDgsZrzrk4x5SSJA+PmaE5adbfID9+3KPJcdxqRobaHU397EfWBqfQircrjKqvEqZ/mW5QH6w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"optional": true,
|
||||||
|
"peerDependencies": {
|
||||||
|
"zod": "^3.25 || ^4.0.14"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+3
-1
@@ -15,17 +15,19 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@internationalized/date": "^3.12.0",
|
"@internationalized/date": "^3.12.0",
|
||||||
"@lucide/svelte": "^1.7.0",
|
"@lucide/svelte": "^1.16.0",
|
||||||
"@sveltejs/adapter-static": "^3.0.10",
|
"@sveltejs/adapter-static": "^3.0.10",
|
||||||
"@sveltejs/kit": "^2.50.2",
|
"@sveltejs/kit": "^2.50.2",
|
||||||
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||||
"@tailwindcss/vite": "^4.1.18",
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
"@types/node": "^22.15.0",
|
"@types/node": "^22.15.0",
|
||||||
|
"formsnap": "^2.0.1",
|
||||||
"prettier": "^3.8.1",
|
"prettier": "^3.8.1",
|
||||||
"prettier-plugin-svelte": "^3.4.1",
|
"prettier-plugin-svelte": "^3.4.1",
|
||||||
"prettier-plugin-tailwindcss": "^0.7.2",
|
"prettier-plugin-tailwindcss": "^0.7.2",
|
||||||
"svelte": "^5.54.0",
|
"svelte": "^5.54.0",
|
||||||
"svelte-check": "^4.4.2",
|
"svelte-check": "^4.4.2",
|
||||||
|
"sveltekit-superforms": "^2.30.1",
|
||||||
"tailwindcss": "^4.1.18",
|
"tailwindcss": "^4.1.18",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"vite": "^7.3.1"
|
"vite": "^7.3.1"
|
||||||
|
|||||||
@@ -1,208 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { resolve } from '$app/paths';
|
|
||||||
import { page } from '$app/state';
|
|
||||||
import { cn } from '$lib/utils.js';
|
|
||||||
import { Button } from '$lib/components/ui/button/index.js';
|
|
||||||
import { buttonVariants } from '$lib/components/ui/button/button.svelte';
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuLabel,
|
|
||||||
DropdownMenuRadioGroup,
|
|
||||||
DropdownMenuRadioItem,
|
|
||||||
DropdownMenuTrigger
|
|
||||||
} from '$lib/components/ui/dropdown-menu/index.js';
|
|
||||||
import { Separator } from '$lib/components/ui/separator/index.js';
|
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '$lib/components/ui/tooltip/index.js';
|
|
||||||
import type { ThemePreference } from '$lib/theme.js';
|
|
||||||
|
|
||||||
import Activity from '@lucide/svelte/icons/activity';
|
|
||||||
import Boxes from '@lucide/svelte/icons/boxes';
|
|
||||||
import BookOpen from '@lucide/svelte/icons/book-open';
|
|
||||||
import LayoutDashboard from '@lucide/svelte/icons/layout-dashboard';
|
|
||||||
import Network from '@lucide/svelte/icons/network';
|
|
||||||
import Settings from '@lucide/svelte/icons/settings';
|
|
||||||
import Gauge from '@lucide/svelte/icons/gauge';
|
|
||||||
import Zap from '@lucide/svelte/icons/zap';
|
|
||||||
import CalendarClock from '@lucide/svelte/icons/calendar-clock';
|
|
||||||
import PanelLeftClose from '@lucide/svelte/icons/panel-left-close';
|
|
||||||
import PanelLeftOpen from '@lucide/svelte/icons/panel-left-open';
|
|
||||||
import Sun from '@lucide/svelte/icons/sun';
|
|
||||||
import Moon from '@lucide/svelte/icons/moon';
|
|
||||||
import Monitor from '@lucide/svelte/icons/monitor';
|
|
||||||
|
|
||||||
const nav = [
|
|
||||||
{ href: '/', label: 'Обзор', icon: LayoutDashboard },
|
|
||||||
{ href: '/modules', label: 'Модули', icon: Boxes },
|
|
||||||
{ href: '/directories', label: 'Справочники', icon: BookOpen },
|
|
||||||
{ href: '/network', label: 'Сеть', icon: Network },
|
|
||||||
{ href: '/revisions', label: 'Ревизии', icon: Activity },
|
|
||||||
{ href: '/operations', label: 'Операции', icon: Zap },
|
|
||||||
{ href: '/schedule', label: 'Расписание', icon: CalendarClock },
|
|
||||||
{ href: '/monitoring', label: 'Мониторинг', icon: Gauge },
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
const navBottom = [
|
|
||||||
{ href: '/settings', label: 'Настройки', icon: Settings },
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
let collapsed = $state(false);
|
|
||||||
let { children, theme = $bindable<ThemePreference>('system') } = $props();
|
|
||||||
|
|
||||||
function isActive(href: string) {
|
|
||||||
const pathname = page.url.pathname;
|
|
||||||
if (href === '/') return pathname === '/';
|
|
||||||
return pathname === href || pathname.startsWith(href + '/');
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<TooltipProvider delayDuration={0}>
|
|
||||||
<div class="bg-background flex min-h-screen">
|
|
||||||
<aside
|
|
||||||
class={cn(
|
|
||||||
'border-border bg-sidebar sticky top-0 flex h-screen shrink-0 flex-col overflow-hidden border-r transition-all duration-200',
|
|
||||||
collapsed ? 'w-14' : 'w-56'
|
|
||||||
)}
|
|
||||||
aria-label="Основная навигация"
|
|
||||||
>
|
|
||||||
<!-- Header -->
|
|
||||||
<div class={cn('flex items-center gap-2 p-3', collapsed && 'justify-center')}>
|
|
||||||
{#if !collapsed}
|
|
||||||
<div class="min-w-0 flex-1">
|
|
||||||
<a href={resolve('/')} class="text-sidebar-foreground block truncate font-semibold tracking-tight">EvoBGP</a>
|
|
||||||
<p class="text-sidebar-foreground/50 truncate text-xs">Панель управления</p>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger
|
|
||||||
class={cn(
|
|
||||||
buttonVariants({ variant: 'ghost', size: 'icon-sm' }),
|
|
||||||
'text-sidebar-foreground/60 hover:text-sidebar-foreground shrink-0'
|
|
||||||
)}
|
|
||||||
aria-label="Тема оформления"
|
|
||||||
>
|
|
||||||
<span class="flex size-4 items-center justify-center">
|
|
||||||
<Sun class="size-4 dark:hidden" />
|
|
||||||
<Moon class="hidden size-4 dark:block" />
|
|
||||||
</span>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end" class="w-44">
|
|
||||||
<DropdownMenuLabel>Тема</DropdownMenuLabel>
|
|
||||||
<DropdownMenuRadioGroup bind:value={theme}>
|
|
||||||
<DropdownMenuRadioItem value="light" class="gap-2">
|
|
||||||
<Sun class="size-4" />
|
|
||||||
Светлая
|
|
||||||
</DropdownMenuRadioItem>
|
|
||||||
<DropdownMenuRadioItem value="dark" class="gap-2">
|
|
||||||
<Moon class="size-4" />
|
|
||||||
Тёмная
|
|
||||||
</DropdownMenuRadioItem>
|
|
||||||
<DropdownMenuRadioItem value="system" class="gap-2">
|
|
||||||
<Monitor class="size-4" />
|
|
||||||
Как в системе
|
|
||||||
</DropdownMenuRadioItem>
|
|
||||||
</DropdownMenuRadioGroup>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
<button
|
|
||||||
onclick={() => (collapsed = !collapsed)}
|
|
||||||
class={cn(
|
|
||||||
buttonVariants({ variant: 'ghost', size: 'icon-sm' }),
|
|
||||||
'text-sidebar-foreground/60 hover:text-sidebar-foreground shrink-0'
|
|
||||||
)}
|
|
||||||
aria-label={collapsed ? 'Развернуть меню' : 'Свернуть меню'}
|
|
||||||
>
|
|
||||||
{#if collapsed}
|
|
||||||
<PanelLeftOpen class="size-4" />
|
|
||||||
{:else}
|
|
||||||
<PanelLeftClose class="size-4" />
|
|
||||||
{/if}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<Separator />
|
|
||||||
|
|
||||||
<!-- Main nav -->
|
|
||||||
<nav class="flex flex-1 flex-col gap-0.5 overflow-y-auto p-2">
|
|
||||||
{#each nav as item (item.href)}
|
|
||||||
{@const Icon = item.icon}
|
|
||||||
{@const active = isActive(item.href)}
|
|
||||||
{#if collapsed}
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger>
|
|
||||||
<a
|
|
||||||
href={resolve(item.href)}
|
|
||||||
class={cn(
|
|
||||||
buttonVariants({ variant: active ? 'secondary' : 'ghost', size: 'icon-sm' }),
|
|
||||||
'w-full no-underline flex items-center justify-center',
|
|
||||||
active && 'ring-sidebar-primary/50 bg-sidebar-accent/90 text-sidebar-accent-foreground ring-1'
|
|
||||||
)}
|
|
||||||
aria-current={active ? 'page' : undefined}
|
|
||||||
>
|
|
||||||
<Icon class="size-4" />
|
|
||||||
</a>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="right">{item.label}</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
{:else}
|
|
||||||
<a
|
|
||||||
href={resolve(item.href)}
|
|
||||||
class={cn(
|
|
||||||
buttonVariants({ variant: active ? 'secondary' : 'ghost', size: 'sm' }),
|
|
||||||
'w-full justify-start gap-2 no-underline',
|
|
||||||
active &&
|
|
||||||
'border-l-sidebar-primary bg-sidebar-accent/80 text-sidebar-accent-foreground border-l-2 shadow-sm'
|
|
||||||
)}
|
|
||||||
aria-current={active ? 'page' : undefined}
|
|
||||||
>
|
|
||||||
<Icon class="size-4" />
|
|
||||||
{item.label}
|
|
||||||
</a>
|
|
||||||
{/if}
|
|
||||||
{/each}
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<!-- Bottom nav -->
|
|
||||||
<div class="flex flex-col gap-0.5 p-2">
|
|
||||||
<Separator class="mb-2" />
|
|
||||||
{#each navBottom as item (item.href)}
|
|
||||||
{@const Icon = item.icon}
|
|
||||||
{@const active = isActive(item.href)}
|
|
||||||
{#if collapsed}
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger>
|
|
||||||
<a
|
|
||||||
href={resolve(item.href)}
|
|
||||||
class={cn(
|
|
||||||
buttonVariants({ variant: active ? 'secondary' : 'ghost', size: 'icon-sm' }),
|
|
||||||
'w-full no-underline flex items-center justify-center',
|
|
||||||
active && 'ring-sidebar-primary/50 bg-sidebar-accent/90 text-sidebar-accent-foreground ring-1'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Icon class="size-4" />
|
|
||||||
</a>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="right">{item.label}</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
{:else}
|
|
||||||
<a
|
|
||||||
href={resolve(item.href)}
|
|
||||||
class={cn(
|
|
||||||
buttonVariants({ variant: active ? 'secondary' : 'ghost', size: 'sm' }),
|
|
||||||
'w-full justify-start gap-2 no-underline',
|
|
||||||
active &&
|
|
||||||
'border-l-sidebar-primary bg-sidebar-accent/80 text-sidebar-accent-foreground border-l-2 shadow-sm'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Icon class="size-4" />
|
|
||||||
{item.label}
|
|
||||||
</a>
|
|
||||||
{/if}
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<main class="min-w-0 flex-1 p-6">
|
|
||||||
{@render children?.()}
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
</TooltipProvider>
|
|
||||||
@@ -8,6 +8,8 @@ export type Page<T> = {
|
|||||||
// ---- Modules ----
|
// ---- Modules ----
|
||||||
export type ModuleType = 'AS_PREFIXES' | 'CDN_CIDRS' | 'DOMAINS' | 'IP_RANGES';
|
export type ModuleType = 'AS_PREFIXES' | 'CDN_CIDRS' | 'DOMAINS' | 'IP_RANGES';
|
||||||
|
|
||||||
|
export type DohResolverPolicy = 'primary_only' | 'failover' | 'union';
|
||||||
|
|
||||||
export type ModuleRow = {
|
export type ModuleRow = {
|
||||||
id: string;
|
id: string;
|
||||||
type: ModuleType;
|
type: ModuleType;
|
||||||
@@ -17,7 +19,10 @@ export type ModuleRow = {
|
|||||||
refresh_interval_sec: number | null;
|
refresh_interval_sec: number | null;
|
||||||
cron_expr: string | null;
|
cron_expr: string | null;
|
||||||
default_community_id: string | null;
|
default_community_id: string | null;
|
||||||
|
/** @deprecated use doh_profile_ids */
|
||||||
doh_profile_id: string | null;
|
doh_profile_id: string | null;
|
||||||
|
doh_profile_ids: string[];
|
||||||
|
doh_resolver_policy: DohResolverPolicy;
|
||||||
last_refreshed_at: string | null;
|
last_refreshed_at: string | null;
|
||||||
};
|
};
|
||||||
export type ModulesResponse = Page<ModuleRow>;
|
export type ModulesResponse = Page<ModuleRow>;
|
||||||
@@ -36,6 +41,8 @@ export type ModuleCreate = {
|
|||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
priority?: number;
|
priority?: number;
|
||||||
doh_profile_id?: string | null;
|
doh_profile_id?: string | null;
|
||||||
|
doh_profile_ids?: string[];
|
||||||
|
doh_resolver_policy?: DohResolverPolicy;
|
||||||
refresh_interval_sec?: number | null;
|
refresh_interval_sec?: number | null;
|
||||||
cron_expr?: string | null;
|
cron_expr?: string | null;
|
||||||
default_community_id?: string | null;
|
default_community_id?: string | null;
|
||||||
@@ -117,11 +124,13 @@ export type IpRangeEntriesResponse = Page<IpRangeEntry>;
|
|||||||
// ---- DoH Profiles ----
|
// ---- DoH Profiles ----
|
||||||
export type DohProfile = {
|
export type DohProfile = {
|
||||||
id: string;
|
id: string;
|
||||||
|
name?: string;
|
||||||
url: string;
|
url: string;
|
||||||
timeout_ms: number | null;
|
timeout_ms: number | null;
|
||||||
vault_secret_ref: string | null;
|
vault_secret_ref: string | null;
|
||||||
};
|
};
|
||||||
export type DohProfileCreate = {
|
export type DohProfileCreate = {
|
||||||
|
name?: string;
|
||||||
url: string;
|
url: string;
|
||||||
timeout_ms?: number | null;
|
timeout_ms?: number | null;
|
||||||
vault_secret_ref?: string | null;
|
vault_secret_ref?: string | null;
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
/** Ответ GET /v1/version (см. docs/openapi.yaml VersionInfo). */
|
||||||
|
export type VersionInfo = {
|
||||||
|
version?: string;
|
||||||
|
api_version?: string;
|
||||||
|
git_sha?: string;
|
||||||
|
build_time?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const PLACEHOLDER_SHA = new Set(['', 'unknown']);
|
||||||
|
|
||||||
|
function nonEmptyString(value: unknown): string {
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const s = value.trim();
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
if (value == null) return '';
|
||||||
|
return String(value).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Semver или legacy api_version; пустая строка, если полей нет. */
|
||||||
|
export function resolveVersionString(info: VersionInfo | null | undefined): string {
|
||||||
|
if (!info) return '';
|
||||||
|
return nonEmptyString(info.version) || nonEmptyString(info.api_version);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Короткий git SHA; пусто для placeholder «unknown». */
|
||||||
|
export function resolveGitSha(info: VersionInfo | null | undefined): string {
|
||||||
|
if (!info) return '';
|
||||||
|
const sha = nonEmptyString(info.git_sha);
|
||||||
|
if (!sha || PLACEHOLDER_SHA.has(sha.toLowerCase())) return '';
|
||||||
|
return sha;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Заголовок карточки «Версия» на /monitoring. */
|
||||||
|
export function formatVersionHeadline(info: VersionInfo | null | undefined): string {
|
||||||
|
if (!info) return '—';
|
||||||
|
const ver = resolveVersionString(info);
|
||||||
|
const sha = resolveGitSha(info);
|
||||||
|
if (ver && sha) return `${ver} (${sha.slice(0, 12)})`;
|
||||||
|
if (ver) return ver;
|
||||||
|
if (sha) return sha.slice(0, 12);
|
||||||
|
return '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Подпись в сайдбаре (v1.2.3). */
|
||||||
|
export function formatVersionSidebarLabel(info: VersionInfo | null | undefined): string | null {
|
||||||
|
const ver = resolveVersionString(info);
|
||||||
|
return ver ? `v${ver}` : null;
|
||||||
|
}
|
||||||
@@ -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,342 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { apiFetch, apiMutate } from '$lib/api/client.js';
|
||||||
|
import type { AsEntry, BgpCommunity, ModuleRow } from '$lib/api/types.js';
|
||||||
|
import { formatDateTime } from '$lib/modules/display.js';
|
||||||
|
import {
|
||||||
|
communityLabel,
|
||||||
|
sanitizeFilenamePart,
|
||||||
|
supportsCsvIO
|
||||||
|
} from '$lib/components/modules/module-helpers.js';
|
||||||
|
|
||||||
|
import { Button } from '$lib/ui/core/button/index.js';
|
||||||
|
import { Checkbox } from '$lib/ui/core/checkbox/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 { confirm } from '$lib/ui/patterns/confirm/confirm-state.svelte.js';
|
||||||
|
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
|
||||||
|
import ModuleAsEntryDialog from '$lib/components/modules/ModuleAsEntryDialog.svelte';
|
||||||
|
import Plus from '@lucide/svelte/icons/plus';
|
||||||
|
import Pencil from '@lucide/svelte/icons/pencil';
|
||||||
|
import Trash2 from '@lucide/svelte/icons/trash-2';
|
||||||
|
import Upload from '@lucide/svelte/icons/upload';
|
||||||
|
import Download from '@lucide/svelte/icons/download';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
moduleId: string;
|
||||||
|
mod: ModuleRow;
|
||||||
|
entries: AsEntry[];
|
||||||
|
communities: BgpCommunity[];
|
||||||
|
loading?: boolean;
|
||||||
|
onChanged: () => void | Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
let { moduleId, mod, entries, communities, loading = false, onChanged }: Props = $props();
|
||||||
|
|
||||||
|
let dialogOpen = $state(false);
|
||||||
|
let editTarget = $state<AsEntry | null>(null);
|
||||||
|
let selectedIds = $state(new Set<string>());
|
||||||
|
let deletingBulk = $state(false);
|
||||||
|
let csvImporting = $state(false);
|
||||||
|
let csvExporting = $state(false);
|
||||||
|
let csvFileInput = $state<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
|
const activeSelected = $derived.by(() => {
|
||||||
|
const allowed = new Set(entries.map((e) => e.id));
|
||||||
|
return [...selectedIds].filter((id) => allowed.has(id));
|
||||||
|
});
|
||||||
|
const selectedCount = $derived(activeSelected.length);
|
||||||
|
|
||||||
|
const allSelected = $derived(entries.length > 0 && entries.every((e) => selectedIds.has(e.id)));
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ id: 'select', label: '', class: 'w-10' },
|
||||||
|
{ id: 'asn', label: 'ASN', sortable: true, sortValue: (e: AsEntry) => e.asn },
|
||||||
|
{
|
||||||
|
id: 'name',
|
||||||
|
label: 'Название AS',
|
||||||
|
sortable: true,
|
||||||
|
sortValue: (e: AsEntry) => e.asn_name ?? ''
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'prefixes',
|
||||||
|
label: 'Префиксов',
|
||||||
|
sortable: true,
|
||||||
|
sortValue: (e: AsEntry) => e.prefix_count ?? 0,
|
||||||
|
class: 'text-right'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'updated',
|
||||||
|
label: 'Обновлено',
|
||||||
|
sortable: true,
|
||||||
|
sortValue: (e: AsEntry) => e.asn_resolved_at ?? ''
|
||||||
|
},
|
||||||
|
{ id: 'community', label: 'Community' },
|
||||||
|
{ id: 'actions', label: '', class: 'w-20' }
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
function toggleSelection(id: string) {
|
||||||
|
const next = new Set(selectedIds);
|
||||||
|
if (next.has(id)) next.delete(id);
|
||||||
|
else next.add(id);
|
||||||
|
selectedIds = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleAll(checked: boolean) {
|
||||||
|
selectedIds = checked ? new Set(entries.map((e) => e.id)) : new Set<string>();
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreate() {
|
||||||
|
editTarget = null;
|
||||||
|
dialogOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(entry: AsEntry) {
|
||||||
|
editTarget = entry;
|
||||||
|
dialogOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestDelete(entry: AsEntry) {
|
||||||
|
void confirm({
|
||||||
|
title: 'Удалить запись?',
|
||||||
|
description: `ASN: ${entry.asn}`,
|
||||||
|
confirmLabel: 'Удалить',
|
||||||
|
destructive: true,
|
||||||
|
onConfirm: async () => {
|
||||||
|
await apiMutate(`/v1/modules/${moduleId}/as-entries/${entry.id}`, 'DELETE', undefined, {
|
||||||
|
idempotent: false
|
||||||
|
});
|
||||||
|
notify.success('Удалено');
|
||||||
|
await onChanged();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestBulkDelete() {
|
||||||
|
if (selectedCount === 0) return;
|
||||||
|
void confirm({
|
||||||
|
title: 'Удалить выбранные AS-записи?',
|
||||||
|
description: `Будет удалено: ${selectedCount}`,
|
||||||
|
confirmLabel: 'Удалить',
|
||||||
|
destructive: true,
|
||||||
|
onConfirm: bulkDelete
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bulkDelete() {
|
||||||
|
if (selectedCount === 0) return;
|
||||||
|
deletingBulk = true;
|
||||||
|
let deleted = 0;
|
||||||
|
try {
|
||||||
|
for (const id of activeSelected) {
|
||||||
|
try {
|
||||||
|
await apiMutate(`/v1/modules/${moduleId}/as-entries/${id}`, 'DELETE', undefined, {
|
||||||
|
idempotent: false
|
||||||
|
});
|
||||||
|
deleted += 1;
|
||||||
|
} catch (e) {
|
||||||
|
notifyApiError(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (deleted > 0) notify.success(`Удалено AS-записей: ${deleted}`);
|
||||||
|
await onChanged();
|
||||||
|
} finally {
|
||||||
|
deletingBulk = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readErrorText(res: Response): Promise<string> {
|
||||||
|
const body = (await res.text()).trim();
|
||||||
|
return body || `HTTP ${res.status}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exportCsv() {
|
||||||
|
if (!supportsCsvIO(mod.type) || csvExporting) return;
|
||||||
|
csvExporting = true;
|
||||||
|
try {
|
||||||
|
const res = await apiFetch(`/v1/modules/${moduleId}/entries.csv`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: { Accept: 'text/csv' }
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
notify.error(await readErrorText(res));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const blob = await res.blob();
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `${sanitizeFilenamePart(mod.name)}-${mod.type.toLowerCase()}-entries.csv`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
a.remove();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
} catch (e) {
|
||||||
|
notifyApiError(e);
|
||||||
|
} finally {
|
||||||
|
csvExporting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openImportPicker() {
|
||||||
|
if (!supportsCsvIO(mod.type) || csvImporting) return;
|
||||||
|
csvFileInput?.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleImportChange(event: Event) {
|
||||||
|
const input = event.currentTarget as HTMLInputElement | null;
|
||||||
|
const file = input?.files?.[0];
|
||||||
|
if (!file || csvImporting) return;
|
||||||
|
csvImporting = true;
|
||||||
|
try {
|
||||||
|
const fileText = await file.text();
|
||||||
|
const res = await apiFetch(`/v1/modules/${moduleId}/entries.csv`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'text/csv' },
|
||||||
|
body: fileText
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
notify.error(await readErrorText(res));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const payload = (await res.json()) as { imported?: number };
|
||||||
|
notify.success(`Импортировано записей: ${payload.imported ?? 0}`);
|
||||||
|
await onChanged();
|
||||||
|
} catch (e) {
|
||||||
|
notifyApiError(e);
|
||||||
|
} finally {
|
||||||
|
csvImporting = false;
|
||||||
|
if (input) input.value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<input
|
||||||
|
class="hidden"
|
||||||
|
type="file"
|
||||||
|
accept=".csv,text/csv"
|
||||||
|
bind:this={csvFileInput}
|
||||||
|
onchange={handleImportChange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader class="flex flex-col gap-3 pb-2 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<CardTitle class="text-base">AS-записи</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Номер AS и community; имя, число префиксов и дата обновляются при успешном refresh
|
||||||
|
(RIPEstat).
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<div class="flex shrink-0 flex-wrap items-center justify-end gap-2 self-start sm:self-auto">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onclick={openImportPicker}
|
||||||
|
disabled={!supportsCsvIO(mod.type) || csvImporting || csvExporting}
|
||||||
|
>
|
||||||
|
<Upload />
|
||||||
|
{csvImporting ? 'Импорт…' : 'Импорт CSV'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onclick={exportCsv}
|
||||||
|
disabled={!supportsCsvIO(mod.type) || csvExporting || csvImporting}
|
||||||
|
>
|
||||||
|
<Download />
|
||||||
|
{csvExporting ? 'Экспорт…' : 'Экспорт CSV'}
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" onclick={openCreate}><Plus />Добавить</Button>
|
||||||
|
{#if selectedCount > 0}
|
||||||
|
<Button variant="destructive" size="sm" onclick={requestBulkDelete} disabled={deletingBulk}>
|
||||||
|
<Trash2 />
|
||||||
|
Удалить ({selectedCount})
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="p-4 pt-0">
|
||||||
|
<AppDataTable
|
||||||
|
columns={[...columns]}
|
||||||
|
rows={entries}
|
||||||
|
rowKey={(e) => e.id}
|
||||||
|
{loading}
|
||||||
|
emptyTitle="Нет AS-записей"
|
||||||
|
emptyDescription="Добавьте ASN или импортируйте CSV."
|
||||||
|
>
|
||||||
|
{#snippet toolbar()}
|
||||||
|
{#if entries.length > 0}
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
checked={allSelected}
|
||||||
|
onCheckedChange={(v) => toggleAll(v === true)}
|
||||||
|
aria-label="Выбрать все AS-записи"
|
||||||
|
/>
|
||||||
|
<span class="text-sm text-muted-foreground">Выбрать все</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
{#snippet cell({ row: entry, column })}
|
||||||
|
{#if column.id === 'select'}
|
||||||
|
<Checkbox
|
||||||
|
checked={selectedIds.has(entry.id)}
|
||||||
|
aria-label={`Выбрать AS ${entry.asn}`}
|
||||||
|
onCheckedChange={() => toggleSelection(entry.id)}
|
||||||
|
/>
|
||||||
|
{:else if column.id === 'asn'}
|
||||||
|
<span class="font-mono">{entry.asn}</span>
|
||||||
|
{:else if column.id === 'name'}
|
||||||
|
<span
|
||||||
|
class="max-w-[14rem] truncate text-sm text-muted-foreground"
|
||||||
|
title={entry.asn_name ?? ''}
|
||||||
|
>
|
||||||
|
{entry.asn_name?.trim() ? entry.asn_name : '—'}
|
||||||
|
</span>
|
||||||
|
{:else if column.id === 'prefixes'}
|
||||||
|
<span class="font-mono text-sm">
|
||||||
|
{entry.prefix_count != null ? entry.prefix_count : '—'}
|
||||||
|
</span>
|
||||||
|
{:else if column.id === 'updated'}
|
||||||
|
<span class="text-sm whitespace-nowrap text-muted-foreground">
|
||||||
|
{formatDateTime(entry.asn_resolved_at)}
|
||||||
|
</span>
|
||||||
|
{:else if column.id === 'community'}
|
||||||
|
<span class="text-sm text-muted-foreground">
|
||||||
|
{communityLabel(entry.community_id, communities)}
|
||||||
|
</span>
|
||||||
|
{:else if column.id === 'actions'}
|
||||||
|
<div class="flex gap-1">
|
||||||
|
<Button variant="ghost" size="icon-sm" onclick={() => openEdit(entry)}>
|
||||||
|
<Pencil class="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
class="text-destructive"
|
||||||
|
onclick={() => requestDelete(entry)}
|
||||||
|
>
|
||||||
|
<Trash2 class="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
</AppDataTable>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<ModuleAsEntryDialog
|
||||||
|
bind:open={dialogOpen}
|
||||||
|
{moduleId}
|
||||||
|
edit={editTarget}
|
||||||
|
{communities}
|
||||||
|
onSaved={onChanged}
|
||||||
|
onClose={() => {
|
||||||
|
editTarget = null;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { apiMutate } from '$lib/api/client.js';
|
||||||
|
import type { AsEntry, AsEntryCreate, AsEntryPatch, BgpCommunity } from '$lib/api/types.js';
|
||||||
|
import {
|
||||||
|
communityLabel,
|
||||||
|
communityOptionLabel,
|
||||||
|
fromNullableSelect,
|
||||||
|
NONE_OPTION,
|
||||||
|
nullableSelectValue
|
||||||
|
} from '$lib/components/modules/module-helpers.js';
|
||||||
|
import { Button } from '$lib/ui/core/button/index.js';
|
||||||
|
import { Input } from '$lib/ui/core/input/index.js';
|
||||||
|
import { Label } from '$lib/ui/core/label/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 { notify, notifyApiError } from '$lib/ui/app/toast.js';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
open: boolean;
|
||||||
|
moduleId: string;
|
||||||
|
edit: AsEntry | null;
|
||||||
|
communities: BgpCommunity[];
|
||||||
|
onSaved: () => void | Promise<void>;
|
||||||
|
onClose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
let { open = $bindable(), moduleId, edit, communities, onSaved, onClose }: Props = $props();
|
||||||
|
|
||||||
|
let saving = $state(false);
|
||||||
|
let form = $state<AsEntryCreate>({ asn: 0, community_id: null });
|
||||||
|
let initKey = $state('');
|
||||||
|
|
||||||
|
function resetForm() {
|
||||||
|
form = edit
|
||||||
|
? { asn: edit.asn, community_id: edit.community_id }
|
||||||
|
: { asn: 0, community_id: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!open) {
|
||||||
|
initKey = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nextKey = edit?.id ?? 'new';
|
||||||
|
if (nextKey !== initKey) {
|
||||||
|
initKey = nextKey;
|
||||||
|
resetForm();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
const asn = Number(form.asn);
|
||||||
|
if (!Number.isFinite(asn) || asn < 1 || asn > 4294967295) {
|
||||||
|
notify.error('Укажите корректный ASN (1–4294967295)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
saving = true;
|
||||||
|
try {
|
||||||
|
const body: AsEntryCreate | AsEntryPatch = { asn, community_id: form.community_id };
|
||||||
|
if (edit) {
|
||||||
|
await apiMutate(`/v1/modules/${moduleId}/as-entries/${edit.id}`, 'PATCH', body);
|
||||||
|
notify.success('Запись обновлена');
|
||||||
|
} else {
|
||||||
|
await apiMutate(`/v1/modules/${moduleId}/as-entries`, 'POST', body as AsEntryCreate);
|
||||||
|
notify.success('Запись добавлена');
|
||||||
|
}
|
||||||
|
open = false;
|
||||||
|
await onSaved();
|
||||||
|
} catch (e) {
|
||||||
|
notifyApiError(e);
|
||||||
|
} finally {
|
||||||
|
saving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleOpenChange(next: boolean) {
|
||||||
|
open = next;
|
||||||
|
if (!next) onClose();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Dialog bind:open onOpenChange={handleOpenChange}>
|
||||||
|
<DialogContent class="sm:max-w-sm">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{edit ? 'Редактировать запись' : 'Новая AS-запись'}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Номер автономной системы и community для политики анонса.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div class="space-y-4 py-2">
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<Label for="as-asn">ASN</Label>
|
||||||
|
<Input
|
||||||
|
id="as-asn"
|
||||||
|
type="number"
|
||||||
|
placeholder="12345"
|
||||||
|
bind:value={form.asn}
|
||||||
|
min={1}
|
||||||
|
max={4294967295}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<Label for="as-comm">Community</Label>
|
||||||
|
<Select
|
||||||
|
type="single"
|
||||||
|
value={nullableSelectValue(form.community_id)}
|
||||||
|
onValueChange={(v) => {
|
||||||
|
form.community_id = fromNullableSelect(v);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="as-comm" class="w-full">
|
||||||
|
{form.community_id ? communityLabel(form.community_id, communities) : 'Не выбрано'}
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value={NONE_OPTION}>Не выбрано</SelectItem>
|
||||||
|
{#each communities as c (c.id)}
|
||||||
|
<SelectItem value={c.id}>{communityOptionLabel(c)}</SelectItem>
|
||||||
|
{/each}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onclick={() => handleOpenChange(false)}>Отмена</Button>
|
||||||
|
<Button onclick={save} disabled={saving}>
|
||||||
|
{saving ? 'Сохранение…' : edit ? 'Сохранить' : 'Добавить'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
@@ -0,0 +1,270 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { apiMutate } from '$lib/api/client.js';
|
||||||
|
import type {
|
||||||
|
BgpCommunity,
|
||||||
|
CdnPreviewResponse,
|
||||||
|
CdnSource,
|
||||||
|
CdnSourceCreate
|
||||||
|
} from '$lib/api/types.js';
|
||||||
|
import {
|
||||||
|
communityLabel,
|
||||||
|
communityOptionLabel,
|
||||||
|
fromNullableSelect,
|
||||||
|
normalizeCdnSourceKind,
|
||||||
|
NONE_OPTION,
|
||||||
|
nullableSelectValue
|
||||||
|
} from '$lib/components/modules/module-helpers.js';
|
||||||
|
import { Button } from '$lib/ui/core/button/index.js';
|
||||||
|
import { Input } from '$lib/ui/core/input/index.js';
|
||||||
|
import { Label } from '$lib/ui/core/label/index.js';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter
|
||||||
|
} from '$lib/ui/core/dialog/index.js';
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger } from '$lib/ui/core/select/index.js';
|
||||||
|
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
open: boolean;
|
||||||
|
moduleId: string;
|
||||||
|
edit: CdnSource | null;
|
||||||
|
communities: BgpCommunity[];
|
||||||
|
onSaved: () => void | Promise<void>;
|
||||||
|
onClose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
let { open = $bindable(), moduleId, edit, communities, onSaved, onClose }: Props = $props();
|
||||||
|
|
||||||
|
let saving = $state(false);
|
||||||
|
let previewLoading = $state(false);
|
||||||
|
let previewItems = $state<string[]>([]);
|
||||||
|
let previewTotal = $state(0);
|
||||||
|
let previewTruncated = $state(false);
|
||||||
|
let previewError = $state<string | null>(null);
|
||||||
|
let previewOk = $state(false);
|
||||||
|
let form = $state<CdnSourceCreate & { refresh_interval_sec?: number | null }>({
|
||||||
|
url: '',
|
||||||
|
source_kind: 'plaintext',
|
||||||
|
prefix_path: '',
|
||||||
|
community_id: null
|
||||||
|
});
|
||||||
|
let initKey = $state('');
|
||||||
|
|
||||||
|
function clearPreview() {
|
||||||
|
previewLoading = false;
|
||||||
|
previewItems = [];
|
||||||
|
previewTotal = 0;
|
||||||
|
previewTruncated = false;
|
||||||
|
previewError = null;
|
||||||
|
previewOk = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetForm() {
|
||||||
|
clearPreview();
|
||||||
|
form = edit
|
||||||
|
? {
|
||||||
|
url: edit.url,
|
||||||
|
source_kind: normalizeCdnSourceKind(edit.source_kind),
|
||||||
|
prefix_path: edit.prefix_path ?? '',
|
||||||
|
community_id: edit.community_id,
|
||||||
|
refresh_interval_sec: edit.refresh_interval_sec
|
||||||
|
}
|
||||||
|
: { url: '', source_kind: 'plaintext', prefix_path: '', community_id: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!open) {
|
||||||
|
initKey = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nextKey = edit?.id ?? 'new';
|
||||||
|
if (nextKey !== initKey) {
|
||||||
|
initKey = nextKey;
|
||||||
|
resetForm();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function previewCdn() {
|
||||||
|
const urlTrim = form.url.trim();
|
||||||
|
if (!urlTrim) {
|
||||||
|
notify.error('Укажите URL');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
previewLoading = true;
|
||||||
|
previewError = null;
|
||||||
|
previewOk = false;
|
||||||
|
try {
|
||||||
|
const res = await apiMutate<CdnPreviewResponse>(
|
||||||
|
`/v1/modules/${moduleId}/cdn-sources/preview`,
|
||||||
|
'POST',
|
||||||
|
{
|
||||||
|
url: urlTrim,
|
||||||
|
source_kind: form.source_kind,
|
||||||
|
prefix_path: form.prefix_path?.trim() ?? ''
|
||||||
|
}
|
||||||
|
);
|
||||||
|
previewItems = res.items;
|
||||||
|
previewTotal = res.total;
|
||||||
|
previewTruncated = res.truncated;
|
||||||
|
previewOk = true;
|
||||||
|
} catch (e) {
|
||||||
|
previewError = e instanceof Error ? e.message : String(e);
|
||||||
|
previewItems = [];
|
||||||
|
previewTotal = 0;
|
||||||
|
previewTruncated = false;
|
||||||
|
previewOk = false;
|
||||||
|
} finally {
|
||||||
|
previewLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
const urlTrim = form.url.trim();
|
||||||
|
if (!urlTrim) {
|
||||||
|
notify.error('Укажите URL');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
saving = true;
|
||||||
|
try {
|
||||||
|
const body = {
|
||||||
|
...form,
|
||||||
|
url: urlTrim,
|
||||||
|
source_kind: form.source_kind,
|
||||||
|
prefix_path: form.prefix_path?.trim() ?? ''
|
||||||
|
};
|
||||||
|
if (edit) {
|
||||||
|
await apiMutate(`/v1/modules/${moduleId}/cdn-sources/${edit.id}`, 'PATCH', body);
|
||||||
|
notify.success('Источник обновлён');
|
||||||
|
} else {
|
||||||
|
await apiMutate(`/v1/modules/${moduleId}/cdn-sources`, 'POST', body);
|
||||||
|
notify.success('Источник добавлен');
|
||||||
|
}
|
||||||
|
clearPreview();
|
||||||
|
open = false;
|
||||||
|
await onSaved();
|
||||||
|
} catch (e) {
|
||||||
|
notifyApiError(e);
|
||||||
|
} finally {
|
||||||
|
saving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleOpenChange(next: boolean) {
|
||||||
|
open = next;
|
||||||
|
if (!next) {
|
||||||
|
clearPreview();
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Dialog bind:open onOpenChange={handleOpenChange}>
|
||||||
|
<DialogContent class="sm:max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{edit ? 'Редактировать источник' : 'Новый CDN-источник'}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div class="space-y-4 py-2">
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<Label for="cdn-url">URL</Label>
|
||||||
|
<Input id="cdn-url" placeholder="https://example.com/list.txt" bind:value={form.url} />
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<Label for="cdn-kind">Тип источника</Label>
|
||||||
|
<Select
|
||||||
|
type="single"
|
||||||
|
value={form.source_kind}
|
||||||
|
onValueChange={(v) => {
|
||||||
|
form.source_kind = v || 'plaintext';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="cdn-kind" class="w-full">
|
||||||
|
{form.source_kind === 'json' ? 'json' : 'plaintext'}
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="plaintext">plaintext</SelectItem>
|
||||||
|
<SelectItem value="json">json</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<Label for="cdn-prefix-path">JSON path (prefix_path)</Label>
|
||||||
|
<Input
|
||||||
|
id="cdn-prefix-path"
|
||||||
|
placeholder="напр. prefixes[] или data.items[].cidr"
|
||||||
|
bind:value={form.prefix_path}
|
||||||
|
/>
|
||||||
|
{#if form.source_kind === 'json' && !form.prefix_path?.trim()}
|
||||||
|
<p class="text-xs text-muted-foreground">
|
||||||
|
Для JSON укажите путь к полям с CIDR; пустой путь может не дать префиксов.
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<Label for="cdn-comm">Community</Label>
|
||||||
|
<Select
|
||||||
|
type="single"
|
||||||
|
value={nullableSelectValue(form.community_id)}
|
||||||
|
onValueChange={(v) => {
|
||||||
|
form.community_id = fromNullableSelect(v);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="cdn-comm" class="w-full">
|
||||||
|
{form.community_id ? communityLabel(form.community_id, communities) : 'Не выбрано'}
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value={NONE_OPTION}>Не выбрано</SelectItem>
|
||||||
|
{#each communities as c (c.id)}
|
||||||
|
<SelectItem value={c.id}>{communityOptionLabel(c)}</SelectItem>
|
||||||
|
{/each}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<Label for="cdn-interval">Интервал обновления (сек)</Label>
|
||||||
|
<Input
|
||||||
|
id="cdn-interval"
|
||||||
|
type="number"
|
||||||
|
placeholder="3600"
|
||||||
|
bind:value={form.refresh_interval_sec}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-2 rounded-lg border border-border p-3">
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onclick={previewCdn}
|
||||||
|
disabled={previewLoading}
|
||||||
|
>
|
||||||
|
{previewLoading ? 'Загрузка…' : 'Предпросмотр'}
|
||||||
|
</Button>
|
||||||
|
{#if previewError}
|
||||||
|
<span class="text-sm text-destructive">{previewError}</span>
|
||||||
|
{:else if previewOk}
|
||||||
|
<span class="text-sm text-muted-foreground">
|
||||||
|
Всего: {previewTotal}{#if previewTruncated}
|
||||||
|
<span class="text-amber-600 dark:text-amber-500"> (обрезано)</span>{/if}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if previewItems.length}
|
||||||
|
<ul class="max-h-48 overflow-y-auto rounded-md border bg-muted/40 p-2 font-mono text-xs">
|
||||||
|
{#each previewItems as item, i (`${i}-${item}`)}
|
||||||
|
<li class="py-0.5">{item}</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onclick={() => handleOpenChange(false)}>Отмена</Button>
|
||||||
|
<Button onclick={save} disabled={saving}>
|
||||||
|
{saving ? 'Сохранение…' : edit ? 'Сохранить' : 'Добавить'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { apiMutate } from '$lib/api/client.js';
|
||||||
|
import type { BgpCommunity, CdnSource } from '$lib/api/types.js';
|
||||||
|
import { formatDateTime } from '$lib/modules/display.js';
|
||||||
|
import {
|
||||||
|
communityLabel,
|
||||||
|
normalizeCdnSourceKind
|
||||||
|
} from '$lib/components/modules/module-helpers.js';
|
||||||
|
import { Badge } from '$lib/ui/core/badge/index.js';
|
||||||
|
import { Button } from '$lib/ui/core/button/index.js';
|
||||||
|
import { Checkbox } from '$lib/ui/core/checkbox/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 { confirm } from '$lib/ui/patterns/confirm/confirm-state.svelte.js';
|
||||||
|
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
|
||||||
|
import ModuleCdnSourceDialog from '$lib/components/modules/ModuleCdnSourceDialog.svelte';
|
||||||
|
import Plus from '@lucide/svelte/icons/plus';
|
||||||
|
import Pencil from '@lucide/svelte/icons/pencil';
|
||||||
|
import Trash2 from '@lucide/svelte/icons/trash-2';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
moduleId: string;
|
||||||
|
sources: CdnSource[];
|
||||||
|
communities: BgpCommunity[];
|
||||||
|
loading?: boolean;
|
||||||
|
onChanged: () => void | Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
let { moduleId, sources, communities, loading = false, onChanged }: Props = $props();
|
||||||
|
|
||||||
|
let dialogOpen = $state(false);
|
||||||
|
let editTarget = $state<CdnSource | null>(null);
|
||||||
|
let selectedIds = $state(new Set<string>());
|
||||||
|
let deletingBulk = $state(false);
|
||||||
|
|
||||||
|
const activeSelected = $derived.by(() => {
|
||||||
|
const allowed = new Set(sources.map((s) => s.id));
|
||||||
|
return [...selectedIds].filter((id) => allowed.has(id));
|
||||||
|
});
|
||||||
|
const selectedCount = $derived(activeSelected.length);
|
||||||
|
const allSelected = $derived(sources.length > 0 && sources.every((s) => selectedIds.has(s.id)));
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ id: 'select', label: '', class: 'w-10' },
|
||||||
|
{ id: 'url', label: 'URL', sortable: true, sortValue: (s: CdnSource) => s.url },
|
||||||
|
{ id: 'kind', label: 'Тип', sortable: true, sortValue: (s: CdnSource) => s.source_kind },
|
||||||
|
{ id: 'community', label: 'Community' },
|
||||||
|
{
|
||||||
|
id: 'interval',
|
||||||
|
label: 'Интервал',
|
||||||
|
sortable: true,
|
||||||
|
sortValue: (s: CdnSource) => s.refresh_interval_sec ?? 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'refreshed',
|
||||||
|
label: 'Последнее обновление',
|
||||||
|
sortable: true,
|
||||||
|
sortValue: (s: CdnSource) => s.last_refreshed_at ?? ''
|
||||||
|
},
|
||||||
|
{ id: 'actions', label: '', class: 'w-20' }
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
function toggleSelection(id: string) {
|
||||||
|
const next = new Set(selectedIds);
|
||||||
|
if (next.has(id)) next.delete(id);
|
||||||
|
else next.add(id);
|
||||||
|
selectedIds = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleAll(checked: boolean) {
|
||||||
|
selectedIds = checked ? new Set(sources.map((s) => s.id)) : new Set<string>();
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreate() {
|
||||||
|
editTarget = null;
|
||||||
|
dialogOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(src: CdnSource) {
|
||||||
|
editTarget = src;
|
||||||
|
dialogOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestDelete(src: CdnSource) {
|
||||||
|
void confirm({
|
||||||
|
title: 'Удалить CDN-источник?',
|
||||||
|
description: src.url,
|
||||||
|
confirmLabel: 'Удалить',
|
||||||
|
destructive: true,
|
||||||
|
onConfirm: async () => {
|
||||||
|
await apiMutate(`/v1/modules/${moduleId}/cdn-sources/${src.id}`, 'DELETE', undefined, {
|
||||||
|
idempotent: false
|
||||||
|
});
|
||||||
|
notify.success('Удалено');
|
||||||
|
await onChanged();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestBulkDelete() {
|
||||||
|
if (selectedCount === 0) return;
|
||||||
|
void confirm({
|
||||||
|
title: 'Удалить выбранные CDN-источники?',
|
||||||
|
description: `Будет удалено: ${selectedCount}`,
|
||||||
|
confirmLabel: 'Удалить',
|
||||||
|
destructive: true,
|
||||||
|
onConfirm: bulkDelete
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bulkDelete() {
|
||||||
|
if (selectedCount === 0) return;
|
||||||
|
deletingBulk = true;
|
||||||
|
let deleted = 0;
|
||||||
|
try {
|
||||||
|
for (const id of activeSelected) {
|
||||||
|
try {
|
||||||
|
await apiMutate(`/v1/modules/${moduleId}/cdn-sources/${id}`, 'DELETE', undefined, {
|
||||||
|
idempotent: false
|
||||||
|
});
|
||||||
|
deleted += 1;
|
||||||
|
} catch (e) {
|
||||||
|
notifyApiError(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (deleted > 0) notify.success(`Удалено CDN-источников: ${deleted}`);
|
||||||
|
await onChanged();
|
||||||
|
} finally {
|
||||||
|
deletingBulk = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader class="flex flex-col gap-3 pb-2 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<CardTitle class="text-base">CDN-источники</CardTitle>
|
||||||
|
<CardDescription>URL источников для скачивания списков CIDR.</CardDescription>
|
||||||
|
</div>
|
||||||
|
<div class="flex shrink-0 flex-wrap items-center justify-end gap-2 self-start sm:self-auto">
|
||||||
|
<Button size="sm" onclick={openCreate}><Plus />Добавить</Button>
|
||||||
|
{#if selectedCount > 0}
|
||||||
|
<Button variant="destructive" size="sm" onclick={requestBulkDelete} disabled={deletingBulk}>
|
||||||
|
<Trash2 />
|
||||||
|
Удалить ({selectedCount})
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="p-4 pt-0">
|
||||||
|
<AppDataTable
|
||||||
|
columns={[...columns]}
|
||||||
|
rows={sources}
|
||||||
|
rowKey={(s) => s.id}
|
||||||
|
{loading}
|
||||||
|
emptyTitle="Нет CDN-источников"
|
||||||
|
emptyDescription="Добавьте URL для загрузки списков CIDR."
|
||||||
|
>
|
||||||
|
{#snippet toolbar()}
|
||||||
|
{#if sources.length > 0}
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
checked={allSelected}
|
||||||
|
onCheckedChange={(v) => toggleAll(v === true)}
|
||||||
|
aria-label="Выбрать все CDN-источники"
|
||||||
|
/>
|
||||||
|
<span class="text-sm text-muted-foreground">Выбрать все</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
{#snippet cell({ row: src, column })}
|
||||||
|
{#if column.id === 'select'}
|
||||||
|
<Checkbox
|
||||||
|
checked={selectedIds.has(src.id)}
|
||||||
|
aria-label="Выбрать CDN-источник"
|
||||||
|
onCheckedChange={() => toggleSelection(src.id)}
|
||||||
|
/>
|
||||||
|
{:else if column.id === 'url'}
|
||||||
|
<span class="max-w-xs truncate font-mono text-xs" title={src.url}>{src.url}</span>
|
||||||
|
{:else if column.id === 'kind'}
|
||||||
|
<div class="flex flex-col gap-0.5">
|
||||||
|
<Badge variant="outline">{normalizeCdnSourceKind(src.source_kind)}</Badge>
|
||||||
|
{#if src.prefix_path?.trim()}
|
||||||
|
<span
|
||||||
|
class="font-mono text-xs break-all text-muted-foreground"
|
||||||
|
title={src.prefix_path}>{src.prefix_path}</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else if column.id === 'community'}
|
||||||
|
<span class="text-sm text-muted-foreground">
|
||||||
|
{communityLabel(src.community_id, communities)}
|
||||||
|
</span>
|
||||||
|
{:else if column.id === 'interval'}
|
||||||
|
<span class="text-sm text-muted-foreground">
|
||||||
|
{src.refresh_interval_sec != null ? `${src.refresh_interval_sec}с` : '—'}
|
||||||
|
</span>
|
||||||
|
{:else if column.id === 'refreshed'}
|
||||||
|
<span class="text-sm whitespace-nowrap text-muted-foreground">
|
||||||
|
{formatDateTime(src.last_refreshed_at)}
|
||||||
|
</span>
|
||||||
|
{:else if column.id === 'actions'}
|
||||||
|
<div class="flex gap-1">
|
||||||
|
<Button variant="ghost" size="icon-sm" onclick={() => openEdit(src)}>
|
||||||
|
<Pencil class="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
class="text-destructive"
|
||||||
|
onclick={() => requestDelete(src)}
|
||||||
|
>
|
||||||
|
<Trash2 class="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
</AppDataTable>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<ModuleCdnSourceDialog
|
||||||
|
bind:open={dialogOpen}
|
||||||
|
{moduleId}
|
||||||
|
edit={editTarget}
|
||||||
|
{communities}
|
||||||
|
onSaved={onChanged}
|
||||||
|
onClose={() => {
|
||||||
|
editTarget = null;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { apiMutate } from '$lib/api/client.js';
|
||||||
|
import type { ModuleCreate } from '$lib/api/types.js';
|
||||||
|
import { moduleTypeRu } from '$lib/ui-labels.js';
|
||||||
|
import { Button } from '$lib/ui/core/button/index.js';
|
||||||
|
import { Input } from '$lib/ui/core/input/index.js';
|
||||||
|
import { Label } from '$lib/ui/core/label/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 { notify, notifyApiError } from '$lib/ui/app/toast.js';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onCreated: () => void | Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
let { open = $bindable(), onClose, onCreated }: Props = $props();
|
||||||
|
|
||||||
|
let saving = $state(false);
|
||||||
|
let form = $state<ModuleCreate>({
|
||||||
|
type: 'AS_PREFIXES',
|
||||||
|
name: '',
|
||||||
|
enabled: true,
|
||||||
|
priority: 0
|
||||||
|
});
|
||||||
|
|
||||||
|
const moduleTypes = [
|
||||||
|
{ value: 'AS_PREFIXES', label: moduleTypeRu('AS_PREFIXES') },
|
||||||
|
{ value: 'CDN_CIDRS', label: moduleTypeRu('CDN_CIDRS') },
|
||||||
|
{ value: 'DOMAINS', label: moduleTypeRu('DOMAINS') },
|
||||||
|
{ value: 'IP_RANGES', label: moduleTypeRu('IP_RANGES') }
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
function resetForm() {
|
||||||
|
form = { type: 'AS_PREFIXES', name: '', enabled: true, priority: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function create() {
|
||||||
|
if (!form.name.trim()) {
|
||||||
|
notify.error('Укажите название модуля');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
saving = true;
|
||||||
|
try {
|
||||||
|
await apiMutate('/v1/modules', 'POST', form);
|
||||||
|
notify.success('Модуль создан');
|
||||||
|
open = false;
|
||||||
|
resetForm();
|
||||||
|
await onCreated();
|
||||||
|
} catch (e) {
|
||||||
|
notifyApiError(e);
|
||||||
|
} finally {
|
||||||
|
saving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleOpenChange(next: boolean) {
|
||||||
|
open = next;
|
||||||
|
if (!next) {
|
||||||
|
onClose();
|
||||||
|
resetForm();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Dialog bind:open onOpenChange={handleOpenChange}>
|
||||||
|
<DialogContent class="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Новый модуль</DialogTitle>
|
||||||
|
<DialogDescription>Создание нового модуля префиксов.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div class="space-y-4 py-2">
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<Label for="m-name">Название</Label>
|
||||||
|
<Input id="m-name" bind:value={form.name} placeholder="my-asn-module" />
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<Label for="m-type">Тип</Label>
|
||||||
|
<Select
|
||||||
|
type="single"
|
||||||
|
value={form.type}
|
||||||
|
onValueChange={(v) => {
|
||||||
|
if (v) form.type = v as typeof form.type;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="m-type" class="w-full">
|
||||||
|
{moduleTypes.find((t) => t.value === form.type)?.label ?? 'Выберите тип'}
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{#each moduleTypes as t (t.value)}
|
||||||
|
<SelectItem value={t.value}>{t.label}</SelectItem>
|
||||||
|
{/each}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<Label for="m-priority">Приоритет</Label>
|
||||||
|
<Input id="m-priority" type="number" bind:value={form.priority} />
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<Label for="m-interval">Интервал (сек)</Label>
|
||||||
|
<Input
|
||||||
|
id="m-interval"
|
||||||
|
type="number"
|
||||||
|
placeholder="3600"
|
||||||
|
bind:value={form.refresh_interval_sec}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</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="m-enabled" class="leading-snug text-foreground">Включён</Label>
|
||||||
|
<p class="text-xs leading-snug text-muted-foreground">
|
||||||
|
Модуль участвует в сборке ревизий, если включён.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
id="m-enabled"
|
||||||
|
class="shrink-0"
|
||||||
|
checked={form.enabled !== false}
|
||||||
|
onCheckedChange={(v) => {
|
||||||
|
form = { ...form, enabled: v };
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onclick={() => handleOpenChange(false)}>Отмена</Button>
|
||||||
|
<Button onclick={create} disabled={saving}>{saving ? 'Создание…' : 'Создать'}</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { resolve } from '$app/paths';
|
||||||
|
import type { ModuleRow } from '$lib/api/types.js';
|
||||||
|
import { moduleEnabledRu, moduleEnabledBadgeVariant, moduleTypeRu } from '$lib/ui-labels.js';
|
||||||
|
import { Badge } from '$lib/ui/core/badge/index.js';
|
||||||
|
import { Button } from '$lib/ui/core/button/index.js';
|
||||||
|
import PageHeader from '$lib/ui/app/page-header/page-header.svelte';
|
||||||
|
import ArrowLeft from '@lucide/svelte/icons/arrow-left';
|
||||||
|
import Pencil from '@lucide/svelte/icons/pencil';
|
||||||
|
import Trash2 from '@lucide/svelte/icons/trash-2';
|
||||||
|
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
|
||||||
|
import Blocks from '@lucide/svelte/icons/blocks';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
mod: ModuleRow;
|
||||||
|
refreshing: boolean;
|
||||||
|
onRefresh: () => void;
|
||||||
|
onEdit: () => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
let { mod, refreshing, onRefresh, onEdit, onDelete }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex min-w-0 items-start gap-2">
|
||||||
|
<Button variant="ghost" size="icon-sm" class="mt-1 shrink-0" href={resolve('/modules')}>
|
||||||
|
<ArrowLeft class="size-4" />
|
||||||
|
</Button>
|
||||||
|
<PageHeader
|
||||||
|
class="min-w-0 flex-1"
|
||||||
|
title={mod.name}
|
||||||
|
description={mod.id}
|
||||||
|
icon={Blocks}
|
||||||
|
iconClass="bg-chart-2/15 text-chart-2"
|
||||||
|
>
|
||||||
|
{#snippet actions()}
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<Badge variant="outline">{moduleTypeRu(mod.type)}</Badge>
|
||||||
|
<Badge variant={moduleEnabledBadgeVariant(!!mod.enabled)} class="text-xs">
|
||||||
|
{moduleEnabledRu(!!mod.enabled)}
|
||||||
|
</Badge>
|
||||||
|
<Button variant="outline" size="sm" onclick={() => onRefresh()} disabled={refreshing}>
|
||||||
|
<RefreshCw class={refreshing ? 'animate-spin' : ''} />
|
||||||
|
Обновить
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" onclick={() => onEdit()}>
|
||||||
|
<Pencil />
|
||||||
|
Редактировать
|
||||||
|
</Button>
|
||||||
|
<Button variant="destructive" size="sm" onclick={() => onDelete()}>
|
||||||
|
<Trash2 />
|
||||||
|
Удалить
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
</PageHeader>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,302 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { apiFetch, apiMutate } from '$lib/api/client.js';
|
||||||
|
import type { BgpCommunity, DomainEntry, ModuleRow } from '$lib/api/types.js';
|
||||||
|
import {
|
||||||
|
communityLabel,
|
||||||
|
sanitizeFilenamePart,
|
||||||
|
supportsCsvIO
|
||||||
|
} from '$lib/components/modules/module-helpers.js';
|
||||||
|
import { Button } from '$lib/ui/core/button/index.js';
|
||||||
|
import { Checkbox } from '$lib/ui/core/checkbox/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 { confirm } from '$lib/ui/patterns/confirm/confirm-state.svelte.js';
|
||||||
|
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
|
||||||
|
import ModuleDomainEntryDialog from '$lib/components/modules/ModuleDomainEntryDialog.svelte';
|
||||||
|
import Plus from '@lucide/svelte/icons/plus';
|
||||||
|
import Pencil from '@lucide/svelte/icons/pencil';
|
||||||
|
import Trash2 from '@lucide/svelte/icons/trash-2';
|
||||||
|
import Upload from '@lucide/svelte/icons/upload';
|
||||||
|
import Download from '@lucide/svelte/icons/download';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
moduleId: string;
|
||||||
|
mod: ModuleRow;
|
||||||
|
entries: DomainEntry[];
|
||||||
|
communities: BgpCommunity[];
|
||||||
|
loading?: boolean;
|
||||||
|
onChanged: () => void | Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
let { moduleId, mod, entries, communities, loading = false, onChanged }: Props = $props();
|
||||||
|
|
||||||
|
let dialogOpen = $state(false);
|
||||||
|
let editTarget = $state<DomainEntry | null>(null);
|
||||||
|
let selectedIds = $state(new Set<string>());
|
||||||
|
let deletingBulk = $state(false);
|
||||||
|
let csvImporting = $state(false);
|
||||||
|
let csvExporting = $state(false);
|
||||||
|
let csvFileInput = $state<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
|
const activeSelected = $derived.by(() => {
|
||||||
|
const allowed = new Set(entries.map((e) => e.id));
|
||||||
|
return [...selectedIds].filter((id) => allowed.has(id));
|
||||||
|
});
|
||||||
|
const selectedCount = $derived(activeSelected.length);
|
||||||
|
const allSelected = $derived(entries.length > 0 && entries.every((e) => selectedIds.has(e.id)));
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ id: 'select', label: '', class: 'w-10' },
|
||||||
|
{ id: 'fqdn', label: 'FQDN', sortable: true, sortValue: (e: DomainEntry) => e.fqdn },
|
||||||
|
{ id: 'community', label: 'Community' },
|
||||||
|
{ id: 'actions', label: '', class: 'w-20' }
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
function toggleSelection(id: string) {
|
||||||
|
const next = new Set(selectedIds);
|
||||||
|
if (next.has(id)) next.delete(id);
|
||||||
|
else next.add(id);
|
||||||
|
selectedIds = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleAll(checked: boolean) {
|
||||||
|
selectedIds = checked ? new Set(entries.map((e) => e.id)) : new Set<string>();
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreate() {
|
||||||
|
editTarget = null;
|
||||||
|
dialogOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(entry: DomainEntry) {
|
||||||
|
editTarget = entry;
|
||||||
|
dialogOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestDelete(entry: DomainEntry) {
|
||||||
|
void confirm({
|
||||||
|
title: 'Удалить домен?',
|
||||||
|
description: entry.fqdn,
|
||||||
|
confirmLabel: 'Удалить',
|
||||||
|
destructive: true,
|
||||||
|
onConfirm: async () => {
|
||||||
|
await apiMutate(`/v1/modules/${moduleId}/domain-entries/${entry.id}`, 'DELETE', undefined, {
|
||||||
|
idempotent: false
|
||||||
|
});
|
||||||
|
notify.success('Удалено');
|
||||||
|
await onChanged();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestBulkDelete() {
|
||||||
|
if (selectedCount === 0) return;
|
||||||
|
void confirm({
|
||||||
|
title: 'Удалить выбранные домены?',
|
||||||
|
description: `Будет удалено: ${selectedCount}`,
|
||||||
|
confirmLabel: 'Удалить',
|
||||||
|
destructive: true,
|
||||||
|
onConfirm: bulkDelete
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bulkDelete() {
|
||||||
|
if (selectedCount === 0) return;
|
||||||
|
deletingBulk = true;
|
||||||
|
let deleted = 0;
|
||||||
|
try {
|
||||||
|
for (const id of activeSelected) {
|
||||||
|
try {
|
||||||
|
await apiMutate(`/v1/modules/${moduleId}/domain-entries/${id}`, 'DELETE', undefined, {
|
||||||
|
idempotent: false
|
||||||
|
});
|
||||||
|
deleted += 1;
|
||||||
|
} catch (e) {
|
||||||
|
notifyApiError(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (deleted > 0) notify.success(`Удалено доменов: ${deleted}`);
|
||||||
|
await onChanged();
|
||||||
|
} finally {
|
||||||
|
deletingBulk = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readErrorText(res: Response): Promise<string> {
|
||||||
|
const body = (await res.text()).trim();
|
||||||
|
return body || `HTTP ${res.status}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exportCsv() {
|
||||||
|
if (!supportsCsvIO(mod.type) || csvExporting) return;
|
||||||
|
csvExporting = true;
|
||||||
|
try {
|
||||||
|
const res = await apiFetch(`/v1/modules/${moduleId}/entries.csv`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: { Accept: 'text/csv' }
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
notify.error(await readErrorText(res));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const blob = await res.blob();
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `${sanitizeFilenamePart(mod.name)}-${mod.type.toLowerCase()}-entries.csv`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
a.remove();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
} catch (e) {
|
||||||
|
notifyApiError(e);
|
||||||
|
} finally {
|
||||||
|
csvExporting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openImportPicker() {
|
||||||
|
if (!supportsCsvIO(mod.type) || csvImporting) return;
|
||||||
|
csvFileInput?.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleImportChange(event: Event) {
|
||||||
|
const input = event.currentTarget as HTMLInputElement | null;
|
||||||
|
const file = input?.files?.[0];
|
||||||
|
if (!file || csvImporting) return;
|
||||||
|
csvImporting = true;
|
||||||
|
try {
|
||||||
|
const fileText = await file.text();
|
||||||
|
const res = await apiFetch(`/v1/modules/${moduleId}/entries.csv`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'text/csv' },
|
||||||
|
body: fileText
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
notify.error(await readErrorText(res));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const payload = (await res.json()) as { imported?: number };
|
||||||
|
notify.success(`Импортировано записей: ${payload.imported ?? 0}`);
|
||||||
|
await onChanged();
|
||||||
|
} catch (e) {
|
||||||
|
notifyApiError(e);
|
||||||
|
} finally {
|
||||||
|
csvImporting = false;
|
||||||
|
if (input) input.value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<input
|
||||||
|
class="hidden"
|
||||||
|
type="file"
|
||||||
|
accept=".csv,text/csv"
|
||||||
|
bind:this={csvFileInput}
|
||||||
|
onchange={handleImportChange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader class="flex flex-col gap-3 pb-2 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<CardTitle class="text-base">Домены</CardTitle>
|
||||||
|
<CardDescription>FQDN для резолвинга через DoH.</CardDescription>
|
||||||
|
</div>
|
||||||
|
<div class="flex shrink-0 flex-wrap items-center justify-end gap-2 self-start sm:self-auto">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onclick={openImportPicker}
|
||||||
|
disabled={!supportsCsvIO(mod.type) || csvImporting || csvExporting}
|
||||||
|
>
|
||||||
|
<Upload />
|
||||||
|
{csvImporting ? 'Импорт…' : 'Импорт CSV'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onclick={exportCsv}
|
||||||
|
disabled={!supportsCsvIO(mod.type) || csvExporting || csvImporting}
|
||||||
|
>
|
||||||
|
<Download />
|
||||||
|
{csvExporting ? 'Экспорт…' : 'Экспорт CSV'}
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" onclick={openCreate}><Plus />Добавить</Button>
|
||||||
|
{#if selectedCount > 0}
|
||||||
|
<Button variant="destructive" size="sm" onclick={requestBulkDelete} disabled={deletingBulk}>
|
||||||
|
<Trash2 />
|
||||||
|
Удалить ({selectedCount})
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="p-4 pt-0">
|
||||||
|
<AppDataTable
|
||||||
|
columns={[...columns]}
|
||||||
|
rows={entries}
|
||||||
|
rowKey={(e) => e.id}
|
||||||
|
{loading}
|
||||||
|
emptyTitle="Нет доменов"
|
||||||
|
emptyDescription="Добавьте FQDN или импортируйте CSV."
|
||||||
|
>
|
||||||
|
{#snippet toolbar()}
|
||||||
|
{#if entries.length > 0}
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
checked={allSelected}
|
||||||
|
onCheckedChange={(v) => toggleAll(v === true)}
|
||||||
|
aria-label="Выбрать все домены"
|
||||||
|
/>
|
||||||
|
<span class="text-sm text-muted-foreground">Выбрать все</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
{#snippet cell({ row: entry, column })}
|
||||||
|
{#if column.id === 'select'}
|
||||||
|
<Checkbox
|
||||||
|
checked={selectedIds.has(entry.id)}
|
||||||
|
aria-label={`Выбрать домен ${entry.fqdn}`}
|
||||||
|
onCheckedChange={() => toggleSelection(entry.id)}
|
||||||
|
/>
|
||||||
|
{:else if column.id === 'fqdn'}
|
||||||
|
<span class="font-mono">{entry.fqdn}</span>
|
||||||
|
{:else if column.id === 'community'}
|
||||||
|
<span class="text-sm text-muted-foreground">
|
||||||
|
{communityLabel(entry.community_id, communities)}
|
||||||
|
</span>
|
||||||
|
{:else if column.id === 'actions'}
|
||||||
|
<div class="flex gap-1">
|
||||||
|
<Button variant="ghost" size="icon-sm" onclick={() => openEdit(entry)}>
|
||||||
|
<Pencil class="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
class="text-destructive"
|
||||||
|
onclick={() => requestDelete(entry)}
|
||||||
|
>
|
||||||
|
<Trash2 class="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
</AppDataTable>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<ModuleDomainEntryDialog
|
||||||
|
bind:open={dialogOpen}
|
||||||
|
{moduleId}
|
||||||
|
edit={editTarget}
|
||||||
|
{communities}
|
||||||
|
onSaved={onChanged}
|
||||||
|
onClose={() => {
|
||||||
|
editTarget = null;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { apiMutate } from '$lib/api/client.js';
|
||||||
|
import type { BgpCommunity, DomainEntry, DomainEntryCreate } from '$lib/api/types.js';
|
||||||
|
import {
|
||||||
|
communityLabel,
|
||||||
|
communityOptionLabel,
|
||||||
|
fromNullableSelect,
|
||||||
|
NONE_OPTION,
|
||||||
|
nullableSelectValue
|
||||||
|
} from '$lib/components/modules/module-helpers.js';
|
||||||
|
import { Button } from '$lib/ui/core/button/index.js';
|
||||||
|
import { Input } from '$lib/ui/core/input/index.js';
|
||||||
|
import { Label } from '$lib/ui/core/label/index.js';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter
|
||||||
|
} from '$lib/ui/core/dialog/index.js';
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger } from '$lib/ui/core/select/index.js';
|
||||||
|
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
open: boolean;
|
||||||
|
moduleId: string;
|
||||||
|
edit: DomainEntry | null;
|
||||||
|
communities: BgpCommunity[];
|
||||||
|
onSaved: () => void | Promise<void>;
|
||||||
|
onClose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
let { open = $bindable(), moduleId, edit, communities, onSaved, onClose }: Props = $props();
|
||||||
|
|
||||||
|
let saving = $state(false);
|
||||||
|
let form = $state<DomainEntryCreate>({ fqdn: '', community_id: null });
|
||||||
|
let initKey = $state('');
|
||||||
|
|
||||||
|
function resetForm() {
|
||||||
|
form = edit
|
||||||
|
? { fqdn: edit.fqdn, community_id: edit.community_id }
|
||||||
|
: { fqdn: '', community_id: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!open) {
|
||||||
|
initKey = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nextKey = edit?.id ?? 'new';
|
||||||
|
if (nextKey !== initKey) {
|
||||||
|
initKey = nextKey;
|
||||||
|
resetForm();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
saving = true;
|
||||||
|
try {
|
||||||
|
if (edit) {
|
||||||
|
await apiMutate(`/v1/modules/${moduleId}/domain-entries/${edit.id}`, 'PATCH', form);
|
||||||
|
notify.success('Домен обновлён');
|
||||||
|
} else {
|
||||||
|
await apiMutate(`/v1/modules/${moduleId}/domain-entries`, 'POST', form);
|
||||||
|
notify.success('Домен добавлен');
|
||||||
|
}
|
||||||
|
open = false;
|
||||||
|
await onSaved();
|
||||||
|
} catch (e) {
|
||||||
|
notifyApiError(e);
|
||||||
|
} finally {
|
||||||
|
saving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleOpenChange(next: boolean) {
|
||||||
|
open = next;
|
||||||
|
if (!next) onClose();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Dialog bind:open onOpenChange={handleOpenChange}>
|
||||||
|
<DialogContent class="sm:max-w-sm">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{edit ? 'Редактировать домен' : 'Новый домен'}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div class="space-y-4 py-2">
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<Label for="dom-fqdn">FQDN</Label>
|
||||||
|
<Input id="dom-fqdn" placeholder="example.com" bind:value={form.fqdn} />
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<Label for="dom-comm">Community</Label>
|
||||||
|
<Select
|
||||||
|
type="single"
|
||||||
|
value={nullableSelectValue(form.community_id)}
|
||||||
|
onValueChange={(v) => {
|
||||||
|
form.community_id = fromNullableSelect(v);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="dom-comm" class="w-full">
|
||||||
|
{form.community_id ? communityLabel(form.community_id, communities) : 'Не выбрано'}
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value={NONE_OPTION}>Не выбрано</SelectItem>
|
||||||
|
{#each communities as c (c.id)}
|
||||||
|
<SelectItem value={c.id}>{communityOptionLabel(c)}</SelectItem>
|
||||||
|
{/each}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onclick={() => handleOpenChange(false)}>Отмена</Button>
|
||||||
|
<Button onclick={save} disabled={saving}>
|
||||||
|
{saving ? 'Сохранение…' : edit ? 'Сохранить' : 'Добавить'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
@@ -0,0 +1,294 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { apiMutate } from '$lib/api/client.js';
|
||||||
|
import type {
|
||||||
|
BgpCommunity,
|
||||||
|
DohProfile,
|
||||||
|
DohResolverPolicy,
|
||||||
|
ModulePatch,
|
||||||
|
ModuleRow
|
||||||
|
} from '$lib/api/types.js';
|
||||||
|
import { dohPolicyRu } from '$lib/ui-labels.js';
|
||||||
|
import {
|
||||||
|
communityLabel,
|
||||||
|
communityOptionLabel,
|
||||||
|
fromNullableSelect,
|
||||||
|
moduleDohProfileIds,
|
||||||
|
NONE_OPTION,
|
||||||
|
nullableSelectValue
|
||||||
|
} from '$lib/components/modules/module-helpers.js';
|
||||||
|
import { Button } from '$lib/ui/core/button/index.js';
|
||||||
|
import { Input } from '$lib/ui/core/input/index.js';
|
||||||
|
import { Label } from '$lib/ui/core/label/index.js';
|
||||||
|
import { Checkbox } from '$lib/ui/core/checkbox/index.js';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter
|
||||||
|
} 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 { notify, notifyApiError } from '$lib/ui/app/toast.js';
|
||||||
|
|
||||||
|
const dohPolicyOptions: { value: DohResolverPolicy; label: string; hint: string }[] = [
|
||||||
|
{
|
||||||
|
value: 'primary_only',
|
||||||
|
label: 'Только первый',
|
||||||
|
hint: 'Используется первый выбранный DoH-профиль.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'failover',
|
||||||
|
label: 'Резервирование',
|
||||||
|
hint: 'Профили по порядку до первого успешного ответа.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'union',
|
||||||
|
label: 'Объединение',
|
||||||
|
hint: 'Все A/AAAA со всех профилей (geo-split DNS).'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
open: boolean;
|
||||||
|
mod: ModuleRow;
|
||||||
|
moduleId: string;
|
||||||
|
communities: BgpCommunity[];
|
||||||
|
dohProfiles: DohProfile[];
|
||||||
|
onSaved: () => void | Promise<void>;
|
||||||
|
onClose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
let {
|
||||||
|
open = $bindable(),
|
||||||
|
mod,
|
||||||
|
moduleId,
|
||||||
|
communities,
|
||||||
|
dohProfiles,
|
||||||
|
onSaved,
|
||||||
|
onClose
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
let editForm = $state<ModulePatch>({});
|
||||||
|
let editSaving = $state(false);
|
||||||
|
let initKey = $state('');
|
||||||
|
|
||||||
|
function resetEditForm() {
|
||||||
|
editForm = {
|
||||||
|
name: mod.name,
|
||||||
|
enabled: mod.enabled,
|
||||||
|
priority: mod.priority,
|
||||||
|
refresh_interval_sec: mod.refresh_interval_sec,
|
||||||
|
cron_expr: mod.cron_expr,
|
||||||
|
default_community_id: mod.default_community_id,
|
||||||
|
doh_profile_ids: moduleDohProfileIds(mod),
|
||||||
|
doh_resolver_policy: mod.doh_resolver_policy ?? 'primary_only'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!open) {
|
||||||
|
initKey = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nextKey = mod.id;
|
||||||
|
if (nextKey !== initKey) {
|
||||||
|
initKey = nextKey;
|
||||||
|
resetEditForm();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function toggleEditDohProfile(id: string, checked: boolean) {
|
||||||
|
let ids = [...(editForm.doh_profile_ids ?? [])];
|
||||||
|
if (checked) {
|
||||||
|
if (!ids.includes(id)) ids.push(id);
|
||||||
|
} else {
|
||||||
|
ids = ids.filter((x) => x !== id);
|
||||||
|
}
|
||||||
|
editForm = { ...editForm, doh_profile_ids: ids };
|
||||||
|
}
|
||||||
|
|
||||||
|
function isEditDohProfileSelected(id: string): boolean {
|
||||||
|
return (editForm.doh_profile_ids ?? []).includes(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveMod() {
|
||||||
|
editSaving = true;
|
||||||
|
try {
|
||||||
|
const cron =
|
||||||
|
typeof editForm.cron_expr === 'string' ? editForm.cron_expr.trim() : editForm.cron_expr;
|
||||||
|
const intervalRaw = editForm.refresh_interval_sec;
|
||||||
|
const interval =
|
||||||
|
intervalRaw === null || intervalRaw === undefined ? null : Number(intervalRaw);
|
||||||
|
const payload: ModulePatch = {
|
||||||
|
...editForm,
|
||||||
|
cron_expr: cron ? cron : null,
|
||||||
|
refresh_interval_sec: Number.isFinite(interval) ? interval : null,
|
||||||
|
default_community_id: fromNullableSelect(
|
||||||
|
nullableSelectValue(editForm.default_community_id)
|
||||||
|
),
|
||||||
|
doh_profile_ids: editForm.doh_profile_ids ?? [],
|
||||||
|
doh_resolver_policy: editForm.doh_resolver_policy ?? 'primary_only'
|
||||||
|
};
|
||||||
|
await apiMutate<ModuleRow>(`/v1/modules/${moduleId}`, 'PATCH', payload);
|
||||||
|
notify.success('Модуль обновлён');
|
||||||
|
open = false;
|
||||||
|
await onSaved();
|
||||||
|
} catch (e) {
|
||||||
|
notifyApiError(e);
|
||||||
|
} finally {
|
||||||
|
editSaving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleOpenChange(next: boolean) {
|
||||||
|
open = next;
|
||||||
|
if (!next) onClose();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Dialog bind:open onOpenChange={handleOpenChange}>
|
||||||
|
<DialogContent class="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Редактировать модуль</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div class="space-y-4 py-2">
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<Label for="e-name">Название</Label>
|
||||||
|
<Input id="e-name" bind:value={editForm.name} />
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<Label for="e-priority">Приоритет</Label>
|
||||||
|
<Input id="e-priority" type="number" bind:value={editForm.priority} />
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<Label for="e-interval">Интервал (сек)</Label>
|
||||||
|
<Input id="e-interval" type="number" bind:value={editForm.refresh_interval_sec} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<Label for="e-cron">Cron-выражение</Label>
|
||||||
|
<Input id="e-cron" placeholder="0 */6 * * *" bind:value={editForm.cron_expr} />
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<Label for="e-comm">Community по умолчанию</Label>
|
||||||
|
<Select
|
||||||
|
type="single"
|
||||||
|
value={nullableSelectValue(editForm.default_community_id)}
|
||||||
|
onValueChange={(v) => {
|
||||||
|
editForm.default_community_id = fromNullableSelect(v);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="e-comm" class="w-full">
|
||||||
|
{editForm.default_community_id
|
||||||
|
? communityLabel(editForm.default_community_id, communities)
|
||||||
|
: 'Не выбрано'}
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value={NONE_OPTION}>Не выбрано</SelectItem>
|
||||||
|
{#each communities as c (c.id)}
|
||||||
|
<SelectItem value={c.id}>{communityOptionLabel(c)}</SelectItem>
|
||||||
|
{/each}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
class="h-7 px-2"
|
||||||
|
onclick={() => {
|
||||||
|
editForm.default_community_id = null;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Сбросить
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{#if mod.type === 'DOMAINS'}
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<Label for="e-doh-policy">Политика DoH</Label>
|
||||||
|
<Select
|
||||||
|
type="single"
|
||||||
|
value={editForm.doh_resolver_policy ?? 'primary_only'}
|
||||||
|
onValueChange={(v) => {
|
||||||
|
if (v) editForm.doh_resolver_policy = v as DohResolverPolicy;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="e-doh-policy" class="w-full">
|
||||||
|
{dohPolicyRu(editForm.doh_resolver_policy)}
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{#each dohPolicyOptions as opt (opt.value)}
|
||||||
|
<SelectItem value={opt.value}>{opt.label}</SelectItem>
|
||||||
|
{/each}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p class="text-xs text-muted-foreground">
|
||||||
|
{dohPolicyOptions.find(
|
||||||
|
(o) => o.value === (editForm.doh_resolver_policy ?? 'primary_only')
|
||||||
|
)?.hint}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label>DoH-профили</Label>
|
||||||
|
<p class="text-xs text-muted-foreground">
|
||||||
|
Порядок выбора = порядок в списке (сверху вниз).
|
||||||
|
</p>
|
||||||
|
<div class="max-h-40 space-y-2 overflow-y-auto rounded-md border p-3">
|
||||||
|
{#each dohProfiles as d (d.id)}
|
||||||
|
<label class="flex items-start gap-2 text-sm">
|
||||||
|
<Checkbox
|
||||||
|
checked={isEditDohProfileSelected(d.id)}
|
||||||
|
onCheckedChange={(v) => toggleEditDohProfile(d.id, v === true)}
|
||||||
|
/>
|
||||||
|
<span class="min-w-0 break-all">
|
||||||
|
<span class="font-medium">{d.name?.trim() ? d.name : d.url}</span>
|
||||||
|
{#if d.name?.trim()}
|
||||||
|
<span class="block font-mono text-xs text-muted-foreground">{d.url}</span>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
{:else}
|
||||||
|
<p class="text-xs text-muted-foreground">Нет профилей — создайте в справочниках.</p>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
class="h-7 px-2"
|
||||||
|
onclick={() => {
|
||||||
|
editForm = { ...editForm, doh_profile_ids: [] };
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Сбросить профили
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<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="e-enabled" class="leading-snug text-foreground">Включён</Label>
|
||||||
|
<p class="text-xs leading-snug text-muted-foreground">
|
||||||
|
Отключённые модули не участвуют в обновлении конфигурации.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
id="e-enabled"
|
||||||
|
class="shrink-0"
|
||||||
|
checked={editForm.enabled !== false}
|
||||||
|
onCheckedChange={(v) => {
|
||||||
|
editForm = { ...editForm, enabled: v };
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onclick={() => handleOpenChange(false)}>Отмена</Button>
|
||||||
|
<Button onclick={saveMod} disabled={editSaving}>
|
||||||
|
{editSaving ? 'Сохранение…' : 'Сохранить'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { apiMutate } from '$lib/api/client.js';
|
||||||
|
import type { BgpCommunity, IpRangeEntry, IpRangeEntryCreate } from '$lib/api/types.js';
|
||||||
|
import { communityLabel, communityOptionLabel } from '$lib/components/modules/module-helpers.js';
|
||||||
|
import { Button } from '$lib/ui/core/button/index.js';
|
||||||
|
import { Input } from '$lib/ui/core/input/index.js';
|
||||||
|
import { Label } from '$lib/ui/core/label/index.js';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter
|
||||||
|
} from '$lib/ui/core/dialog/index.js';
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger } from '$lib/ui/core/select/index.js';
|
||||||
|
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
open: boolean;
|
||||||
|
moduleId: string;
|
||||||
|
edit: IpRangeEntry | null;
|
||||||
|
communities: BgpCommunity[];
|
||||||
|
onSaved: () => void | Promise<void>;
|
||||||
|
onClose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
let { open = $bindable(), moduleId, edit, communities, onSaved, onClose }: Props = $props();
|
||||||
|
|
||||||
|
let saving = $state(false);
|
||||||
|
let form = $state<IpRangeEntryCreate>({ prefix: '', community_id: '' });
|
||||||
|
let initKey = $state('');
|
||||||
|
|
||||||
|
function resetForm() {
|
||||||
|
form = edit
|
||||||
|
? { prefix: edit.prefix, community_id: edit.community_id }
|
||||||
|
: { prefix: '', community_id: '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!open) {
|
||||||
|
initKey = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nextKey = edit?.id ?? 'new';
|
||||||
|
if (nextKey !== initKey) {
|
||||||
|
initKey = nextKey;
|
||||||
|
resetForm();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
saving = true;
|
||||||
|
try {
|
||||||
|
if (edit) {
|
||||||
|
await apiMutate(`/v1/modules/${moduleId}/ip-range-entries/${edit.id}`, 'PATCH', form);
|
||||||
|
notify.success('Диапазон обновлён');
|
||||||
|
} else {
|
||||||
|
await apiMutate(`/v1/modules/${moduleId}/ip-range-entries`, 'POST', form);
|
||||||
|
notify.success('Диапазон добавлен');
|
||||||
|
}
|
||||||
|
open = false;
|
||||||
|
await onSaved();
|
||||||
|
} catch (e) {
|
||||||
|
notifyApiError(e);
|
||||||
|
} finally {
|
||||||
|
saving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleOpenChange(next: boolean) {
|
||||||
|
open = next;
|
||||||
|
if (!next) onClose();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Dialog bind:open onOpenChange={handleOpenChange}>
|
||||||
|
<DialogContent class="sm:max-w-sm">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{edit ? 'Редактировать диапазон' : 'Новый IP-диапазон'}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div class="space-y-4 py-2">
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<Label for="ip-prefix">Префикс (CIDR)</Label>
|
||||||
|
<Input id="ip-prefix" placeholder="203.0.113.0/24" bind:value={form.prefix} />
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<Label for="ip-comm">Community (обязательно)</Label>
|
||||||
|
<Select
|
||||||
|
type="single"
|
||||||
|
value={form.community_id}
|
||||||
|
onValueChange={(v) => {
|
||||||
|
form.community_id = v;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="ip-comm" class="w-full">
|
||||||
|
{form.community_id
|
||||||
|
? communityLabel(form.community_id, communities)
|
||||||
|
: 'Выберите community'}
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{#each communities as c (c.id)}
|
||||||
|
<SelectItem value={c.id}>{communityOptionLabel(c)}</SelectItem>
|
||||||
|
{/each}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onclick={() => handleOpenChange(false)}>Отмена</Button>
|
||||||
|
<Button onclick={save} disabled={saving}>
|
||||||
|
{saving ? 'Сохранение…' : edit ? 'Сохранить' : 'Добавить'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
@@ -0,0 +1,310 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { apiFetch, apiMutate } from '$lib/api/client.js';
|
||||||
|
import type { BgpCommunity, IpRangeEntry, ModuleRow } from '$lib/api/types.js';
|
||||||
|
import {
|
||||||
|
communityLabel,
|
||||||
|
sanitizeFilenamePart,
|
||||||
|
supportsCsvIO
|
||||||
|
} from '$lib/components/modules/module-helpers.js';
|
||||||
|
import { Button } from '$lib/ui/core/button/index.js';
|
||||||
|
import { Checkbox } from '$lib/ui/core/checkbox/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 { confirm } from '$lib/ui/patterns/confirm/confirm-state.svelte.js';
|
||||||
|
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
|
||||||
|
import ModuleIpRangeEntryDialog from '$lib/components/modules/ModuleIpRangeEntryDialog.svelte';
|
||||||
|
import Plus from '@lucide/svelte/icons/plus';
|
||||||
|
import Pencil from '@lucide/svelte/icons/pencil';
|
||||||
|
import Trash2 from '@lucide/svelte/icons/trash-2';
|
||||||
|
import Upload from '@lucide/svelte/icons/upload';
|
||||||
|
import Download from '@lucide/svelte/icons/download';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
moduleId: string;
|
||||||
|
mod: ModuleRow;
|
||||||
|
entries: IpRangeEntry[];
|
||||||
|
communities: BgpCommunity[];
|
||||||
|
loading?: boolean;
|
||||||
|
onChanged: () => void | Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
let { moduleId, mod, entries, communities, loading = false, onChanged }: Props = $props();
|
||||||
|
|
||||||
|
let dialogOpen = $state(false);
|
||||||
|
let editTarget = $state<IpRangeEntry | null>(null);
|
||||||
|
let selectedIds = $state(new Set<string>());
|
||||||
|
let deletingBulk = $state(false);
|
||||||
|
let csvImporting = $state(false);
|
||||||
|
let csvExporting = $state(false);
|
||||||
|
let csvFileInput = $state<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
|
const activeSelected = $derived.by(() => {
|
||||||
|
const allowed = new Set(entries.map((e) => e.id));
|
||||||
|
return [...selectedIds].filter((id) => allowed.has(id));
|
||||||
|
});
|
||||||
|
const selectedCount = $derived(activeSelected.length);
|
||||||
|
const allSelected = $derived(entries.length > 0 && entries.every((e) => selectedIds.has(e.id)));
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ id: 'select', label: '', class: 'w-10' },
|
||||||
|
{
|
||||||
|
id: 'prefix',
|
||||||
|
label: 'Префикс (CIDR)',
|
||||||
|
sortable: true,
|
||||||
|
sortValue: (e: IpRangeEntry) => e.prefix
|
||||||
|
},
|
||||||
|
{ id: 'community', label: 'Community' },
|
||||||
|
{ id: 'actions', label: '', class: 'w-20' }
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
function toggleSelection(id: string) {
|
||||||
|
const next = new Set(selectedIds);
|
||||||
|
if (next.has(id)) next.delete(id);
|
||||||
|
else next.add(id);
|
||||||
|
selectedIds = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleAll(checked: boolean) {
|
||||||
|
selectedIds = checked ? new Set(entries.map((e) => e.id)) : new Set<string>();
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreate() {
|
||||||
|
editTarget = null;
|
||||||
|
dialogOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(entry: IpRangeEntry) {
|
||||||
|
editTarget = entry;
|
||||||
|
dialogOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestDelete(entry: IpRangeEntry) {
|
||||||
|
void confirm({
|
||||||
|
title: 'Удалить диапазон?',
|
||||||
|
description: entry.prefix,
|
||||||
|
confirmLabel: 'Удалить',
|
||||||
|
destructive: true,
|
||||||
|
onConfirm: async () => {
|
||||||
|
await apiMutate(
|
||||||
|
`/v1/modules/${moduleId}/ip-range-entries/${entry.id}`,
|
||||||
|
'DELETE',
|
||||||
|
undefined,
|
||||||
|
{ idempotent: false }
|
||||||
|
);
|
||||||
|
notify.success('Удалено');
|
||||||
|
await onChanged();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestBulkDelete() {
|
||||||
|
if (selectedCount === 0) return;
|
||||||
|
void confirm({
|
||||||
|
title: 'Удалить выбранные диапазоны?',
|
||||||
|
description: `Будет удалено: ${selectedCount}`,
|
||||||
|
confirmLabel: 'Удалить',
|
||||||
|
destructive: true,
|
||||||
|
onConfirm: bulkDelete
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bulkDelete() {
|
||||||
|
if (selectedCount === 0) return;
|
||||||
|
deletingBulk = true;
|
||||||
|
let deleted = 0;
|
||||||
|
try {
|
||||||
|
for (const id of activeSelected) {
|
||||||
|
try {
|
||||||
|
await apiMutate(`/v1/modules/${moduleId}/ip-range-entries/${id}`, 'DELETE', undefined, {
|
||||||
|
idempotent: false
|
||||||
|
});
|
||||||
|
deleted += 1;
|
||||||
|
} catch (e) {
|
||||||
|
notifyApiError(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (deleted > 0) notify.success(`Удалено диапазонов: ${deleted}`);
|
||||||
|
await onChanged();
|
||||||
|
} finally {
|
||||||
|
deletingBulk = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readErrorText(res: Response): Promise<string> {
|
||||||
|
const body = (await res.text()).trim();
|
||||||
|
return body || `HTTP ${res.status}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exportCsv() {
|
||||||
|
if (!supportsCsvIO(mod.type) || csvExporting) return;
|
||||||
|
csvExporting = true;
|
||||||
|
try {
|
||||||
|
const res = await apiFetch(`/v1/modules/${moduleId}/entries.csv`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: { Accept: 'text/csv' }
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
notify.error(await readErrorText(res));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const blob = await res.blob();
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `${sanitizeFilenamePart(mod.name)}-${mod.type.toLowerCase()}-entries.csv`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
a.remove();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
} catch (e) {
|
||||||
|
notifyApiError(e);
|
||||||
|
} finally {
|
||||||
|
csvExporting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openImportPicker() {
|
||||||
|
if (!supportsCsvIO(mod.type) || csvImporting) return;
|
||||||
|
csvFileInput?.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleImportChange(event: Event) {
|
||||||
|
const input = event.currentTarget as HTMLInputElement | null;
|
||||||
|
const file = input?.files?.[0];
|
||||||
|
if (!file || csvImporting) return;
|
||||||
|
csvImporting = true;
|
||||||
|
try {
|
||||||
|
const fileText = await file.text();
|
||||||
|
const res = await apiFetch(`/v1/modules/${moduleId}/entries.csv`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'text/csv' },
|
||||||
|
body: fileText
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
notify.error(await readErrorText(res));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const payload = (await res.json()) as { imported?: number };
|
||||||
|
notify.success(`Импортировано записей: ${payload.imported ?? 0}`);
|
||||||
|
await onChanged();
|
||||||
|
} catch (e) {
|
||||||
|
notifyApiError(e);
|
||||||
|
} finally {
|
||||||
|
csvImporting = false;
|
||||||
|
if (input) input.value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<input
|
||||||
|
class="hidden"
|
||||||
|
type="file"
|
||||||
|
accept=".csv,text/csv"
|
||||||
|
bind:this={csvFileInput}
|
||||||
|
onchange={handleImportChange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader class="flex flex-col gap-3 pb-2 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<CardTitle class="text-base">IP-диапазоны</CardTitle>
|
||||||
|
<CardDescription>Статические CIDR для анонса.</CardDescription>
|
||||||
|
</div>
|
||||||
|
<div class="flex shrink-0 flex-wrap items-center justify-end gap-2 self-start sm:self-auto">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onclick={openImportPicker}
|
||||||
|
disabled={!supportsCsvIO(mod.type) || csvImporting || csvExporting}
|
||||||
|
>
|
||||||
|
<Upload />
|
||||||
|
{csvImporting ? 'Импорт…' : 'Импорт CSV'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onclick={exportCsv}
|
||||||
|
disabled={!supportsCsvIO(mod.type) || csvExporting || csvImporting}
|
||||||
|
>
|
||||||
|
<Download />
|
||||||
|
{csvExporting ? 'Экспорт…' : 'Экспорт CSV'}
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" onclick={openCreate}><Plus />Добавить</Button>
|
||||||
|
{#if selectedCount > 0}
|
||||||
|
<Button variant="destructive" size="sm" onclick={requestBulkDelete} disabled={deletingBulk}>
|
||||||
|
<Trash2 />
|
||||||
|
Удалить ({selectedCount})
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="p-4 pt-0">
|
||||||
|
<AppDataTable
|
||||||
|
columns={[...columns]}
|
||||||
|
rows={entries}
|
||||||
|
rowKey={(e) => e.id}
|
||||||
|
{loading}
|
||||||
|
emptyTitle="Нет диапазонов"
|
||||||
|
emptyDescription="Добавьте CIDR или импортируйте CSV."
|
||||||
|
>
|
||||||
|
{#snippet toolbar()}
|
||||||
|
{#if entries.length > 0}
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
checked={allSelected}
|
||||||
|
onCheckedChange={(v) => toggleAll(v === true)}
|
||||||
|
aria-label="Выбрать все диапазоны"
|
||||||
|
/>
|
||||||
|
<span class="text-sm text-muted-foreground">Выбрать все</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
{#snippet cell({ row: entry, column })}
|
||||||
|
{#if column.id === 'select'}
|
||||||
|
<Checkbox
|
||||||
|
checked={selectedIds.has(entry.id)}
|
||||||
|
aria-label={`Выбрать диапазон ${entry.prefix}`}
|
||||||
|
onCheckedChange={() => toggleSelection(entry.id)}
|
||||||
|
/>
|
||||||
|
{:else if column.id === 'prefix'}
|
||||||
|
<span class="font-mono">{entry.prefix}</span>
|
||||||
|
{:else if column.id === 'community'}
|
||||||
|
<span class="text-sm text-muted-foreground">
|
||||||
|
{communityLabel(entry.community_id, communities)}
|
||||||
|
</span>
|
||||||
|
{:else if column.id === 'actions'}
|
||||||
|
<div class="flex gap-1">
|
||||||
|
<Button variant="ghost" size="icon-sm" onclick={() => openEdit(entry)}>
|
||||||
|
<Pencil class="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
class="text-destructive"
|
||||||
|
onclick={() => requestDelete(entry)}
|
||||||
|
>
|
||||||
|
<Trash2 class="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
</AppDataTable>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<ModuleIpRangeEntryDialog
|
||||||
|
bind:open={dialogOpen}
|
||||||
|
{moduleId}
|
||||||
|
edit={editTarget}
|
||||||
|
{communities}
|
||||||
|
onSaved={onChanged}
|
||||||
|
onClose={() => {
|
||||||
|
editTarget = null;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { AsEntry, BgpCommunity, DohProfile, ModuleRow } from '$lib/api/types.js';
|
||||||
|
import { formatDateTime, moduleIntervalLabel } from '$lib/modules/display.js';
|
||||||
|
import { dohPolicyRu } from '$lib/ui-labels.js';
|
||||||
|
import {
|
||||||
|
communityLabel,
|
||||||
|
dohProfileLabel,
|
||||||
|
moduleDohProfileIds
|
||||||
|
} from '$lib/components/modules/module-helpers.js';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '$lib/ui/core/card/index.js';
|
||||||
|
import CardSkeleton from '$lib/ui/patterns/feedback/card-skeleton.svelte';
|
||||||
|
import { cn } from '$lib/utils.js';
|
||||||
|
import ArrowDownUp from '@lucide/svelte/icons/arrow-down-up';
|
||||||
|
import Timer from '@lucide/svelte/icons/timer';
|
||||||
|
import ShieldCheck from '@lucide/svelte/icons/shield-check';
|
||||||
|
import Network from '@lucide/svelte/icons/network';
|
||||||
|
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
mod: ModuleRow | null;
|
||||||
|
communities: BgpCommunity[];
|
||||||
|
dohProfiles: DohProfile[];
|
||||||
|
asEntries: AsEntry[];
|
||||||
|
loading?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
let { mod, communities, dohProfiles, asEntries, loading = false }: Props = $props();
|
||||||
|
|
||||||
|
const asPrefixTotal = $derived(
|
||||||
|
asEntries.reduce((acc, entry) => acc + (entry.prefix_count ?? 0), 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
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-5',
|
||||||
|
bg: 'bg-chart-5/5',
|
||||||
|
iconBg: 'bg-chart-5/15',
|
||||||
|
iconText: 'text-chart-5'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
border: 'border-l-chart-4',
|
||||||
|
bg: 'bg-chart-4/5',
|
||||||
|
iconBg: 'bg-chart-4/15',
|
||||||
|
iconText: 'text-chart-4'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
border: 'border-l-chart-2',
|
||||||
|
bg: 'bg-chart-2/5',
|
||||||
|
iconBg: 'bg-chart-2/15',
|
||||||
|
iconText: 'text-chart-2'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
border: 'border-l-muted-foreground',
|
||||||
|
bg: 'bg-muted/30',
|
||||||
|
iconBg: 'bg-muted',
|
||||||
|
iconText: 'text-muted-foreground'
|
||||||
|
}
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const kpiCards = $derived.by(() => {
|
||||||
|
if (!mod) return [];
|
||||||
|
const dohIds = moduleDohProfileIds(mod);
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'priority',
|
||||||
|
label: 'Приоритет',
|
||||||
|
value: String(mod.priority ?? 0),
|
||||||
|
description: 'порядок в сборке ревизии',
|
||||||
|
icon: ArrowDownUp,
|
||||||
|
accent: statAccents[0]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'interval',
|
||||||
|
label: 'Интервал',
|
||||||
|
value: moduleIntervalLabel(mod),
|
||||||
|
description: 'refresh_interval_sec / cron',
|
||||||
|
icon: Timer,
|
||||||
|
accent: statAccents[1],
|
||||||
|
mono: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'doh',
|
||||||
|
label: 'DoH',
|
||||||
|
value: mod.type === 'DOMAINS' ? dohPolicyRu(mod.doh_resolver_policy) : '—',
|
||||||
|
description:
|
||||||
|
mod.type === 'DOMAINS'
|
||||||
|
? dohIds.length
|
||||||
|
? dohIds.map((id) => dohProfileLabel(id, dohProfiles)).join('; ')
|
||||||
|
: 'Системный DNS'
|
||||||
|
: 'не применимо',
|
||||||
|
icon: Network,
|
||||||
|
accent: statAccents[2]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'community',
|
||||||
|
label: 'Community по умолч.',
|
||||||
|
value: communityLabel(mod.default_community_id, communities),
|
||||||
|
description: 'для записей без своего community',
|
||||||
|
icon: ShieldCheck,
|
||||||
|
accent: statAccents[3]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'refreshed',
|
||||||
|
label: 'Последнее обновление',
|
||||||
|
value: formatDateTime(mod.last_refreshed_at),
|
||||||
|
description:
|
||||||
|
mod.type === 'AS_PREFIXES'
|
||||||
|
? `ASN: ${asEntries.length}, префиксов: ${asPrefixTotal}`
|
||||||
|
: 'время последнего refresh',
|
||||||
|
icon: RefreshCw,
|
||||||
|
accent: statAccents[4]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-5">
|
||||||
|
{#if loading}
|
||||||
|
{#each Array(5) 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">
|
||||||
|
<p class="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||||
|
<span
|
||||||
|
class={cn('flex size-7 shrink-0 items-center justify-center rounded-md', a.iconBg)}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<Icon class={cn('size-3.5', a.iconText)} />
|
||||||
|
</span>
|
||||||
|
{card.label}
|
||||||
|
</p>
|
||||||
|
<CardTitle
|
||||||
|
class={cn('text-base font-semibold break-all', card.mono ? 'font-mono text-sm' : '')}
|
||||||
|
>
|
||||||
|
{card.value}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p class="line-clamp-2 text-xs text-muted-foreground">{card.description}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { resolve } from '$app/paths';
|
||||||
|
import type { AsEntry, CdnSource, DomainEntry, IpRangeEntry, ModuleRow } 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';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
mod: ModuleRow;
|
||||||
|
asEntries: AsEntry[];
|
||||||
|
cdnSources: CdnSource[];
|
||||||
|
domainEntries: DomainEntry[];
|
||||||
|
ipEntries: IpRangeEntry[];
|
||||||
|
};
|
||||||
|
|
||||||
|
let { mod, asEntries, cdnSources, domainEntries, ipEntries }: Props = $props();
|
||||||
|
|
||||||
|
const asPrefixTotal = $derived(
|
||||||
|
asEntries.reduce((acc, entry) => acc + (entry.prefix_count ?? 0), 0)
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle class="text-base">Операционный отчёт модуля</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Читаемая сводка по данным модуля: источники, объёмы и ожидаемый результат для
|
||||||
|
refresh/агрегации.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="grid gap-3 md:grid-cols-2">
|
||||||
|
{#if mod.type === 'DOMAINS'}
|
||||||
|
<div class="rounded-lg border p-3">
|
||||||
|
<p class="text-sm font-medium">Домены и ожидаемые IP</p>
|
||||||
|
<p class="text-xs text-muted-foreground">
|
||||||
|
После refresh домены резолвятся в IP и конвертируются в префиксы.
|
||||||
|
</p>
|
||||||
|
<div class="mt-2 space-y-1">
|
||||||
|
{#each domainEntries.slice(0, 8) as entry (entry.id)}
|
||||||
|
<p class="font-mono text-xs break-all">{entry.fqdn}</p>
|
||||||
|
{:else}
|
||||||
|
<p class="text-xs text-muted-foreground">Нет доменов</p>
|
||||||
|
{/each}
|
||||||
|
{#if domainEntries.length > 8}
|
||||||
|
<p class="text-xs text-muted-foreground">…и ещё {domainEntries.length - 8}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else if mod.type === 'AS_PREFIXES'}
|
||||||
|
<div class="rounded-lg border p-3">
|
||||||
|
<p class="text-sm font-medium">ASN и число полученных префиксов</p>
|
||||||
|
<p class="text-xs text-muted-foreground">
|
||||||
|
Счётчик префиксов обновляется после успешного refresh (RIPEstat).
|
||||||
|
</p>
|
||||||
|
<div class="mt-2 space-y-1">
|
||||||
|
<p class="text-xs">
|
||||||
|
Всего ASN: <span class="font-semibold">{asEntries.length}</span>
|
||||||
|
</p>
|
||||||
|
<p class="text-xs">
|
||||||
|
Сумма префиксов: <span class="font-semibold">{asPrefixTotal}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else if mod.type === 'CDN_CIDRS'}
|
||||||
|
<div class="rounded-lg border p-3">
|
||||||
|
<p class="text-sm font-medium">CDN ссылки и импортируемые префиксы</p>
|
||||||
|
<p class="text-xs text-muted-foreground">
|
||||||
|
Каждый URL поставляет список CIDR для агрегации.
|
||||||
|
</p>
|
||||||
|
<div class="mt-2 space-y-1">
|
||||||
|
{#each cdnSources.slice(0, 6) as src (src.id)}
|
||||||
|
<p class="font-mono text-xs break-all">{src.url}</p>
|
||||||
|
{:else}
|
||||||
|
<p class="text-xs text-muted-foreground">Нет CDN источников</p>
|
||||||
|
{/each}
|
||||||
|
{#if cdnSources.length > 6}
|
||||||
|
<p class="text-xs text-muted-foreground">…и ещё {cdnSources.length - 6}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else if mod.type === 'IP_RANGES'}
|
||||||
|
<div class="rounded-lg border p-3">
|
||||||
|
<p class="text-sm font-medium">IP ranges для агрегации</p>
|
||||||
|
<p class="text-xs text-muted-foreground">
|
||||||
|
Статические CIDR, которые попадают в итоговую ревизию.
|
||||||
|
</p>
|
||||||
|
<div class="mt-2 space-y-1">
|
||||||
|
{#each ipEntries.slice(0, 8) as entry (entry.id)}
|
||||||
|
<p class="font-mono text-xs">{entry.prefix}</p>
|
||||||
|
{:else}
|
||||||
|
<p class="text-xs text-muted-foreground">Нет диапазонов</p>
|
||||||
|
{/each}
|
||||||
|
{#if ipEntries.length > 8}
|
||||||
|
<p class="text-xs text-muted-foreground">…и ещё {ipEntries.length - 8}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div class="rounded-lg border p-3">
|
||||||
|
<p class="text-sm font-medium">Результат операции</p>
|
||||||
|
<p class="text-xs text-muted-foreground">
|
||||||
|
Подробный результат по конкретному запуску refresh смотрите в Операции → Задачи →
|
||||||
|
module_refresh: там отображаются источники, количество префиксов и итог агрегации.
|
||||||
|
</p>
|
||||||
|
<Button variant="link" class="mt-2 h-auto p-0" href={resolve('/operations?tab=jobs')}>
|
||||||
|
Открыть задачи
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import type { BgpCommunity, DohProfile, ModuleRow } from '$lib/api/types.js';
|
||||||
|
|
||||||
|
export const NONE_OPTION = '__none__';
|
||||||
|
|
||||||
|
export function supportsCsvIO(type: ModuleRow['type'] | null | undefined): boolean {
|
||||||
|
return type === 'AS_PREFIXES' || type === 'DOMAINS' || type === 'IP_RANGES';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sanitizeFilenamePart(v: string): string {
|
||||||
|
const cleaned = v
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9._-]+/g, '-')
|
||||||
|
.replace(/-+/g, '-')
|
||||||
|
.replace(/^[-_.]+|[-_.]+$/g, '');
|
||||||
|
return cleaned || 'module';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function communityLabel(id: string | null, communities: BgpCommunity[]): string {
|
||||||
|
if (!id) return '—';
|
||||||
|
const c = communities.find((x) => x.id === id);
|
||||||
|
if (!c) return id.slice(0, 8) + '…';
|
||||||
|
const t = c.title?.trim();
|
||||||
|
return t || c.community;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function communityOptionLabel(c: BgpCommunity): string {
|
||||||
|
const t = c.title?.trim();
|
||||||
|
return t || c.community;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function nullableSelectValue(value: string | null | undefined): string {
|
||||||
|
if (value === null || value === undefined || value === '') return NONE_OPTION;
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fromNullableSelect(value: string): string | null {
|
||||||
|
if (value === NONE_OPTION || value === '') return null;
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function moduleDohProfileIds(modRow: ModuleRow | null): string[] {
|
||||||
|
if (!modRow) return [];
|
||||||
|
if (modRow.doh_profile_ids?.length) return modRow.doh_profile_ids;
|
||||||
|
return modRow.doh_profile_id ? [modRow.doh_profile_id] : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dohProfileLabel(id: string, dohProfiles: DohProfile[]): string {
|
||||||
|
const p = dohProfiles.find((d) => d.id === id);
|
||||||
|
return p ? (p.name?.trim() ? `${p.name} (${p.url})` : p.url) : id.slice(0, 8) + '…';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeCdnSourceKind(k: string): 'plaintext' | 'json' {
|
||||||
|
return k.trim().toLowerCase() === 'json' ? 'json' : 'plaintext';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function syncSelection(selected: Set<string>, existingIds: string[]): Set<string> {
|
||||||
|
const validIds = new Set(existingIds);
|
||||||
|
return new Set([...selected].filter((id) => validIds.has(id)));
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -1,7 +1,15 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { RevisionDiff, RevisionPrefix, RevisionRow } from '$lib/api/types.js';
|
import type { RevisionDiff, RevisionPrefix, RevisionRow } from '$lib/api/types.js';
|
||||||
import { Button } from '$lib/components/ui/button/index.js';
|
import { Button } from '$lib/ui/core/button/index.js';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '$lib/components/ui/card/index.js';
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle
|
||||||
|
} from '$lib/ui/core/card/index.js';
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger } from '$lib/ui/core/select/index.js';
|
||||||
|
import { formatDateTime } from '$lib/modules/display.js';
|
||||||
import { sortRevisionDiffItems } from '$lib/sort-prefixes.js';
|
import { sortRevisionDiffItems } from '$lib/sort-prefixes.js';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -13,7 +21,6 @@
|
|||||||
onDiffRevAChange: (value: string) => void;
|
onDiffRevAChange: (value: string) => void;
|
||||||
onDiffRevBChange: (value: string) => void;
|
onDiffRevBChange: (value: string) => void;
|
||||||
onLoadDiff: () => void;
|
onLoadDiff: () => void;
|
||||||
formatDate: (d?: string | null) => string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -24,11 +31,13 @@
|
|||||||
diffLoading,
|
diffLoading,
|
||||||
onDiffRevAChange,
|
onDiffRevAChange,
|
||||||
onDiffRevBChange,
|
onDiffRevBChange,
|
||||||
onLoadDiff,
|
onLoadDiff
|
||||||
formatDate
|
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
/** Бэкенд отдаёт `prefixes.added` / `prefixes.removed`; верхний уровень added/removed — опционально. */
|
function revisionLabel(rev: RevisionRow): string {
|
||||||
|
return `${rev.id.slice(0, 8)}… (${formatDateTime(rev.created_at)})`;
|
||||||
|
}
|
||||||
|
|
||||||
function diffAddedRaw(d: RevisionDiff | null): (string | RevisionPrefix)[] {
|
function diffAddedRaw(d: RevisionDiff | null): (string | RevisionPrefix)[] {
|
||||||
if (!d) return [];
|
if (!d) return [];
|
||||||
if (d.prefixes && Array.isArray(d.prefixes.added)) return d.prefixes.added;
|
if (d.prefixes && Array.isArray(d.prefixes.added)) return d.prefixes.added;
|
||||||
@@ -76,64 +85,80 @@
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent class="min-h-0 space-y-4">
|
<CardContent class="min-h-0 space-y-4">
|
||||||
<div class="flex flex-col gap-2 sm:flex-row">
|
<div class="flex flex-col gap-2 sm:flex-row">
|
||||||
<select
|
<Select type="single" value={diffRevA} onValueChange={(v) => onDiffRevAChange(v ?? '')}>
|
||||||
value={diffRevA}
|
<SelectTrigger class="min-w-0 flex-1">
|
||||||
onchange={(e) => onDiffRevAChange((e.currentTarget as HTMLSelectElement).value)}
|
{diffRevA
|
||||||
class="border-border bg-background h-8 min-w-0 flex-1 rounded-md border px-2 text-sm"
|
? revisions.find((r) => r.id === diffRevA)
|
||||||
>
|
? revisionLabel(revisions.find((r) => r.id === diffRevA)!)
|
||||||
<option value="">Ревизия A</option>
|
: diffRevA
|
||||||
|
: 'Ревизия A'}
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="">Ревизия A</SelectItem>
|
||||||
{#each revisions as rev (rev.id)}
|
{#each revisions as rev (rev.id)}
|
||||||
<option value={rev.id}>{rev.id.slice(0, 8)}… ({formatDate(rev.created_at)})</option>
|
<SelectItem value={rev.id}>{revisionLabel(rev)}</SelectItem>
|
||||||
{/each}
|
{/each}
|
||||||
</select>
|
</SelectContent>
|
||||||
<select
|
</Select>
|
||||||
value={diffRevB}
|
<Select type="single" value={diffRevB} onValueChange={(v) => onDiffRevBChange(v ?? '')}>
|
||||||
onchange={(e) => onDiffRevBChange((e.currentTarget as HTMLSelectElement).value)}
|
<SelectTrigger class="min-w-0 flex-1">
|
||||||
class="border-border bg-background h-8 min-w-0 flex-1 rounded-md border px-2 text-sm"
|
{diffRevB
|
||||||
>
|
? revisions.find((r) => r.id === diffRevB)
|
||||||
<option value="">Ревизия B</option>
|
? revisionLabel(revisions.find((r) => r.id === diffRevB)!)
|
||||||
|
: diffRevB
|
||||||
|
: 'Ревизия B'}
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="">Ревизия B</SelectItem>
|
||||||
{#each revisions as rev (rev.id)}
|
{#each revisions as rev (rev.id)}
|
||||||
<option value={rev.id}>{rev.id.slice(0, 8)}… ({formatDate(rev.created_at)})</option>
|
<SelectItem value={rev.id}>{revisionLabel(rev)}</SelectItem>
|
||||||
{/each}
|
{/each}
|
||||||
</select>
|
</SelectContent>
|
||||||
<Button size="sm" class="shrink-0 self-start sm:self-auto" onclick={onLoadDiff} disabled={diffLoading}>
|
</Select>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
class="shrink-0 self-start sm:self-auto"
|
||||||
|
onclick={onLoadDiff}
|
||||||
|
disabled={diffLoading}
|
||||||
|
>
|
||||||
{diffLoading ? 'Загрузка…' : 'Сравнить'}
|
{diffLoading ? 'Загрузка…' : 'Сравнить'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if diffData}
|
{#if diffData}
|
||||||
<div
|
<div
|
||||||
class="border-border grid min-h-0 grid-cols-1 overflow-hidden rounded-md border sm:grid-cols-2"
|
class="grid min-h-0 grid-cols-1 overflow-hidden rounded-md border border-border sm:grid-cols-2"
|
||||||
style="scrollbar-gutter: stable;"
|
style="scrollbar-gutter: stable;"
|
||||||
>
|
>
|
||||||
<!-- + Добавлено -->
|
<div class="flex min-h-0 min-w-0 flex-col border-b border-border sm:border-r sm:border-b-0">
|
||||||
<div class="border-border flex min-h-0 min-w-0 flex-col border-b sm:border-r sm:border-b-0">
|
|
||||||
<div
|
<div
|
||||||
class="border-border bg-muted/60 flex shrink-0 items-center border-b px-3 py-2 font-mono text-xs font-semibold text-green-600 dark:text-green-400"
|
class="flex shrink-0 items-center border-b border-border bg-muted/60 px-3 py-2 font-mono text-xs font-semibold text-success"
|
||||||
>
|
>
|
||||||
<span class="text-muted-foreground mr-2 w-10 shrink-0 select-none text-right">+</span>
|
<span class="mr-2 w-10 shrink-0 text-right text-muted-foreground select-none">+</span>
|
||||||
<span>Добавлено ({addedSorted.length})</span>
|
<span>Добавлено ({addedSorted.length})</span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
bind:this={scrollAddedEl}
|
bind:this={scrollAddedEl}
|
||||||
class="bg-background/50 max-h-[min(28rem,70vh)] min-h-0 overflow-x-auto overflow-y-scroll overscroll-contain"
|
class="max-h-[min(28rem,70vh)] min-h-0 overflow-x-auto overflow-y-scroll overscroll-contain bg-background/50"
|
||||||
onscroll={syncFromAdded}
|
onscroll={syncFromAdded}
|
||||||
>
|
>
|
||||||
{#if addedSorted.length === 0}
|
{#if addedSorted.length === 0}
|
||||||
<p class="text-muted-foreground p-3 font-mono text-xs">Нет изменений</p>
|
<p class="p-3 font-mono text-xs text-muted-foreground">Нет изменений</p>
|
||||||
{:else}
|
{:else}
|
||||||
<table class="w-full border-collapse font-mono text-xs">
|
<table class="w-full border-collapse font-mono text-xs">
|
||||||
<tbody>
|
<tbody>
|
||||||
{#each addedSorted as line, i (`a-${i}-${line}`)}
|
{#each addedSorted as line, i (`a-${i}-${line}`)}
|
||||||
<tr
|
<tr
|
||||||
class="border-border/50 hover:bg-muted/30 border-b border-l-2 border-l-green-500/80 bg-green-500/[0.08] dark:bg-green-500/15"
|
class="border-b border-l-2 border-success/30 bg-success/5 hover:bg-muted/30"
|
||||||
>
|
>
|
||||||
<td
|
<td
|
||||||
class="text-muted-foreground w-10 shrink-0 border-r border-transparent py-0.5 pr-1 pl-2 text-right align-top text-[11px] tabular-nums select-none"
|
class="w-10 shrink-0 border-r border-transparent py-0.5 pr-1 pl-2 text-right align-top text-[11px] text-muted-foreground tabular-nums select-none"
|
||||||
>
|
>
|
||||||
{i + 1}
|
{i + 1}
|
||||||
</td>
|
</td>
|
||||||
<td class="text-foreground max-w-0 break-all py-0.5 pr-3 pl-1 whitespace-pre-wrap">
|
<td
|
||||||
|
class="max-w-0 py-0.5 pr-3 pl-1 break-all whitespace-pre-wrap text-foreground"
|
||||||
|
>
|
||||||
{line}
|
{line}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -144,34 +169,35 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- − Удалено -->
|
|
||||||
<div class="flex min-h-0 min-w-0 flex-col">
|
<div class="flex min-h-0 min-w-0 flex-col">
|
||||||
<div
|
<div
|
||||||
class="border-border bg-muted/60 flex shrink-0 items-center border-b px-3 py-2 font-mono text-xs font-semibold text-red-600 dark:text-red-400"
|
class="flex shrink-0 items-center border-b border-border bg-muted/60 px-3 py-2 font-mono text-xs font-semibold text-destructive"
|
||||||
>
|
>
|
||||||
<span class="text-muted-foreground mr-2 w-10 shrink-0 select-none text-right">−</span>
|
<span class="mr-2 w-10 shrink-0 text-right text-muted-foreground select-none">−</span>
|
||||||
<span>Удалено ({removedSorted.length})</span>
|
<span>Удалено ({removedSorted.length})</span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
bind:this={scrollRemovedEl}
|
bind:this={scrollRemovedEl}
|
||||||
class="bg-background/50 max-h-[min(28rem,70vh)] min-h-0 overflow-x-auto overflow-y-scroll overscroll-contain"
|
class="max-h-[min(28rem,70vh)] min-h-0 overflow-x-auto overflow-y-scroll overscroll-contain bg-background/50"
|
||||||
onscroll={syncFromRemoved}
|
onscroll={syncFromRemoved}
|
||||||
>
|
>
|
||||||
{#if removedSorted.length === 0}
|
{#if removedSorted.length === 0}
|
||||||
<p class="text-muted-foreground p-3 font-mono text-xs">Нет изменений</p>
|
<p class="p-3 font-mono text-xs text-muted-foreground">Нет изменений</p>
|
||||||
{:else}
|
{:else}
|
||||||
<table class="w-full border-collapse font-mono text-xs">
|
<table class="w-full border-collapse font-mono text-xs">
|
||||||
<tbody>
|
<tbody>
|
||||||
{#each removedSorted as line, i (`r-${i}-${line}`)}
|
{#each removedSorted as line, i (`r-${i}-${line}`)}
|
||||||
<tr
|
<tr
|
||||||
class="border-border/50 hover:bg-muted/30 border-b border-l-2 border-l-red-500/80 bg-red-500/[0.08] dark:bg-red-500/15"
|
class="border-b border-l-2 border-destructive/30 bg-destructive/5 hover:bg-muted/30"
|
||||||
>
|
>
|
||||||
<td
|
<td
|
||||||
class="text-muted-foreground w-10 shrink-0 border-r border-transparent py-0.5 pr-1 pl-2 text-right align-top text-[11px] tabular-nums select-none"
|
class="w-10 shrink-0 border-r border-transparent py-0.5 pr-1 pl-2 text-right align-top text-[11px] text-muted-foreground tabular-nums select-none"
|
||||||
>
|
>
|
||||||
{i + 1}
|
{i + 1}
|
||||||
</td>
|
</td>
|
||||||
<td class="text-foreground max-w-0 break-all py-0.5 pr-3 pl-1 whitespace-pre-wrap">
|
<td
|
||||||
|
class="max-w-0 py-0.5 pr-3 pl-1 break-all whitespace-pre-wrap text-foreground"
|
||||||
|
>
|
||||||
{line}
|
{line}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Button } from '$lib/components/ui/button/index.js';
|
import { Button } from '$lib/ui/core/button/index.js';
|
||||||
import { Input } from '$lib/components/ui/input/index.js';
|
import { Input } from '$lib/ui/core/input/index.js';
|
||||||
import { Label } from '$lib/components/ui/label/index.js';
|
import { Label } from '$lib/ui/core/label/index.js';
|
||||||
import Filter from '@lucide/svelte/icons/filter';
|
import Filter from '@lucide/svelte/icons/filter';
|
||||||
import Search from '@lucide/svelte/icons/search';
|
import Search from '@lucide/svelte/icons/search';
|
||||||
import X from '@lucide/svelte/icons/x';
|
import X from '@lucide/svelte/icons/x';
|
||||||
@@ -61,7 +61,7 @@
|
|||||||
|
|
||||||
<div class="space-y-3 rounded-lg border border-border/80 bg-muted/15 p-3">
|
<div class="space-y-3 rounded-lg border border-border/80 bg-muted/15 p-3">
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
<Filter class="text-muted-foreground size-4 shrink-0" aria-hidden="true" />
|
<Filter class="size-4 shrink-0 text-muted-foreground" aria-hidden="true" />
|
||||||
<span class="text-sm font-medium">Фильтры задач</span>
|
<span class="text-sm font-medium">Фильтры задач</span>
|
||||||
<div class="ml-auto flex flex-wrap gap-1.5">
|
<div class="ml-auto flex flex-wrap gap-1.5">
|
||||||
<Button
|
<Button
|
||||||
@@ -84,7 +84,14 @@
|
|||||||
>
|
>
|
||||||
В работе
|
В работе
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="button" variant="ghost" size="sm" class="h-7 text-xs" {disabled} onclick={onResetFilters}>
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
class="h-7 text-xs"
|
||||||
|
{disabled}
|
||||||
|
onclick={onResetFilters}
|
||||||
|
>
|
||||||
<X class="size-3.5" aria-hidden="true" />
|
<X class="size-3.5" aria-hidden="true" />
|
||||||
Сброс
|
Сброс
|
||||||
</Button>
|
</Button>
|
||||||
@@ -93,7 +100,7 @@
|
|||||||
|
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<Search
|
<Search
|
||||||
class="text-muted-foreground pointer-events-none absolute top-1/2 left-2.5 size-4 -translate-y-1/2"
|
class="pointer-events-none absolute top-1/2 left-2.5 size-4 -translate-y-1/2 text-muted-foreground"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
@@ -113,7 +120,7 @@
|
|||||||
<Label for="job-filter-status" class="text-xs">Статус</Label>
|
<Label for="job-filter-status" class="text-xs">Статус</Label>
|
||||||
<select
|
<select
|
||||||
id="job-filter-status"
|
id="job-filter-status"
|
||||||
class="border-border bg-background h-9 w-full rounded-md border px-2 text-sm"
|
class="h-9 w-full rounded-md border border-border bg-background px-2 text-sm"
|
||||||
value={filterStatus}
|
value={filterStatus}
|
||||||
onchange={(e) => onFilterStatusChange((e.currentTarget as HTMLSelectElement).value)}
|
onchange={(e) => onFilterStatusChange((e.currentTarget as HTMLSelectElement).value)}
|
||||||
{disabled}
|
{disabled}
|
||||||
@@ -127,7 +134,7 @@
|
|||||||
<Label for="job-filter-kind" class="text-xs">Тип задачи</Label>
|
<Label for="job-filter-kind" class="text-xs">Тип задачи</Label>
|
||||||
<select
|
<select
|
||||||
id="job-filter-kind"
|
id="job-filter-kind"
|
||||||
class="border-border bg-background h-9 w-full rounded-md border px-2 text-sm"
|
class="h-9 w-full rounded-md border border-border bg-background px-2 text-sm"
|
||||||
value={filterKind}
|
value={filterKind}
|
||||||
onchange={(e) => onFilterKindChange((e.currentTarget as HTMLSelectElement).value)}
|
onchange={(e) => onFilterKindChange((e.currentTarget as HTMLSelectElement).value)}
|
||||||
{disabled}
|
{disabled}
|
||||||
@@ -141,7 +148,7 @@
|
|||||||
<Label for="job-filter-module" class="text-xs">Модуль (локально)</Label>
|
<Label for="job-filter-module" class="text-xs">Модуль (локально)</Label>
|
||||||
<select
|
<select
|
||||||
id="job-filter-module"
|
id="job-filter-module"
|
||||||
class="border-border bg-background h-9 w-full rounded-md border px-2 text-sm"
|
class="h-9 w-full rounded-md border border-border bg-background px-2 text-sm"
|
||||||
value={filterModuleId}
|
value={filterModuleId}
|
||||||
onchange={(e) => onFilterModuleIdChange((e.currentTarget as HTMLSelectElement).value)}
|
onchange={(e) => onFilterModuleIdChange((e.currentTarget as HTMLSelectElement).value)}
|
||||||
{disabled}
|
{disabled}
|
||||||
|
|||||||
@@ -2,15 +2,17 @@
|
|||||||
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
|
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
|
||||||
import type { JobRow } from '$lib/api/types.js';
|
import type { JobRow } from '$lib/api/types.js';
|
||||||
import type { JobDetailedReport, JobLogEntry } from './types.js';
|
import type { JobDetailedReport, JobLogEntry } from './types.js';
|
||||||
import { Badge } from '$lib/components/ui/badge/index.js';
|
import { Badge } from '$lib/ui/core/badge/index.js';
|
||||||
import { Button } from '$lib/components/ui/button/index.js';
|
import { Button } from '$lib/ui/core/button/index.js';
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
CardDescription,
|
CardDescription,
|
||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle
|
CardTitle
|
||||||
} from '$lib/components/ui/card/index.js';
|
} from '$lib/ui/core/card/index.js';
|
||||||
|
import EmptyState from '$lib/ui/patterns/empty-state/empty-state.svelte';
|
||||||
|
import { formatDateTime } from '$lib/modules/display.js';
|
||||||
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
|
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
|
||||||
import Eye from '@lucide/svelte/icons/eye';
|
import Eye from '@lucide/svelte/icons/eye';
|
||||||
import X from '@lucide/svelte/icons/x';
|
import X from '@lucide/svelte/icons/x';
|
||||||
@@ -49,7 +51,6 @@
|
|||||||
getJobLogEntries: (job: JobRow) => JobLogEntry[];
|
getJobLogEntries: (job: JobRow) => JobLogEntry[];
|
||||||
getJobLogTotal: (job: JobRow, entries?: JobLogEntry[]) => number;
|
getJobLogTotal: (job: JobRow, entries?: JobLogEntry[]) => number;
|
||||||
jobStatusVariant: (status: string) => 'default' | 'secondary' | 'outline' | 'destructive';
|
jobStatusVariant: (status: string) => 'default' | 'secondary' | 'outline' | 'destructive';
|
||||||
formatDate: (d?: string | null) => string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -69,8 +70,7 @@
|
|||||||
isJobExpanded,
|
isJobExpanded,
|
||||||
getJobLogEntries,
|
getJobLogEntries,
|
||||||
getJobLogTotal,
|
getJobLogTotal,
|
||||||
jobStatusVariant,
|
jobStatusVariant
|
||||||
formatDate
|
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
const reportCols = reportRowColumns as import('@tanstack/table-core').ColumnDef<
|
const reportCols = reportRowColumns as import('@tanstack/table-core').ColumnDef<
|
||||||
@@ -87,7 +87,7 @@
|
|||||||
<CardDescription class="flex flex-wrap items-center gap-x-2 gap-y-1">
|
<CardDescription class="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||||
<span>Фоновые задачи (ingest, применение, обновление)</span>
|
<span>Фоновые задачи (ingest, применение, обновление)</span>
|
||||||
{#if jobsFetchedTotal !== undefined}
|
{#if jobsFetchedTotal !== undefined}
|
||||||
<span class="text-muted-foreground font-normal tabular-nums">
|
<span class="font-normal text-muted-foreground tabular-nums">
|
||||||
· Показано {jobs.length} из {jobsFetchedTotal}
|
· Показано {jobs.length} из {jobsFetchedTotal}
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -163,46 +163,40 @@
|
|||||||
<div
|
<div
|
||||||
class="rounded-md border border-chart-1/25 bg-chart-1/5 px-2.5 py-2 dark:bg-chart-1/10"
|
class="rounded-md border border-chart-1/25 bg-chart-1/5 px-2.5 py-2 dark:bg-chart-1/10"
|
||||||
>
|
>
|
||||||
<p
|
<p class="flex items-center gap-1 text-[11px] font-medium text-chart-1 uppercase">
|
||||||
class="text-chart-1 flex items-center gap-1 text-[11px] font-medium uppercase"
|
|
||||||
>
|
|
||||||
<CircleDot class="size-3.5 shrink-0" aria-hidden="true" />
|
<CircleDot class="size-3.5 shrink-0" aria-hidden="true" />
|
||||||
Статус
|
Статус
|
||||||
</p>
|
</p>
|
||||||
<Badge class="mt-1" variant={jobStatusVariant(job.status)}>{jobStatusRu(job.status)}</Badge>
|
<Badge class="mt-1" variant={jobStatusVariant(job.status)}
|
||||||
|
>{jobStatusRu(job.status)}</Badge
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="rounded-md border border-chart-2/25 bg-chart-2/5 px-2.5 py-2 dark:bg-chart-2/10"
|
class="rounded-md border border-chart-2/25 bg-chart-2/5 px-2.5 py-2 dark:bg-chart-2/10"
|
||||||
>
|
>
|
||||||
<p
|
<p class="flex items-center gap-1 text-[11px] font-medium text-chart-2 uppercase">
|
||||||
class="text-chart-2 flex items-center gap-1 text-[11px] font-medium uppercase"
|
|
||||||
>
|
|
||||||
<CalendarClock class="size-3.5 shrink-0" aria-hidden="true" />
|
<CalendarClock class="size-3.5 shrink-0" aria-hidden="true" />
|
||||||
Создана
|
Создана
|
||||||
</p>
|
</p>
|
||||||
<p class="mt-1">{formatDate(job.created_at)}</p>
|
<p class="mt-1">{formatDateTime(job.created_at)}</p>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="rounded-md border border-chart-3/25 bg-chart-3/5 px-2.5 py-2 dark:bg-chart-3/10"
|
class="rounded-md border border-chart-3/25 bg-chart-3/5 px-2.5 py-2 dark:bg-chart-3/10"
|
||||||
>
|
>
|
||||||
<p
|
<p class="flex items-center gap-1 text-[11px] font-medium text-chart-3 uppercase">
|
||||||
class="text-chart-3 flex items-center gap-1 text-[11px] font-medium uppercase"
|
|
||||||
>
|
|
||||||
<PlayCircle class="size-3.5 shrink-0" aria-hidden="true" />
|
<PlayCircle class="size-3.5 shrink-0" aria-hidden="true" />
|
||||||
Запущена
|
Запущена
|
||||||
</p>
|
</p>
|
||||||
<p class="mt-1">{formatDate(job.started_at)}</p>
|
<p class="mt-1">{formatDateTime(job.started_at)}</p>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="rounded-md border border-chart-4/25 bg-chart-4/5 px-2.5 py-2 dark:bg-chart-4/10"
|
class="rounded-md border border-chart-4/25 bg-chart-4/5 px-2.5 py-2 dark:bg-chart-4/10"
|
||||||
>
|
>
|
||||||
<p
|
<p class="flex items-center gap-1 text-[11px] font-medium text-chart-4 uppercase">
|
||||||
class="text-chart-4 flex items-center gap-1 text-[11px] font-medium uppercase"
|
|
||||||
>
|
|
||||||
<Flag class="size-3.5 shrink-0" aria-hidden="true" />
|
<Flag class="size-3.5 shrink-0" aria-hidden="true" />
|
||||||
Завершена
|
Завершена
|
||||||
</p>
|
</p>
|
||||||
<p class="mt-1">{formatDate(job.finished_at)}</p>
|
<p class="mt-1">{formatDateTime(job.finished_at)}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -343,7 +337,7 @@
|
|||||||
class="min-w-0 overflow-hidden rounded-md border border-chart-1/30 bg-chart-1/5 p-2.5 dark:bg-chart-1/10"
|
class="min-w-0 overflow-hidden rounded-md border border-chart-1/30 bg-chart-1/5 p-2.5 dark:bg-chart-1/10"
|
||||||
>
|
>
|
||||||
<p
|
<p
|
||||||
class="text-chart-1 flex items-center gap-1 text-[11px] font-medium uppercase"
|
class="flex items-center gap-1 text-[11px] font-medium text-chart-1 uppercase"
|
||||||
>
|
>
|
||||||
<Layers class="size-3.5 shrink-0" aria-hidden="true" />
|
<Layers class="size-3.5 shrink-0" aria-hidden="true" />
|
||||||
Агрегация
|
Агрегация
|
||||||
@@ -354,7 +348,7 @@
|
|||||||
class="min-w-0 overflow-hidden rounded-md border border-chart-2/30 bg-chart-2/5 p-2.5 dark:bg-chart-2/10"
|
class="min-w-0 overflow-hidden rounded-md border border-chart-2/30 bg-chart-2/5 p-2.5 dark:bg-chart-2/10"
|
||||||
>
|
>
|
||||||
<p
|
<p
|
||||||
class="text-chart-2 flex items-center gap-1 text-[11px] font-medium uppercase"
|
class="flex items-center gap-1 text-[11px] font-medium text-chart-2 uppercase"
|
||||||
>
|
>
|
||||||
<Globe class="size-3.5 shrink-0" aria-hidden="true" />
|
<Globe class="size-3.5 shrink-0" aria-hidden="true" />
|
||||||
Домены
|
Домены
|
||||||
@@ -365,7 +359,7 @@
|
|||||||
class="min-w-0 overflow-hidden rounded-md border border-chart-3/30 bg-chart-3/5 p-2.5 dark:bg-chart-3/10"
|
class="min-w-0 overflow-hidden rounded-md border border-chart-3/30 bg-chart-3/5 p-2.5 dark:bg-chart-3/10"
|
||||||
>
|
>
|
||||||
<p
|
<p
|
||||||
class="text-chart-3 flex items-center gap-1 text-[11px] font-medium uppercase"
|
class="flex items-center gap-1 text-[11px] font-medium text-chart-3 uppercase"
|
||||||
>
|
>
|
||||||
<Binary class="size-3.5 shrink-0" aria-hidden="true" />
|
<Binary class="size-3.5 shrink-0" aria-hidden="true" />
|
||||||
ASN
|
ASN
|
||||||
@@ -376,7 +370,7 @@
|
|||||||
class="min-w-0 overflow-hidden rounded-md border border-chart-4/30 bg-chart-4/5 p-2.5 dark:bg-chart-4/10"
|
class="min-w-0 overflow-hidden rounded-md border border-chart-4/30 bg-chart-4/5 p-2.5 dark:bg-chart-4/10"
|
||||||
>
|
>
|
||||||
<p
|
<p
|
||||||
class="text-chart-4 flex items-center gap-1 text-[11px] font-medium uppercase"
|
class="flex items-center gap-1 text-[11px] font-medium text-chart-4 uppercase"
|
||||||
>
|
>
|
||||||
<Link2 class="size-3.5 shrink-0" aria-hidden="true" />
|
<Link2 class="size-3.5 shrink-0" aria-hidden="true" />
|
||||||
CDN / IP-диапазоны
|
CDN / IP-диапазоны
|
||||||
@@ -427,7 +421,9 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="min-w-0 space-y-1.5 rounded-lg border p-3">
|
<div class="min-w-0 space-y-1.5 rounded-lg border p-3">
|
||||||
<p class="text-sm font-medium">IP-диапазоны: итог по статическим диапазонам</p>
|
<p class="text-sm font-medium">
|
||||||
|
IP-диапазоны: итог по статическим диапазонам
|
||||||
|
</p>
|
||||||
<JobReportTableBlock
|
<JobReportTableBlock
|
||||||
rows={jobReport.ipRanges as RowData[]}
|
rows={jobReport.ipRanges as RowData[]}
|
||||||
columns={reportCols}
|
columns={reportCols}
|
||||||
@@ -454,9 +450,14 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="text-muted-foreground py-10 text-center text-sm">
|
{#if jobsLoading}
|
||||||
{jobsLoading ? 'Загрузка…' : 'Нет задач'}
|
<div class="py-8 text-center text-sm text-muted-foreground">Загрузка…</div>
|
||||||
</div>
|
{:else}
|
||||||
|
<EmptyState
|
||||||
|
title="Нет задач"
|
||||||
|
description="Задачи появятся после refresh, apply или rollback."
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { BirdStatus } from '$lib/api/types.js';
|
import type { BirdStatus } from '$lib/api/types.js';
|
||||||
import { Badge } from '$lib/components/ui/badge/index.js';
|
import { Badge } from '$lib/ui/core/badge/index.js';
|
||||||
import { Button } from '$lib/components/ui/button/index.js';
|
import { Button } from '$lib/ui/core/button/index.js';
|
||||||
import { Card } from '$lib/components/ui/card/index.js';
|
import { Card } from '$lib/ui/core/card/index.js';
|
||||||
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
|
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
|
||||||
import Play from '@lucide/svelte/icons/play';
|
import Play from '@lucide/svelte/icons/play';
|
||||||
import RotateCcw from '@lucide/svelte/icons/rotate-ccw';
|
import RotateCcw from '@lucide/svelte/icons/rotate-ccw';
|
||||||
@@ -18,7 +18,9 @@
|
|||||||
onReload: () => void;
|
onReload: () => void;
|
||||||
onRefreshBirdStatus: () => void;
|
onRefreshBirdStatus: () => void;
|
||||||
onOpenBirdProtocols: () => void;
|
onOpenBirdProtocols: () => void;
|
||||||
birdHealthyBadgeVariant: (h: boolean | null | undefined) => 'default' | 'secondary' | 'outline' | 'destructive';
|
birdHealthyBadgeVariant: (
|
||||||
|
h: boolean | null | undefined
|
||||||
|
) => 'default' | 'secondary' | 'outline' | 'destructive';
|
||||||
birdHealthyShortLabel: (h: boolean | null | undefined) => string;
|
birdHealthyShortLabel: (h: boolean | null | undefined) => string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -38,24 +40,28 @@
|
|||||||
|
|
||||||
<div class="grid grid-cols-1 items-stretch gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
<div class="grid grid-cols-1 items-stretch gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||||
<Card
|
<Card
|
||||||
class={cn(
|
class={cn('min-w-0 overflow-hidden border-l-4 border-l-chart-1 bg-chart-1/5 p-4 shadow-sm')}
|
||||||
'min-w-0 overflow-hidden border-l-4 border-l-chart-1 bg-chart-1/5 p-4 shadow-sm'
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div class="flex min-w-0 flex-1 gap-3">
|
<div class="flex min-w-0 flex-1 gap-3">
|
||||||
<div
|
<div
|
||||||
class="bg-chart-1/20 flex size-11 shrink-0 items-center justify-center rounded-xl"
|
class="flex size-11 shrink-0 items-center justify-center rounded-xl bg-chart-1/20"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
<Play class="text-chart-1 size-5" />
|
<Play class="size-5 text-chart-1" />
|
||||||
</div>
|
</div>
|
||||||
<div class="min-w-0 flex-1 space-y-1">
|
<div class="min-w-0 flex-1 space-y-1">
|
||||||
<p class="font-semibold">Применить ко всем спикерам</p>
|
<p class="font-semibold">Применить ко всем спикерам</p>
|
||||||
<p class="text-muted-foreground max-w-[42ch] text-sm">Применить текущую конфигурацию на всех BIRD-спикерах</p>
|
<p class="max-w-[42ch] text-sm text-muted-foreground">
|
||||||
|
Применить текущую конфигурацию на всех BIRD-спикерах
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button class="w-full shrink-0 self-start sm:w-auto sm:self-auto" onclick={onApply} disabled={applying}>
|
<Button
|
||||||
|
class="w-full shrink-0 self-start sm:w-auto sm:self-auto"
|
||||||
|
onclick={onApply}
|
||||||
|
disabled={applying}
|
||||||
|
>
|
||||||
<Play class="size-4" aria-hidden="true" />
|
<Play class="size-4" aria-hidden="true" />
|
||||||
Применить
|
Применить
|
||||||
</Button>
|
</Button>
|
||||||
@@ -63,21 +69,21 @@
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card
|
<Card
|
||||||
class={cn(
|
class={cn('min-w-0 overflow-hidden border-l-4 border-l-chart-4 bg-chart-4/5 p-4 shadow-sm')}
|
||||||
'min-w-0 overflow-hidden border-l-4 border-l-chart-4 bg-chart-4/5 p-4 shadow-sm'
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div class="flex min-w-0 flex-1 gap-3">
|
<div class="flex min-w-0 flex-1 gap-3">
|
||||||
<div
|
<div
|
||||||
class="bg-chart-4/20 flex size-11 shrink-0 items-center justify-center rounded-xl"
|
class="flex size-11 shrink-0 items-center justify-center rounded-xl bg-chart-4/20"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
<RotateCcw class="text-chart-4 size-5" />
|
<RotateCcw class="size-5 text-chart-4" />
|
||||||
</div>
|
</div>
|
||||||
<div class="min-w-0 flex-1 space-y-1">
|
<div class="min-w-0 flex-1 space-y-1">
|
||||||
<p class="font-semibold">Перезагрузка BIRD</p>
|
<p class="font-semibold">Перезагрузка BIRD</p>
|
||||||
<p class="text-muted-foreground max-w-[42ch] text-sm">Перезагрузить конфигурацию BIRD на всех спикерах</p>
|
<p class="max-w-[42ch] text-sm text-muted-foreground">
|
||||||
|
Перезагрузить конфигурацию BIRD на всех спикерах
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
@@ -101,26 +107,37 @@
|
|||||||
<div class="min-w-0 flex-1 space-y-1">
|
<div class="min-w-0 flex-1 space-y-1">
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
<div
|
<div
|
||||||
class="bg-info/15 mr-0.5 flex size-9 items-center justify-center rounded-lg"
|
class="mr-0.5 flex size-9 items-center justify-center rounded-lg bg-info/15"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
<Bird class="text-info size-5 shrink-0" />
|
<Bird class="size-5 shrink-0 text-info" />
|
||||||
</div>
|
</div>
|
||||||
<p class="font-semibold">Состояние BIRD</p>
|
<p class="font-semibold">Состояние BIRD</p>
|
||||||
{#if birdStatus}
|
{#if birdStatus}
|
||||||
<Badge variant={birdHealthyBadgeVariant(birdStatus.healthy)}>{birdHealthyShortLabel(birdStatus.healthy)}</Badge>
|
<Badge
|
||||||
|
variant={birdHealthyBadgeVariant(birdStatus.healthy)}
|
||||||
|
class={birdStatus.healthy === true
|
||||||
|
? 'border-success/30 bg-success/15 text-success'
|
||||||
|
: undefined}
|
||||||
|
>
|
||||||
|
{birdHealthyShortLabel(birdStatus.healthy)}
|
||||||
|
</Badge>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<p class="text-foreground/80 max-w-[56ch] text-sm">
|
<p class="max-w-[56ch] text-sm text-foreground/80">
|
||||||
Локально на хосте API: <code class="bg-muted rounded px-1 text-xs">birdc show protocols</code>. Не заменяет мониторинг спикеров.
|
Локально на хосте API: <code class="rounded bg-muted px-1 text-xs"
|
||||||
|
>birdc show protocols</code
|
||||||
|
>. Не заменяет мониторинг спикеров.
|
||||||
</p>
|
</p>
|
||||||
{#if birdLoading}
|
{#if birdLoading}
|
||||||
<p class="text-foreground/80 text-sm">Загрузка…</p>
|
<p class="text-sm text-foreground/80">Загрузка…</p>
|
||||||
{:else if birdStatus}
|
{:else if birdStatus}
|
||||||
{#if !birdStatus.birdc_configured}
|
{#if !birdStatus.birdc_configured}
|
||||||
<p class="text-foreground/80 text-sm">{birdStatus.message ?? 'birdc не настроен на API.'}</p>
|
<p class="text-sm text-foreground/80">
|
||||||
|
{birdStatus.message ?? 'birdc не настроен на API.'}
|
||||||
|
</p>
|
||||||
{:else if birdStatus.error}
|
{:else if birdStatus.error}
|
||||||
<p class="text-destructive text-sm">{birdStatus.error}</p>
|
<p class="text-sm text-destructive">{birdStatus.error}</p>
|
||||||
{:else}
|
{:else}
|
||||||
<p class="text-sm">
|
<p class="text-sm">
|
||||||
<span class="text-muted-foreground">BGP сессий:</span>
|
<span class="text-muted-foreground">BGP сессий:</span>
|
||||||
@@ -131,7 +148,7 @@
|
|||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
{:else}
|
{:else}
|
||||||
<p class="text-foreground/80 text-sm">Статус не загружен</p>
|
<p class="text-sm text-foreground/80">Статус не загружен</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Loaded 100 of 331 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user