Compare commits
31
Commits
c263fd5c7e
..
v1.5.5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5fca165c69 | ||
|
|
293115e0e1 | ||
|
|
6d2051f813 | ||
|
|
b51a9ae3b3 | ||
|
|
4d4cd2301f | ||
|
|
d687881eaa | ||
|
|
87e756f34f | ||
|
|
27864fad58 | ||
|
|
dc5e777d07 | ||
|
|
23910c3393 | ||
|
|
0e90bbee4e | ||
|
|
55bc87cbc4 | ||
|
|
e68f034966 | ||
|
|
e06ee880b2 | ||
|
|
33fe8fdd18 | ||
|
|
6fa693156d | ||
|
|
5858d0889e | ||
|
|
bfe20c1fe0 | ||
|
|
c66cc9317d | ||
|
|
9ea4a68ccf | ||
|
|
8204105fd6 | ||
|
|
0c11ecfa48 | ||
|
|
ab8660ac42 | ||
|
|
ce747673fd | ||
|
|
e558570967 | ||
|
|
b6b081b04f | ||
|
|
13b4c20148 | ||
|
|
700d415422 | ||
|
|
6ca34b9008 | ||
|
|
bb334b10f5 | ||
|
|
4c23232c4e |
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` (patch, без новых функций)
|
||||
|
||||
Заголовок — 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` | реструктуризация **без** новой возможности и **без** исправления бага | patch |
|
||||
| `docs` | только документация | — |
|
||||
| `test` | тесты | — |
|
||||
| `ci` | CI/CD (`.gitea/`, workflows); правки, из‑за которых нужны новые образы | patch |
|
||||
| `chore` | обслуживание, deps, `.cursor/` | — |
|
||||
|
||||
### Выбор type: semver, а не «красивые слова»
|
||||
|
||||
**Главный вопрос:** что изменится для пользователя после релиза?
|
||||
|
||||
1. Появилось **новое** действие / экран / API / настройка, которых не было → `feat`
|
||||
2. То, что **должно было работать**, не работало (кнопки, диалоги, сохранение, 500) → `fix`
|
||||
3. Только перестройка кода или UI на другой паттерн, поведение для пользователя то же → `refactor` (patch, без новых функций)
|
||||
4. Ускорение без изменения контракта → `perf`
|
||||
|
||||
**Не путать с формулировкой diff:**
|
||||
|
||||
| В diff / задаче часто пишут | Неверный type | Верный type, если… |
|
||||
|-----------------------------|---------------|---------------------|
|
||||
| enhance, improve, polish UI | `feat` | …только чиним сломанное после прошлого PR → `fix` |
|
||||
| refactor pages, unify tables | `feat` | …новой возможности нет, лишь перенос на AppDataTable → `refactor` |
|
||||
| follow-up после feat(web) | `feat` | …исправляем баги того же экрана → `fix` |
|
||||
|
||||
**Follow-up rule:** коммит сразу после `feat` в той же области, который **не добавляет** новую возможность, а устраняет дефект (effect loop, не открывается dialog, confirm не срабатывает) — **`fix`**, не `feat`.
|
||||
|
||||
**Split при смешанном diff:** новая страница/flow → `feat`; отдельным коммитом правки багов → `fix`. Не объединять в один `feat`.
|
||||
|
||||
**Breaking changes** — только `feat!` / `fix!` / `BREAKING CHANGE:` когда пользователь **обязан** менять конфиг, API или привычный workflow.
|
||||
|
||||
### Обязательно в пояснении агенту
|
||||
|
||||
При каждом предложении коммита указать:
|
||||
|
||||
- **Semver impact:** `minor` | `patch` | `none` | `major`
|
||||
- **Почему не другой type** (одно предложение), если diff большой или формулировка двусмысленная
|
||||
|
||||
Пример неправильно / правильно:
|
||||
|
||||
```
|
||||
# Плохо — patch-фикс, minor-bump
|
||||
feat(web): enhance module entry dialogs and selection handling
|
||||
|
||||
# Хорошо
|
||||
fix(web): stop effect loop breaking module action buttons
|
||||
|
||||
Исправлен effect_update_depth_exceeded и bind:open у Dialog; кнопки редактирования/удаления снова работают.
|
||||
```
|
||||
|
||||
```
|
||||
# Плохо — рефакторинг без новой фичи
|
||||
feat(web): migrate modules list to AppDataTable
|
||||
|
||||
# Хорошо — если не было нового user-facing
|
||||
refactor(web): migrate modules list to AppDataTable
|
||||
|
||||
Единый паттерн таблиц; поведение списка модулей без изменений. Semver: patch.
|
||||
```
|
||||
|
||||
```
|
||||
# Хорошо feat — действительно новое
|
||||
feat(web): add module create dialog on /modules
|
||||
|
||||
Диалог создания модуля с POST /v1/modules; раньше создание было только через API.
|
||||
```
|
||||
|
||||
## Breaking changes
|
||||
|
||||
- Заголовок: `feat!` / `fix!` **или** в теле строка `BREAKING CHANGE:` (на английском ключевое слово) + описание impact **на русском**.
|
||||
|
||||
## 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-токене с валидной сессией.
|
||||
```
|
||||
@@ -15,7 +15,7 @@ alwaysApply: true
|
||||
|
||||
**ARCH-01** | MUST | Новая persistence-логика — метод `store.Backend` + реализации в `repository` и `store.Memory`; SQL не в `httpapi`.
|
||||
*Rationale:* единая абстракция данных.
|
||||
*Проверка:* grep SQL в `internal/httpapi` — отсутствие; review.
|
||||
*Проверка:* CI `scripts/lint-httpapi.sh`; grep SQL в `internal/httpapi` — отсутствие.
|
||||
|
||||
**ARCH-02** | MUST | HTTP-маршруты только в `internal/httpapi`; регистрация через `http.ServeMux` с паттернами `METHOD /v1/...`.
|
||||
*Rationale:* один слой REST.
|
||||
@@ -75,7 +75,7 @@ alwaysApply: true
|
||||
|
||||
**STYLE-05** | MUST | HTTP-ошибки — `writeProblem` / `writeJSON` (`application/problem+json` для 4xx/5xx).
|
||||
*Rationale:* RFC 9457, OpenAPI.
|
||||
*Проверка:* `problem.go`.
|
||||
*Проверка:* `problem.go`; CI `scripts/lint-httpapi.sh` (5xx и 4xx store/cdn/csv).
|
||||
|
||||
**STYLE-06** | MUST | JSON полей HTTP DTO согласованы с `docs/openapi.yaml`.
|
||||
*Rationale:* контракт API.
|
||||
@@ -123,7 +123,7 @@ alwaysApply: true
|
||||
**TEST-03** | MUST | Новые BIRD-сценарии в `internal/birdfmt/testdata/scenarios/*/bird.conf` + `bird -p`.
|
||||
*Проверка:* CI job `bird2`.
|
||||
|
||||
**TEST-04** | MUST | Изменения `web/` — локально `npm run check` и `npm run lint` (CI web пока не в scope).
|
||||
**TEST-04** | MUST | Изменения `web/` — локально `npm run check` и `npm run lint`; CI job `web` в `.gitea/workflows/ci.yaml`.
|
||||
*Проверка:* локальные команды.
|
||||
|
||||
**TEST-05** | MUST | Изменения OpenAPI — `npx @redocly/cli lint docs/openapi.yaml`.
|
||||
@@ -234,7 +234,9 @@ npx @redocly/cli lint docs/openapi.yaml
|
||||
# birdfmt: go test ./internal/birdfmt/... -count=1
|
||||
```
|
||||
|
||||
**Рекомендуется (частично внедрено):** CI job `web` (Gitea); `scripts/lint-httpapi.sh` в job `go`; `.golangci.yml` (локально); pre-commit gofmt/prettier.
|
||||
**CI (Gitea):** job `web` (check + lint); job `go`: `go vet`, `scripts/lint-httpapi.sh` (ARCH-01, ERR-01), `scripts/check-migrations-pair.sh` (DEP-03), `golangci-lint`, `go test -race`, build `cmd/*`.
|
||||
|
||||
**Рекомендуется локально:** `.golangci.yml`; `.pre-commit-config.yaml` (gofmt + prettier web).
|
||||
|
||||
**Только code review:** слои SQL; роли; idempotency; OpenAPI bodies; secrets в compose.
|
||||
|
||||
|
||||
@@ -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` (patch) |
|
||||
|
||||
**Follow-up:** правки сразу после `feat` в том же scope без новой возможности → **`fix`**, не `feat` (слова *enhance/improve/refactor* в задаче не делают commit `feat`).
|
||||
|
||||
**Запрещено** по умолчанию ставить `feat` для «большого diff» в `web/` — type по **semver impact**, не по объёму.
|
||||
|
||||
### Шаг B — type, scope, текст
|
||||
|
||||
- **type** — результат шага A, не «chore по умолчанию» и не `feat` из-за слова enhance.
|
||||
- **scope** — из JSON группы или доминирующий при merge.
|
||||
- **summary** — конкретный, английский, императив; для `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
|
||||
|
||||
В репозитории включён 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).
|
||||
- **`code`** — в диффе есть **хотя бы один файл вне «только документация»** → запускаются **go**, **bird2** и при push в main/master **docker-images**.
|
||||
Подробнее: [docs/releasing.md](../docs/releasing.md).
|
||||
|
||||
Если изменены **только** файлы из списка ниже, **`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. Полный прогон (все узлы openapi / web / go / bird2 в графе): `.gitea/workflows/*`, `scripts/*`, `.golangci.yml`, `.pre-commit-config.yaml`, корневой `package.json` / `.releaserc.json`. Отдельно: `migrations/*`, `docs/openapi.yaml` → `go` / `openapi` и т.д. (см. `ci.yaml`).
|
||||
|
||||
Любой другой путь (в т.ч. `.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).
|
||||
- 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’а.
|
||||
## Release (job в ci.yaml)
|
||||
|
||||
## Сборка и публикация образов (`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
|
||||
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
|
||||
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) (раздел «Готовые образы без сборки»).
|
||||
|
||||
Убедитесь, что в **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), чтобы не публиковать образы из чужих форков без явной политики.
|
||||
См. [deploy/docker/README.md](../deploy/docker/README.md), [docs/quickstart.md](../docs/quickstart.md).
|
||||
+142
-128
@@ -8,10 +8,9 @@ on:
|
||||
|
||||
jobs:
|
||||
# ---------------------------------------------------------------------------
|
||||
# Гранулярная детекция изменений по модулям.
|
||||
# Каждый флаг соответствует группе файлов; downstream-джобы запускаются
|
||||
# только когда их группа затронута. Изменение CI-конфигурации (.gitea/workflows/*)
|
||||
# поднимает все флаги, чтобы гарантировать полный прогон.
|
||||
# Детекция изменений по модулям (флаги → downstream-джобы в графе CI).
|
||||
# Полный прогон (все флаги true): .gitea/workflows/*, scripts/*, .golangci.yml,
|
||||
# .pre-commit-config.yaml — чтобы при правках CI/CD пересобирались все узлы.
|
||||
# ---------------------------------------------------------------------------
|
||||
changes:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -40,6 +39,23 @@ jobs:
|
||||
docker_web=false
|
||||
docker_bird=false
|
||||
|
||||
# Все флаги true → openapi, web, go, bird2 (и release на main) в графе CI.
|
||||
set_all_flags_true() {
|
||||
openapi=true
|
||||
go=true
|
||||
web=true
|
||||
bird_conf=true
|
||||
docker_go=true
|
||||
docker_web=true
|
||||
docker_bird=true
|
||||
}
|
||||
|
||||
write_outputs() {
|
||||
for v in openapi go web bird_conf docker_go docker_web docker_bird; do
|
||||
eval "echo \"\$v=\$$v\"" >> "$GITHUB_OUTPUT"
|
||||
done
|
||||
}
|
||||
|
||||
if [ "${{ github.event_name }}" = "pull_request" ]; then
|
||||
base="${{ github.event.pull_request.base.sha }}"
|
||||
head="${{ github.event.pull_request.head.sha }}"
|
||||
@@ -52,61 +68,84 @@ jobs:
|
||||
elif git rev-parse --verify HEAD~1 >/dev/null 2>&1; then
|
||||
FILES="$(git diff --name-only HEAD~1 HEAD)"
|
||||
else
|
||||
openapi=true; go=true; web=true; bird_conf=true
|
||||
docker_go=true; docker_web=true; docker_bird=true
|
||||
for v in openapi go web bird_conf docker_go docker_web docker_bird; do
|
||||
echo "$v=true" >> "$GITHUB_OUTPUT"
|
||||
done
|
||||
echo "No parent commit — full pipeline"
|
||||
set_all_flags_true
|
||||
write_outputs
|
||||
echo "No parent commit — full pipeline (all modules)"
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -z "$(printf '%s' "$FILES" | tr -d '[:space:]')" ]; then
|
||||
go=true; web=true
|
||||
for v in openapi go web bird_conf docker_go docker_web docker_bird; do
|
||||
eval "echo \"\$v=\$$v\"" >> "$GITHUB_OUTPUT"
|
||||
done
|
||||
echo "Empty diff — safe fallback: go=true web=true"
|
||||
set_all_flags_true
|
||||
write_outputs
|
||||
echo "Empty diff — full pipeline fallback"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
ci_changed=false
|
||||
full_pipeline=false
|
||||
|
||||
while IFS= read -r f || [ -n "${f:-}" ]; do
|
||||
[ -z "${f:-}" ] && continue
|
||||
case "$f" in
|
||||
.gitea/workflows/*) ci_changed=true ;;
|
||||
docs/openapi.yaml|redocly.yaml) openapi=true ;;
|
||||
web/README.md) ;; # doc-only
|
||||
web/*) web=true ;;
|
||||
deploy/bird/*) bird_conf=true ;;
|
||||
deploy/docker/bird/*) docker_bird=true; docker_go=true ;;
|
||||
deploy/docker/gobinary/*) docker_go=true ;;
|
||||
deploy/docker/docker-bake.hcl) docker_go=true; docker_web=true ;;
|
||||
deploy/docker/evobgp-agent/*) docker_go=true ;;
|
||||
deploy/docker/evobgp-web/*) docker_web=true ;;
|
||||
deploy/docker/bird2/*) docker_bird=true ;;
|
||||
go.mod|go.sum|go.work) go=true ;;
|
||||
*.go) go=true ;;
|
||||
cmd/*|internal/*) go=true ;;
|
||||
# CI/CD инфраструктура — все узлы quality gates
|
||||
.gitea/workflows/*|.golangci.yml|.pre-commit-config.yaml|scripts/*)
|
||||
full_pipeline=true
|
||||
;;
|
||||
docs/openapi.yaml|redocly.yaml)
|
||||
openapi=true
|
||||
;;
|
||||
docs/api.md|docs/access.md)
|
||||
openapi=true
|
||||
go=true
|
||||
;;
|
||||
web/README.md|web/components.json)
|
||||
;;
|
||||
web/*)
|
||||
web=true
|
||||
;;
|
||||
deploy/bird/*)
|
||||
bird_conf=true
|
||||
go=true
|
||||
;;
|
||||
deploy/compose/*|deploy/docker/*)
|
||||
docker_go=true
|
||||
docker_web=true
|
||||
docker_bird=true
|
||||
go=true
|
||||
;;
|
||||
go.mod|go.sum|go.work)
|
||||
go=true
|
||||
;;
|
||||
migrations/*)
|
||||
go=true
|
||||
;;
|
||||
cmd/*|internal/*|*.go)
|
||||
go=true
|
||||
bird_conf=true
|
||||
;;
|
||||
docs/*)
|
||||
go=true
|
||||
;;
|
||||
package.json|package-lock.json|.releaserc.json)
|
||||
full_pipeline=true
|
||||
;;
|
||||
*)
|
||||
go=true
|
||||
;;
|
||||
esac
|
||||
done <<< "$FILES"
|
||||
|
||||
if $ci_changed; then
|
||||
go=true; web=true; bird_conf=true
|
||||
docker_go=true; docker_web=true; docker_bird=true
|
||||
if $full_pipeline; then
|
||||
set_all_flags_true
|
||||
fi
|
||||
|
||||
for v in openapi go web bird_conf docker_go docker_web docker_bird; do
|
||||
eval "echo \"\$v=\$$v\"" >> "$GITHUB_OUTPUT"
|
||||
done
|
||||
write_outputs
|
||||
|
||||
echo "Changed files (first 30):"
|
||||
printf '%s\n' "$FILES" | head -n 30
|
||||
echo "--- flags ---"
|
||||
echo "openapi=$openapi go=$go web=$web bird_conf=$bird_conf"
|
||||
echo "docker_go=$docker_go docker_web=$docker_web docker_bird=$docker_bird ci=$ci_changed"
|
||||
echo "docker_go=$docker_go docker_web=$docker_web docker_bird=$docker_bird full_pipeline=$full_pipeline"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
openapi:
|
||||
@@ -157,6 +196,14 @@ jobs:
|
||||
run: go vet ./...
|
||||
- name: Lint httpapi (ERR-01 / ARCH-01)
|
||||
run: sh scripts/lint-httpapi.sh
|
||||
- name: Check migration pairs (DEP-03)
|
||||
run: sh scripts/check-migrations-pair.sh
|
||||
# go.mod: go 1.24 — бинарник golangci-lint < v1.64.2 (сборка на Go 1.23) не запускается.
|
||||
- name: golangci-lint
|
||||
uses: golangci/golangci-lint-action@v6
|
||||
with:
|
||||
version: v1.64.8
|
||||
install-mode: goinstall
|
||||
- name: Test
|
||||
run: go test ./... -race -count=1
|
||||
- name: Build all commands
|
||||
@@ -208,88 +255,99 @@ jobs:
|
||||
done
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Docker: все Go-образы одним buildx bake (один go mod download, одна компиляция cmd/*,
|
||||
# birdc собирается один раз для api/all). Registry cache: evobgp-buildcache:go-buildcache.
|
||||
# ---------------------------------------------------------------------------
|
||||
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')
|
||||
commitlint:
|
||||
if: github.event_name == 'pull_request'
|
||||
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 Go images (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: git.shts.su/${{ steps.meta.outputs.owner_lc }}/evobgp-buildcache:go-buildcache
|
||||
CACHE_REF_WEB: ""
|
||||
BUILDX_BAKE_ENTITLEMENTS_FS: "0"
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: npm
|
||||
cache-dependency-path: package-lock.json
|
||||
- name: Lint commit messages
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
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 go-images --push
|
||||
npm ci
|
||||
npx commitlint --from "${{ github.event.pull_request.base.sha }}" --to "${{ github.event.pull_request.head.sha }}"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Docker: Web (evobgp-web, evobgp-web-all) — один npm ci (кэш) + два nginx-тега.
|
||||
# Один push в main: semantic-release (тег на текущий commit, без доп. commit) + docker push.
|
||||
# ---------------------------------------------------------------------------
|
||||
docker-web:
|
||||
needs: [changes]
|
||||
release:
|
||||
needs: [changes, openapi, web, go, bird2]
|
||||
if: >-
|
||||
always() &&
|
||||
github.event_name == 'push' &&
|
||||
(github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master') &&
|
||||
(needs.changes.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
|
||||
steps:
|
||||
- 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
|
||||
if: steps.rel.outputs.released == 'true'
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Prepare image metadata
|
||||
if: steps.rel.outputs.released == 'true'
|
||||
id: meta
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "version=${{ steps.rel.outputs.version }}" >> "$GITHUB_OUTPUT"
|
||||
owner_lc="$(echo '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]')"
|
||||
echo "owner_lc=$owner_lc" >> "$GITHUB_OUTPUT"
|
||||
short_sha="$(echo '${{ github.sha }}' | cut -c1-7)"
|
||||
echo "short_sha=$short_sha" >> "$GITHUB_OUTPUT"
|
||||
echo "build_time=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT"
|
||||
- name: Log in to Gitea Registry
|
||||
if: steps.rel.outputs.released == 'true'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: git.shts.su
|
||||
username: ${{ gitea.actor }}
|
||||
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:
|
||||
REGISTRY: git.shts.su/${{ steps.meta.outputs.owner_lc }}
|
||||
IMAGE_TAG: latest
|
||||
VERSION: ${{ steps.meta.outputs.version }}
|
||||
SHORT_SHA: ${{ steps.meta.outputs.short_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
|
||||
BUILDX_BAKE_ENTITLEMENTS_FS: "0"
|
||||
run: |
|
||||
@@ -297,49 +355,5 @@ jobs:
|
||||
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 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
|
||||
@@ -2,6 +2,7 @@ run:
|
||||
timeout: 5m
|
||||
|
||||
linters:
|
||||
disable-all: true
|
||||
enable:
|
||||
- gofmt
|
||||
- govet
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
# Local hooks (optional): install with `pre-commit install`
|
||||
repos:
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v5.0.0
|
||||
hooks:
|
||||
- id: trailing-whitespace
|
||||
- id: end-of-file-fixer
|
||||
- repo: https://github.com/dnephin/pre-commit-golang
|
||||
rev: v0.5.1
|
||||
hooks:
|
||||
- id: go-fmt
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: prettier-web
|
||||
name: prettier (web)
|
||||
entry: bash -c 'cd web && npx prettier --check .'
|
||||
language: system
|
||||
files: ^web/
|
||||
pass_filenames: false
|
||||
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"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" },
|
||||
{ "type": "ci", "release": "patch" },
|
||||
{ "type": "refactor", "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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
@@ -36,6 +36,17 @@
|
||||
- **Повторное использование:** если [docs/architecture.md](docs/architecture.md) уже описывает поток — не пересказывайте его длинно; укажите документ и конкретный подпункт задачи.
|
||||
- **Длинные планы:** `.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`.
|
||||
|
||||
+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/`.
|
||||
- **Веб:** 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/render"
|
||||
"evobgp/internal/scheduler"
|
||||
"evobgp/internal/version"
|
||||
)
|
||||
|
||||
// microVPS entrypoint: один процесс — HTTP API и фоновые воркеры scheduler, ingest, render, deploy (общий store и jobs.Registry).
|
||||
@@ -37,7 +38,7 @@ func main() {
|
||||
log.Fatal(err)
|
||||
}
|
||||
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)
|
||||
defer stop()
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"evobgp/internal/httpapi"
|
||||
"evobgp/internal/observability"
|
||||
"evobgp/internal/platform"
|
||||
"evobgp/internal/version"
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -34,7 +35,7 @@ func main() {
|
||||
}
|
||||
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)
|
||||
defer stop()
|
||||
|
||||
|
||||
@@ -96,7 +96,7 @@ services:
|
||||
<<: *env-ref
|
||||
EVOBGP_HTTP_ADDR: ":8080"
|
||||
EVOBGP_SEED_DEMO: "1"
|
||||
# Local reference only: allows Bearer dev for scheduler HTTP client (EVOBGP_SCHEDULER_BEARER).
|
||||
# DEV ONLY — не для production. Bearer dev + слабые demo-секреты (см. docs/access.md).
|
||||
EVOBGP_DEV_INSECURE: "1"
|
||||
EVOBGP_BIRDC_SOCKET: /run/bird/bird.ctl
|
||||
EVOBGP_BIRDC_INTERVAL: 30s
|
||||
|
||||
@@ -135,6 +135,7 @@ services:
|
||||
EVOBGP_BIRDC_INTERVAL: 30s
|
||||
EVOBGP_BIRD_ACTIVE_DIR: /etc/bird
|
||||
EVOBGP_BIRD_STAGING_DIR: /tmp/evobgp-bird-staging
|
||||
# DEV ONLY — не для production (см. docs/access.md).
|
||||
EVOBGP_DEV_INSECURE: "1"
|
||||
volumes:
|
||||
- bird_etc:/etc/bird
|
||||
|
||||
@@ -2,7 +2,11 @@
|
||||
|
||||
## 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`.
|
||||
|
||||
| Было | Стало |
|
||||
|------|--------|
|
||||
@@ -31,6 +35,8 @@ cd deploy/docker
|
||||
export REGISTRY=git.shts.su/<owner>
|
||||
export IMAGE_TAG=latest
|
||||
export SHORT_SHA=$(git rev-parse --short HEAD)
|
||||
export VERSION=dev
|
||||
export BUILD_TIME=
|
||||
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 web-images
|
||||
|
||||
@@ -19,6 +19,14 @@ variable "SHA_FULL" {
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "VERSION" {
|
||||
default = "dev"
|
||||
}
|
||||
|
||||
variable "BUILD_TIME" {
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "CACHE_REF_GO" {
|
||||
default = ""
|
||||
}
|
||||
@@ -87,6 +95,11 @@ target "go-build-all" {
|
||||
dockerfile = "deploy/docker/gobinary/Dockerfile"
|
||||
target = "build-all"
|
||||
platforms = ["linux/amd64"]
|
||||
args = {
|
||||
VERSION = VERSION
|
||||
GIT_SHA = notequal("", SHA_FULL) ? SHA_FULL : SHORT_SHA
|
||||
BUILD_TIME = BUILD_TIME
|
||||
}
|
||||
contexts = {
|
||||
deps = "target:go-deps"
|
||||
}
|
||||
@@ -127,14 +140,20 @@ target "_go-runtime-birdc" {
|
||||
|
||||
function "image-tags" {
|
||||
params = [name]
|
||||
result = notequal("", SHA_FULL) ? [
|
||||
"${REGISTRY}/${name}:${IMAGE_TAG}",
|
||||
"${REGISTRY}/${name}:${SHORT_SHA}",
|
||||
"${REGISTRY}/${name}:sha-${SHA_FULL}",
|
||||
] : [
|
||||
"${REGISTRY}/${name}:${IMAGE_TAG}",
|
||||
"${REGISTRY}/${name}:${SHORT_SHA}",
|
||||
]
|
||||
result = concat(
|
||||
notequal("", SHA_FULL) ? [
|
||||
"${REGISTRY}/${name}:${IMAGE_TAG}",
|
||||
"${REGISTRY}/${name}:${SHORT_SHA}",
|
||||
"${REGISTRY}/${name}:sha-${SHA_FULL}",
|
||||
] : [
|
||||
"${REGISTRY}/${name}:${IMAGE_TAG}",
|
||||
"${REGISTRY}/${name}:${SHORT_SHA}",
|
||||
],
|
||||
notequal(VERSION, "dev") && notequal(VERSION, "") ? [
|
||||
"${REGISTRY}/${name}:v${VERSION}",
|
||||
"${REGISTRY}/${name}:${VERSION}",
|
||||
] : []
|
||||
)
|
||||
}
|
||||
|
||||
target "evobgp-api" {
|
||||
|
||||
@@ -5,8 +5,14 @@ server {
|
||||
gzip on;
|
||||
gzip_types text/css application/javascript application/json image/svg+xml;
|
||||
|
||||
# Docker embedded DNS: без resolver nginx кэширует IP upstream при старте —
|
||||
# после recreate evobgp-all остаётся 502 (connection refused на старый IP).
|
||||
resolver 127.0.0.11 valid=10s ipv6=off;
|
||||
set $evobgp_upstream evobgp-api;
|
||||
|
||||
location /v1/ {
|
||||
proxy_pass http://evobgp-api:8080/v1/;
|
||||
# С переменной в proxy_pass нельзя полагаться на замену URI — передаём $request_uri целиком.
|
||||
proxy_pass http://$evobgp_upstream:8080$request_uri;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
@@ -15,7 +21,7 @@ server {
|
||||
}
|
||||
|
||||
location = /metrics {
|
||||
proxy_pass http://evobgp-api:8080/metrics;
|
||||
proxy_pass http://$evobgp_upstream:8080/metrics;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
|
||||
@@ -10,22 +10,38 @@ RUN --mount=type=cache,target=/go/pkg/mod,sharing=locked \
|
||||
|
||||
FROM deps AS build-all
|
||||
COPY . .
|
||||
ARG VERSION=dev
|
||||
ARG GIT_SHA=unknown
|
||||
ARG BUILD_TIME=
|
||||
RUN --mount=type=cache,target=/go/pkg/mod,sharing=locked \
|
||||
--mount=type=cache,target=/root/.cache/go-build,sharing=locked \
|
||||
set -eux; \
|
||||
mkdir -p /out; \
|
||||
for d in cmd/*/; do \
|
||||
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
|
||||
|
||||
# Один бинарь (локальная сборка); в CI — build-all + runtime.
|
||||
FROM deps AS build
|
||||
COPY . .
|
||||
ARG BIN=evobgp-api
|
||||
ARG VERSION=dev
|
||||
ARG GIT_SHA=unknown
|
||||
ARG BUILD_TIME=
|
||||
RUN --mount=type=cache,target=/go/pkg/mod,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
|
||||
ARG BIRD_VERSION=2.14
|
||||
|
||||
@@ -6,6 +6,8 @@ REGISTRY="${REGISTRY:?REGISTRY required}"
|
||||
IMAGE_TAG="${IMAGE_TAG:-latest}"
|
||||
SHORT_SHA="${SHORT_SHA:-dev}"
|
||||
SHA_FULL="${SHA_FULL:-}"
|
||||
VERSION="${VERSION:-dev}"
|
||||
BUILD_TIME="${BUILD_TIME:-}"
|
||||
CACHE_REF_GO="${CACHE_REF_GO:-}"
|
||||
CACHE_REF_WEB="${CACHE_REF_WEB:-}"
|
||||
cat >"$OUT" <<EOF
|
||||
@@ -22,6 +24,12 @@ variable "SHORT_SHA" {
|
||||
variable "SHA_FULL" {
|
||||
default = "${SHA_FULL}"
|
||||
}
|
||||
variable "VERSION" {
|
||||
default = "${VERSION}"
|
||||
}
|
||||
variable "BUILD_TIME" {
|
||||
default = "${BUILD_TIME}"
|
||||
}
|
||||
variable "CACHE_REF_GO" {
|
||||
default = "${CACHE_REF_GO}"
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
| [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`) |
|
||||
| [access.md](access.md) | Выдача доступа: API-ключи, роли, нода, CORS |
|
||||
| [releasing.md](releasing.md) | Автоматические релизы, Conventional Commits, CI |
|
||||
| [openapi.yaml](openapi.yaml) | Источник правды по контракту API |
|
||||
| [OPENAPI-GITEA.md](OPENAPI-GITEA.md) | Как открыть HTML-документацию API (в т.ч. из Gitea) |
|
||||
| [evobgp-api-sketches.md](evobgp-api-sketches.md) | Ранний черновик идей API (контекст, не замена OpenAPI) |
|
||||
@@ -31,7 +32,22 @@
|
||||
| [../.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
|
||||
|
||||
- [../.gitea/README.md](../.gitea/README.md) — Gitea Actions, runner, сборка образов и push в Container Registry.
|
||||
- [releasing.md](releasing.md) — semantic-release, commit conventions, теги образов.
|
||||
+6
-1
@@ -37,7 +37,12 @@ opkey|01ARZ3NDEKTSV4RRFFQ69G5FAV|operator,nodekey|01ARZ3NDEKTSV4RRFFQ69G5FAV|nod
|
||||
|
||||
Если установлено `EVOBGP_DEV_INSECURE=1` и в store доступен демо-tenant (`DemoIDs`), то запрос с заголовком **`Authorization: Bearer dev`** получает контекст **`operator`** для этого tenant.
|
||||
|
||||
**Запрещено** в продакшене: любой, кто знает заголовок, получает полные права оператора на демо-данные.
|
||||
**Запрещено** в продакшене: любой, кто знает заголовок, получает полные права оператора на демо-данные. В reference Compose (`deploy/compose/docker-compose.yaml`) флаг включён только для локальной разработки.
|
||||
|
||||
### Синхронные «тяжёлые» GET (control plane)
|
||||
|
||||
- `POST /v1/modules/{module_id}/cdn-sources/preview` — загрузка CDN в том же HTTP-запросе (лимит тела ~8 MiB, см. OpenAPI).
|
||||
- `GET /v1/bird/status` (если маршрут включён в деплое) — опрос локального `birdc`, таймаут сервера ~12 с.
|
||||
|
||||
### Детерминированный ключ подписи бандлов (тесты)
|
||||
|
||||
|
||||
+36
-4
@@ -1,10 +1,12 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: EvoBGP Control Plane API
|
||||
version: 0.1.0
|
||||
version: 1.0.0
|
||||
description: |
|
||||
REST API управления префиксами, модулями ingest, ревизиями конфигурации BIRD и задачами (async jobs).
|
||||
|
||||
**Актуальная semver-сборка:** `GET /version` или `GET /v1/version` (поле `version`; совпадает с git-тегом `vX.Y.Z`).
|
||||
|
||||
**Соглашения:** префикс `/v1`; идентификаторы - UUID v7 или ULID (строки); время - ISO 8601 UTC.
|
||||
Ошибки - `application/problem+json` ([RFC 9457](https://www.rfc-editor.org/rfc/rfc9457)).
|
||||
Пагинация списков - `cursor` + `limit`; ответ содержит `items`, `next_cursor`, `has_more`.
|
||||
@@ -65,7 +67,9 @@ components:
|
||||
required: false
|
||||
schema:
|
||||
$ref: "#/components/schemas/ResourceId"
|
||||
description: Явный tenant (только супер-роли). Без заголовка tenant определяется по ключу.
|
||||
description: >
|
||||
Явный tenant (только супер-роли). Без заголовка tenant определяется по API-ключу.
|
||||
**Реализация v1:** заголовок в Go handlers не обрабатывается; tenant только из Bearer-токена (см. docs/access.md).
|
||||
IdempotencyKey:
|
||||
name: Idempotency-Key
|
||||
in: header
|
||||
@@ -758,8 +762,14 @@ components:
|
||||
VersionInfo:
|
||||
type: object
|
||||
properties:
|
||||
version:
|
||||
type: string
|
||||
description: Semver сборки (git tag без префикса v).
|
||||
example: "1.2.3"
|
||||
api_version:
|
||||
type: string
|
||||
deprecated: true
|
||||
description: Alias поля `version` (сохранён для обратной совместимости).
|
||||
git_sha:
|
||||
type: string
|
||||
build_time:
|
||||
@@ -967,11 +977,31 @@ paths:
|
||||
default:
|
||||
$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:
|
||||
get:
|
||||
tags: [System]
|
||||
summary: Версия сборки
|
||||
description: Версия API и control-plane (`git_sha`, `build_time` и др.).
|
||||
description: |
|
||||
Semver control-plane и метаданные сборки (`git_sha`, `build_time`).
|
||||
Дублирует `GET /version`.
|
||||
operationId: getVersion
|
||||
responses:
|
||||
"200":
|
||||
@@ -1783,7 +1813,9 @@ paths:
|
||||
tags: [Modules]
|
||||
summary: Предпросмотр префиксов из CDN-источника
|
||||
description: >
|
||||
Загружает URL, парсит как plaintext или json и возвращает список извлечённых префиксов (до 100 записей).
|
||||
Синхронный запрос: conditional GET к URL (до 8 MiB тела ответа), парсинг plaintext или JSON,
|
||||
возврат до 100 префиксов в `items` (полный счётчик в `total`). Выполняется в HTTP worker;
|
||||
при таймауте клиента используйте короткий URL или меньший payload.
|
||||
operationId: previewCdnSource
|
||||
requestBody:
|
||||
required: true
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
# Релизы и версионирование 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`, `ci`, `refactor` | patch (1.5.1 → 1.5.2) |
|
||||
| `feat!`, `fix!` или `BREAKING CHANGE:` в теле | major (1.0.0 → 2.0.0) |
|
||||
| `docs`, `chore`, `test` | без релиза |
|
||||
|
||||
`refactor` — patch без новых функций: перестройка кода/UI при том же поведении для пользователя. По semver на одном уровне с `fix`, но семантически «мельче» `feat` (не minor).
|
||||
|
||||
Отдельного суффикса `1.x.y.fix` в semver нет: «fix» в Conventional Commits означает **patch** (третья цифра). Для починки пайплайна без смены продукта — `fix(ci):` или `ci:` (оба дают patch после настройки `.releaserc.json`).
|
||||
|
||||
Первый релиз при отсутствии git-тегов — **1.0.0**, если есть releasable-коммиты.
|
||||
|
||||
**Как не перепутать `feat` и `fix`:** см. раздел «Выбор type: semver, а не «красивые слова»» в [.cursor/rules/conventional-commits.mdc](../.cursor/rules/conventional-commits.mdc). Кратко: `feat` — новая возможность (minor); `fix` — починка ожидаемого поведения (patch); follow-up баги после недавнего `feat` — всегда `fix`, даже если diff большой.
|
||||
|
||||
Подробные правила сообщений коммитов: [.cursor/rules/conventional-commits.mdc](../.cursor/rules/conventional-commits.mdc).
|
||||
|
||||
## CI-пайплайн (один push в main)
|
||||
|
||||
```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.
|
||||
@@ -42,7 +42,7 @@ func AnnouncedPrefixes(ctx context.Context, hc *http.Client, asn int64) ([]netip
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ripestat fetch AS%d: %w", asn, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 32<<20))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -52,8 +52,8 @@ func AnnouncedPrefixes(ctx context.Context, hc *http.Client, asn int64) ([]netip
|
||||
}
|
||||
|
||||
var wrap struct {
|
||||
Status string `json:"status"`
|
||||
Data struct {
|
||||
Status string `json:"status"`
|
||||
Data struct {
|
||||
Prefixes []struct {
|
||||
Prefix string `json:"prefix"`
|
||||
} `json:"prefixes"`
|
||||
@@ -104,7 +104,7 @@ func ASHolderName(ctx context.Context, hc *http.Client, asn int64) (string, erro
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("ripestat as-overview AS%d: %w", asn, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
|
||||
if err != nil {
|
||||
return "", err
|
||||
|
||||
@@ -60,10 +60,10 @@ func RenderBGPTemplates(opts BGPTemplatesOptions) (string, error) {
|
||||
|
||||
// BGPPeerFromTemplateOptions describes protocol bgp NAME from TEMPLATE { … }.
|
||||
type BGPPeerFromTemplateOptions struct {
|
||||
ProtocolName string
|
||||
TemplateName string
|
||||
NeighborIP string
|
||||
NeighborASN uint32
|
||||
ProtocolName string
|
||||
TemplateName string
|
||||
NeighborIP string
|
||||
NeighborASN uint32
|
||||
// If set, emits "local … as …" before neighbor (overrides template local/ASN for this peer).
|
||||
OverrideLocalIP string
|
||||
OverrideLocalASN uint32
|
||||
|
||||
@@ -17,12 +17,12 @@ import (
|
||||
|
||||
// Manifest describes bundle contents for evobgp-node verification.
|
||||
type Manifest struct {
|
||||
RevisionID string `json:"revision_id"`
|
||||
SpeakerID string `json:"speaker_id,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
Files []FileEntry `json:"files"`
|
||||
Algorithm string `json:"signature_algorithm"`
|
||||
PublicKeyB64 string `json:"public_key_base64"`
|
||||
RevisionID string `json:"revision_id"`
|
||||
SpeakerID string `json:"speaker_id,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
Files []FileEntry `json:"files"`
|
||||
Algorithm string `json:"signature_algorithm"`
|
||||
PublicKeyB64 string `json:"public_key_base64"`
|
||||
}
|
||||
|
||||
// FileEntry is one file inside the bundle archive.
|
||||
|
||||
@@ -27,7 +27,7 @@ func VerifyGzippedTar(bundle []byte, pub ed25519.PublicKey) (*VerifiedContents,
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer gr.Close()
|
||||
defer func() { _ = gr.Close() }()
|
||||
|
||||
var manifestRaw []byte
|
||||
var sig []byte
|
||||
|
||||
@@ -3,6 +3,8 @@ package httpapi
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"evobgp/internal/store"
|
||||
)
|
||||
|
||||
func parseListLimit(r *http.Request) int {
|
||||
@@ -22,3 +24,15 @@ func strPtrOrNull(s string) any {
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// writePaginatedListJSON returns a cursor/limit page as OpenAPI list envelopes (items, next_cursor, has_more).
|
||||
func writePaginatedListJSON[T any](w http.ResponseWriter, r *http.Request, all []T, toItem func(T) map[string]any) {
|
||||
page, next, more := store.PaginateOffset(all, r.URL.Query().Get("cursor"), parseListLimit(r))
|
||||
items := make([]map[string]any, 0, len(page))
|
||||
for _, x := range page {
|
||||
items = append(items, toItem(x))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"items": items, "next_cursor": strPtrOrNull(next), "has_more": more,
|
||||
})
|
||||
}
|
||||
@@ -9,15 +9,19 @@ import (
|
||||
const (
|
||||
internalErrorDetail = "an internal error occurred"
|
||||
badGatewayDetail = "upstream request failed"
|
||||
notFoundDetail = "resource not found"
|
||||
invalidInputDetail = "invalid request data"
|
||||
cdnExtractDetail = "could not extract prefixes from source"
|
||||
csvInvalidRowDetail = "invalid row in csv file"
|
||||
)
|
||||
|
||||
// Problem is RFC 9457 application/problem+json.
|
||||
type Problem struct {
|
||||
Type string `json:"type,omitempty"`
|
||||
Title string `json:"title"`
|
||||
Status int `json:"status"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
Instance string `json:"instance,omitempty"`
|
||||
Type string `json:"type,omitempty"`
|
||||
Title string `json:"title"`
|
||||
Status int `json:"status"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
Instance string `json:"instance,omitempty"`
|
||||
}
|
||||
|
||||
func writeProblem(w http.ResponseWriter, status int, title, detail string) {
|
||||
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
"evobgp/internal/pipeline"
|
||||
"evobgp/internal/reports"
|
||||
"evobgp/internal/store"
|
||||
"evobgp/internal/version"
|
||||
)
|
||||
|
||||
// Handler returns the root HTTP handler (system routes public; rest under /v1/ authenticated).
|
||||
@@ -30,6 +31,7 @@ func (s *Server) Handler() http.Handler {
|
||||
wrappedV1 := http.StripPrefix("/v1", v1)
|
||||
|
||||
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/ready", s.handleReady)
|
||||
s.mux.HandleFunc("GET /v1/version", s.handleVersion)
|
||||
@@ -82,12 +84,15 @@ func (s *Server) handleReady(w http.ResponseWriter, r *http.Request) {
|
||||
checks := map[string]string{"store": "ok", "jobs": "memory"}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
|
||||
defer cancel()
|
||||
if s.pgPool != nil {
|
||||
if err := s.pgPool.Ping(ctx); err != nil {
|
||||
if err := s.store.Ping(ctx); err != nil {
|
||||
checks["store"] = "unavailable"
|
||||
if s.pgPool != nil {
|
||||
checks["postgres"] = "unavailable"
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]any{"status": "not_ready", "checks": checks})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]any{"status": "not_ready", "checks": checks})
|
||||
return
|
||||
}
|
||||
if s.pgPool != nil {
|
||||
checks["postgres"] = "ok"
|
||||
} else {
|
||||
checks["store_backend"] = "memory"
|
||||
@@ -96,11 +101,16 @@ func (s *Server) handleReady(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (s *Server) handleVersion(w http.ResponseWriter, r *http.Request) {
|
||||
sha := strings.TrimSpace(os.Getenv("EVOBGP_GIT_SHA"))
|
||||
if sha == "" {
|
||||
sha = "unknown"
|
||||
ver, sha, buildTime := version.Info()
|
||||
resp := map[string]string{
|
||||
"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 {
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -172,12 +173,15 @@ func (s *Server) handleDeleteModule(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func writeStoreErr(w http.ResponseWriter, err error) {
|
||||
if err != nil {
|
||||
log.Printf("httpapi: store: %v", err)
|
||||
}
|
||||
if err == store.ErrNotFound || err == store.ErrTenantScope {
|
||||
writeProblem(w, http.StatusNotFound, "Not Found", err.Error())
|
||||
writeProblem(w, http.StatusNotFound, "Not Found", notFoundDetail)
|
||||
return
|
||||
}
|
||||
if err == store.ErrInvalidInput {
|
||||
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", err.Error())
|
||||
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", invalidInputDetail)
|
||||
return
|
||||
}
|
||||
writeInternalError(w, "store", err)
|
||||
@@ -193,11 +197,7 @@ func (s *Server) handleListCDNSources(w http.ResponseWriter, r *http.Request) {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
items := make([]map[string]any, 0, len(list))
|
||||
for _, x := range list {
|
||||
items = append(items, cdnSourceJSON(x))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"items": items, "next_cursor": nil, "has_more": false})
|
||||
writePaginatedListJSON(w, r, list, cdnSourceJSON)
|
||||
}
|
||||
|
||||
func cdnSourceJSON(x *store.CDNSource) map[string]any {
|
||||
@@ -258,7 +258,7 @@ func (s *Server) handlePreviewCDNSource(w http.ResponseWriter, r *http.Request)
|
||||
writeBadGateway(w, "cdn preview fetch", err)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
writeBadGateway(w, "cdn preview fetch", fmt.Errorf("upstream status: %s", resp.Status))
|
||||
@@ -271,7 +271,8 @@ func (s *Server) handlePreviewCDNSource(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
pfxs, err := pipeline.ExtractCIDRs(string(raw), body.SourceKind, body.PrefixPath)
|
||||
if err != nil {
|
||||
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", err.Error())
|
||||
log.Printf("httpapi: cdn preview extract: %v", err)
|
||||
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", cdnExtractDetail)
|
||||
return
|
||||
}
|
||||
items := make([]string, 0, len(pfxs))
|
||||
@@ -354,11 +355,7 @@ func (s *Server) handleListAS(w http.ResponseWriter, r *http.Request) {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
items := make([]map[string]any, 0, len(list))
|
||||
for _, x := range list {
|
||||
items = append(items, asEntryJSON(x))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"items": items})
|
||||
writePaginatedListJSON(w, r, list, asEntryJSON)
|
||||
}
|
||||
|
||||
func asEntryJSON(x *store.ASEntry) map[string]any {
|
||||
@@ -450,11 +447,7 @@ func (s *Server) handleListDomain(w http.ResponseWriter, r *http.Request) {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
items := make([]map[string]any, 0, len(list))
|
||||
for _, x := range list {
|
||||
items = append(items, domainEntryJSON(x))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"items": items})
|
||||
writePaginatedListJSON(w, r, list, domainEntryJSON)
|
||||
}
|
||||
|
||||
func domainEntryJSON(x *store.DomainEntry) map[string]any {
|
||||
@@ -531,11 +524,7 @@ func (s *Server) handleListIPRange(w http.ResponseWriter, r *http.Request) {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
items := make([]map[string]any, 0, len(list))
|
||||
for _, x := range list {
|
||||
items = append(items, ipRangeJSON(x))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"items": items})
|
||||
writePaginatedListJSON(w, r, list, ipRangeJSON)
|
||||
}
|
||||
|
||||
func ipRangeJSON(x *store.IPRangeEntry) map[string]any {
|
||||
@@ -702,8 +691,8 @@ func (s *Server) handleImportModuleEntriesCSV(w http.ResponseWriter, r *http.Req
|
||||
return
|
||||
}
|
||||
if strings.Contains(err.Error(), "importer: line") {
|
||||
detail := strings.TrimPrefix(err.Error(), "importer: ")
|
||||
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", detail)
|
||||
log.Printf("httpapi: csv import: %v", err)
|
||||
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", csvInvalidRowDetail)
|
||||
return
|
||||
}
|
||||
if strings.Contains(err.Error(), "importer: csv import/export") {
|
||||
|
||||
@@ -34,7 +34,7 @@ func TestModuleEntriesCSVImportExportIPRanges(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer respList.Body.Close()
|
||||
defer func() { _ = respList.Body.Close() }()
|
||||
if respList.StatusCode != http.StatusOK {
|
||||
b, _ := io.ReadAll(respList.Body)
|
||||
t.Fatalf("communities status %d: %s", respList.StatusCode, b)
|
||||
@@ -59,7 +59,7 @@ func TestModuleEntriesCSVImportExportIPRanges(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer respImport.Body.Close()
|
||||
defer func() { _ = respImport.Body.Close() }()
|
||||
if respImport.StatusCode != http.StatusOK {
|
||||
b, _ := io.ReadAll(respImport.Body)
|
||||
t.Fatalf("import status %d: %s", respImport.StatusCode, b)
|
||||
@@ -80,7 +80,7 @@ func TestModuleEntriesCSVImportExportIPRanges(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer respExport.Body.Close()
|
||||
defer func() { _ = respExport.Body.Close() }()
|
||||
if respExport.StatusCode != http.StatusOK {
|
||||
b, _ := io.ReadAll(respExport.Body)
|
||||
t.Fatalf("export status %d: %s", respExport.StatusCode, b)
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNestedModuleListPagination(t *testing.T) {
|
||||
srv, err := New(Options{InsecureDev: true, SeedDemo: true, BundleSeedHex: testBundleSeed})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer srv.Close()
|
||||
tenant, _, modIP, _, _ := srv.Store().DemoIDs()
|
||||
srv.apiKeys = parseAPIKeysSpec("edkey|" + tenant + "|editor")
|
||||
|
||||
ts := httptest.NewServer(srv.Handler())
|
||||
defer ts.Close()
|
||||
client := ts.Client()
|
||||
base := ts.URL
|
||||
mid := modIP
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
body := strings.NewReader(fmt.Sprintf(`{"prefix":"10.%d.0.0/24"}`, 200+i))
|
||||
req, _ := http.NewRequest(http.MethodPost, base+"/v1/modules/"+mid+"/ip-range-entries", body)
|
||||
req.Header.Set("Authorization", "Bearer edkey")
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
_ = resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("create entry %d: status %d", i, resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
req, _ := http.NewRequest(http.MethodGet, base+"/v1/modules/"+mid+"/ip-range-entries?limit=2", nil)
|
||||
req.Header.Set("Authorization", "Bearer edkey")
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("list status %d: %s", resp.StatusCode, b)
|
||||
}
|
||||
var page1 struct {
|
||||
Items []map[string]any `json:"items"`
|
||||
NextCursor *string `json:"next_cursor"`
|
||||
HasMore bool `json:"has_more"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&page1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(page1.Items) != 2 {
|
||||
t.Fatalf("page1 items: got %d want 2", len(page1.Items))
|
||||
}
|
||||
if !page1.HasMore || page1.NextCursor == nil || *page1.NextCursor == "" {
|
||||
t.Fatalf("page1: has_more=%v next_cursor=%v", page1.HasMore, page1.NextCursor)
|
||||
}
|
||||
|
||||
req2, _ := http.NewRequest(http.MethodGet, base+"/v1/modules/"+mid+"/ip-range-entries?limit=2&cursor="+*page1.NextCursor, nil)
|
||||
req2.Header.Set("Authorization", "Bearer edkey")
|
||||
resp2, err := client.Do(req2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = resp2.Body.Close() }()
|
||||
var page2 struct {
|
||||
Items []map[string]any `json:"items"`
|
||||
HasMore bool `json:"has_more"`
|
||||
}
|
||||
if err := json.NewDecoder(resp2.Body).Decode(&page2); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(page1.Items)+len(page2.Items) < 3 {
|
||||
t.Fatalf("expected at least 3 entries across pages, got %d+%d", len(page1.Items), len(page2.Items))
|
||||
}
|
||||
}
|
||||
@@ -32,10 +32,10 @@ type Server struct {
|
||||
type Options struct {
|
||||
APIKeys string
|
||||
// DatabaseURL enables PostgreSQL-backed store (migrations applied on connect).
|
||||
DatabaseURL string
|
||||
InsecureDev bool
|
||||
SeedDemo bool
|
||||
BundleSeedHex string
|
||||
DatabaseURL string
|
||||
InsecureDev bool
|
||||
SeedDemo bool
|
||||
BundleSeedHex string
|
||||
CORSAllowedOrigins string
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
|
||||
"evobgp/internal/jobs"
|
||||
"evobgp/internal/signing"
|
||||
"evobgp/internal/version"
|
||||
)
|
||||
|
||||
const testBundleSeed = "0101010101010101010101010101010101010101010101010101010101010101"
|
||||
@@ -50,7 +51,7 @@ func TestAPIRefreshApplyJobsBundle(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("status %d: %s", resp.StatusCode, b)
|
||||
@@ -75,7 +76,7 @@ func TestAPIRefreshApplyJobsBundle(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusAccepted {
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("status %d: %s", resp.StatusCode, b)
|
||||
@@ -96,7 +97,7 @@ func TestAPIRefreshApplyJobsBundle(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusAccepted {
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("status %d: %s", resp.StatusCode, b)
|
||||
@@ -117,7 +118,7 @@ func TestAPIRefreshApplyJobsBundle(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("status %d: %s", resp.StatusCode, b)
|
||||
@@ -131,7 +132,7 @@ func TestAPIRefreshApplyJobsBundle(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("status %d: %s", resp.StatusCode, b)
|
||||
@@ -161,7 +162,7 @@ func TestAPIRefreshApplyJobsBundle(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
_ = resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("%s status %d: %s", path, resp.StatusCode, b)
|
||||
}
|
||||
@@ -184,7 +185,7 @@ func TestAPIRefreshApplyJobsBundle(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("status %d: %s", resp.StatusCode, b)
|
||||
@@ -214,7 +215,7 @@ func TestAPIRefreshApplyJobsBundle(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("status %d: %s", resp.StatusCode, b)
|
||||
@@ -254,7 +255,7 @@ func TestAPIRefreshApplyJobsBundle(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("status %d want 403: %s", resp.StatusCode, b)
|
||||
@@ -268,7 +269,7 @@ func TestAPIRefreshApplyJobsBundle(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusAccepted {
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("status %d: %s", resp.StatusCode, b)
|
||||
@@ -288,7 +289,7 @@ func TestAPIRefreshApplyJobsBundle(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusAccepted {
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("status %d: %s", resp.StatusCode, b)
|
||||
@@ -313,7 +314,7 @@ func TestAPIRefreshApplyJobsBundle(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("status %d: %s", resp.StatusCode, b)
|
||||
@@ -340,7 +341,7 @@ func waitJob(t *testing.T, client *http.Client, base, token, jobID string) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
_ = resp.Body.Close()
|
||||
var body struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
@@ -371,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"])
|
||||
}
|
||||
}
|
||||
|
||||
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 func() { _ = 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"])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -66,7 +66,7 @@ func fetchLatestRevision(base, token, speaker string) (string, error) {
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
return "", fmt.Errorf("latest revision: %s: %s", resp.Status, strings.TrimSpace(string(b)))
|
||||
@@ -94,7 +94,7 @@ func fetchBundle(base, token, speaker, revision string) ([]byte, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("bundle: %s: %s", resp.Status, strings.TrimSpace(string(b)))
|
||||
|
||||
@@ -27,7 +27,7 @@ func Run(args []string) int {
|
||||
|
||||
// Usage prints CLI help to w.
|
||||
func Usage(w interface{ Write([]byte) (int, error) }) {
|
||||
fmt.Fprintf(w, `Usage:
|
||||
_, _ = fmt.Fprintf(w, `Usage:
|
||||
evobgp-node pull-bundle -base-url URL -token TOKEN -speaker-id ID [-revision-id ID] [-o path]
|
||||
evobgp-node verify-bundle -f bundle.tar.gz (-pubkey-base64 B64 | -pubkey-hex HEX)
|
||||
evobgp-node apply-bundle -f bundle.tar.gz -extract-dir DIR (-pubkey-base64 B64 | -pubkey-hex HEX)
|
||||
|
||||
@@ -126,7 +126,7 @@ func applyCDNSourceHTTPResult(ctx context.Context, st store.Backend, hc *http.Cl
|
||||
return nil, fmt.Errorf("cdn url %s: 304 without cached prefixes", u)
|
||||
}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
|
||||
@@ -46,4 +46,3 @@ func TestBuildPreviewFragments_SamePrefixDifferentCommunity(t *testing.T) {
|
||||
t.Fatalf("expected deterministic static preview text, got first:\n%s\nsecond:\n%s", staticV4, staticV4Second)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ func ParseCIDRLines(body string) []netip.Prefix {
|
||||
return out
|
||||
}
|
||||
|
||||
// ExtractCIDRs parses CIDRs from either plaintext lines or JSON payload.
|
||||
// ExtractCIDRs parses CIDR prefixes from plaintext lines or a JSON payload (see sourceKind and prefixPath).
|
||||
// For sourceKind="json", prefixPath supports dotted traversal, with [] for arrays:
|
||||
// e.g. "prefixes[]", "data.items[].cidr".
|
||||
func ExtractCIDRs(body, sourceKind, prefixPath string) ([]netip.Prefix, error) {
|
||||
|
||||
@@ -306,7 +306,7 @@ func resolveDomainWithDOHMessage(ctx context.Context, hc *http.Client, baseURL,
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
|
||||
return nil, fmt.Errorf("doh dns-message status %s: %s", resp.Status, strings.TrimSpace(string(body)))
|
||||
@@ -378,7 +378,7 @@ func resolveDomainWithDOHJSON(ctx context.Context, hc *http.Client, baseURL, hos
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
|
||||
return nil, fmt.Errorf("doh status %s: %s", resp.Status, strings.TrimSpace(string(body)))
|
||||
@@ -725,15 +725,15 @@ func dedupeSortedPrefixLines(rows []store.PrefixRow) []prefixHashLine {
|
||||
}
|
||||
|
||||
func writePrefixLinesHash(h interface{ Write([]byte) (int, error) }, tenantID string, lines []prefixHashLine) {
|
||||
h.Write([]byte(strings.TrimSpace(tenantID)))
|
||||
h.Write([]byte{0})
|
||||
_, _ = h.Write([]byte(strings.TrimSpace(tenantID)))
|
||||
_, _ = h.Write([]byte{0})
|
||||
for _, l := range lines {
|
||||
h.Write([]byte(l.p))
|
||||
h.Write([]byte{1})
|
||||
h.Write([]byte(l.c))
|
||||
h.Write([]byte{1})
|
||||
h.Write([]byte(l.s))
|
||||
h.Write([]byte{0})
|
||||
_, _ = h.Write([]byte(l.p))
|
||||
_, _ = h.Write([]byte{1})
|
||||
_, _ = h.Write([]byte(l.c))
|
||||
_, _ = h.Write([]byte{1})
|
||||
_, _ = h.Write([]byte(l.s))
|
||||
_, _ = h.Write([]byte{0})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"evobgp/internal/db"
|
||||
"evobgp/internal/store"
|
||||
)
|
||||
|
||||
func TestPostgresImplementsBackend(t *testing.T) {
|
||||
var _ store.Backend = (*Postgres)(nil)
|
||||
}
|
||||
|
||||
func TestPostgresPingIntegration(t *testing.T) {
|
||||
dsn := os.Getenv("EVOBGP_TEST_DATABASE_URL")
|
||||
if dsn == "" {
|
||||
t.Skip("EVOBGP_TEST_DATABASE_URL not set")
|
||||
}
|
||||
ctx := context.Background()
|
||||
pool, err := db.OpenPostgresPool(ctx, dsn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer pool.Close()
|
||||
pg, err := NewPostgres(ctx, pool, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := pg.Ping(ctx); err != nil {
|
||||
t.Fatalf("ping: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostgresModuleCRUDIntegration(t *testing.T) {
|
||||
dsn := os.Getenv("EVOBGP_TEST_DATABASE_URL")
|
||||
if dsn == "" {
|
||||
t.Skip("EVOBGP_TEST_DATABASE_URL not set")
|
||||
}
|
||||
ctx := context.Background()
|
||||
pool, err := db.OpenPostgresPool(ctx, dsn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer pool.Close()
|
||||
pg, err := NewPostgres(ctx, pool, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tenant := "01TESTTENANT00000000000001"
|
||||
mod, err := pg.CreateModule(tenant, &store.Module{
|
||||
Name: "audit-test",
|
||||
Type: "IP_RANGES",
|
||||
Enabled: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := pg.GetModule(tenant, mod.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Name != "audit-test" {
|
||||
t.Fatalf("name: got %q", got.Name)
|
||||
}
|
||||
if err := pg.SoftDeleteModule(tenant, mod.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -28,7 +28,7 @@ func agentDebugNDJSON3214(hypothesisID, location, message string, data map[strin
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
defer func() { _ = f.Close() }()
|
||||
var ms runtime.MemStats
|
||||
runtime.ReadMemStats(&ms)
|
||||
payload := map[string]any{
|
||||
@@ -71,6 +71,11 @@ func (p *Postgres) DemoIDs() (tenant, moduleCDN, moduleIP, revision, speaker str
|
||||
return p.demoTenant, p.demoCDN, p.demoIP, p.demoRev, p.demoSpk
|
||||
}
|
||||
|
||||
// Ping checks PostgreSQL connectivity.
|
||||
func (p *Postgres) Ping(ctx context.Context) error {
|
||||
return p.pool.Ping(ctx)
|
||||
}
|
||||
|
||||
func (p *Postgres) MaterializedPrefixStats() (max int, sum int) {
|
||||
ctx := context.Background()
|
||||
// Агрегация в БД — не тащим все строки config_revision в память.
|
||||
|
||||
@@ -121,7 +121,7 @@ func postTenantRefresh(ctx context.Context, deps *Deps, moduleIDs []string, idem
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode == http.StatusNoContent || resp.StatusCode == http.StatusAccepted {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package store
|
||||
|
||||
import "time"
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Backend is the persistence abstraction for the control plane (memory, PostgreSQL, SQLite).
|
||||
type Backend interface {
|
||||
@@ -90,6 +93,9 @@ type Backend interface {
|
||||
// ASNPrefixCache stores RIPEstat announced-prefixes per ASN (global TTL cache).
|
||||
GetASNPrefixCache(asn int64) (*ASNPrefixCacheEntry, bool, error)
|
||||
SetASNPrefixCache(asn int64, holder string, prefixes []string) error
|
||||
|
||||
// Ping verifies backend connectivity (no-op for in-memory).
|
||||
Ping(ctx context.Context) error
|
||||
}
|
||||
|
||||
// ASNPrefixCacheEntry is a cached RIPEstat response for one ASN.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
@@ -32,13 +33,13 @@ type Memory struct {
|
||||
|
||||
peers map[string]*BGPPeer
|
||||
|
||||
dohProfiles map[string]*DohProfile
|
||||
communities map[string]*Community
|
||||
cdnSources map[string]*CDNSource
|
||||
asEntries map[string]*ASEntry
|
||||
domainEnt map[string]*DomainEntry
|
||||
ipRanges map[string]*IPRangeEntry
|
||||
settings map[string]map[string]any // tenantID -> key -> JSON-compatible value
|
||||
dohProfiles map[string]*DohProfile
|
||||
communities map[string]*Community
|
||||
cdnSources map[string]*CDNSource
|
||||
asEntries map[string]*ASEntry
|
||||
domainEnt map[string]*DomainEntry
|
||||
ipRanges map[string]*IPRangeEntry
|
||||
settings map[string]map[string]any // tenantID -> key -> JSON-compatible value
|
||||
revPrefixes map[string][]PrefixRow
|
||||
moduleSnapshots map[string]*moduleSnapshotRec
|
||||
asnPrefixCache map[int64]*ASNPrefixCacheEntry
|
||||
@@ -291,6 +292,12 @@ func (m *Memory) DemoIDs() (tenant, moduleCDN, moduleIP, revision, speaker strin
|
||||
return m.demoTenantID, m.demoModuleCDN, m.demoModuleIP, m.demoRevisionID, m.demoSpeakerID
|
||||
}
|
||||
|
||||
// Ping is a no-op for the in-memory backend.
|
||||
func (m *Memory) Ping(ctx context.Context) error {
|
||||
_ = ctx
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListTenantIDs returns tenant ids sorted lexicographically.
|
||||
func (m *Memory) ListTenantIDs() ([]string, error) {
|
||||
m.mu.RLock()
|
||||
|
||||
@@ -19,9 +19,9 @@ func TestEffectivePeerEnabledOnCreate(t *testing.T) {
|
||||
|
||||
func TestParsePeerNeighbor(t *testing.T) {
|
||||
tests := []struct {
|
||||
in string
|
||||
want string
|
||||
wantOK bool
|
||||
in string
|
||||
want string
|
||||
wantOK bool
|
||||
}{
|
||||
{"192.168.0.2", "192.168.0.2", true},
|
||||
{"192.168.0.2/32", "192.168.0.2", true},
|
||||
|
||||
@@ -0,0 +1,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
|
||||
}
|
||||
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,74 @@
|
||||
#!/usr/bin/env sh
|
||||
# DEP-03: postgres and sqlite migration sets must have matching numbered pairs.
|
||||
set -eu
|
||||
|
||||
ROOT="$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)"
|
||||
PG="$ROOT/migrations/postgres"
|
||||
SQL="$ROOT/migrations/sqlite"
|
||||
|
||||
list_nums() {
|
||||
dir="$1"
|
||||
ls "$dir" 2>/dev/null | sed -n 's/^\([0-9]\{6\}\)_.*\.up\.sql$/\1/p' | sort -u
|
||||
}
|
||||
|
||||
migration_only_in() {
|
||||
# Prints numbers present in $1 but not in $2 (space-separated lists).
|
||||
haystack="$2 "
|
||||
for n in $1; do
|
||||
case "$haystack" in
|
||||
*" $n "*) ;;
|
||||
*) echo "$n" ;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
pg_nums="$(list_nums "$PG")"
|
||||
sql_nums="$(list_nums "$SQL")"
|
||||
|
||||
if [ "$pg_nums" != "$sql_nums" ]; then
|
||||
echo "check-migrations-pair: postgres and sqlite migration numbers differ" >&2
|
||||
only_pg="$(migration_only_in "$pg_nums" "$sql_nums")"
|
||||
only_sql="$(migration_only_in "$sql_nums" "$pg_nums")"
|
||||
if [ -n "$only_pg" ]; then
|
||||
echo "postgres only:" >&2
|
||||
for n in $only_pg; do
|
||||
echo " $n" >&2
|
||||
done
|
||||
fi
|
||||
if [ -n "$only_sql" ]; then
|
||||
echo "sqlite only:" >&2
|
||||
for n in $only_sql; do
|
||||
echo " $n" >&2
|
||||
done
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
|
||||
for n in $pg_nums; do
|
||||
pg_up=""
|
||||
sql_up=""
|
||||
for f in "$PG"/${n}_*.up.sql; do
|
||||
if [ -f "$f" ]; then
|
||||
pg_up="$f"
|
||||
break
|
||||
fi
|
||||
done
|
||||
for f in "$SQL"/${n}_*.up.sql; do
|
||||
if [ -f "$f" ]; then
|
||||
sql_up="$f"
|
||||
break
|
||||
fi
|
||||
done
|
||||
if [ -z "$pg_up" ] || [ -z "$sql_up" ]; then
|
||||
echo "check-migrations-pair: missing .up.sql for $n" >&2
|
||||
exit 1
|
||||
fi
|
||||
pg_base="$(basename "$pg_up" .up.sql)"
|
||||
sql_base="$(basename "$sql_up" .up.sql)"
|
||||
if [ "$pg_base" != "$sql_base" ]; then
|
||||
echo "check-migrations-pair: name mismatch for $n: $pg_base vs $sql_base" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
echo "check-migrations-pair: ok ($PG and $SQL)"
|
||||
@@ -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
|
||||
@@ -16,6 +16,14 @@ if grep -rE 'writeProblem\(w, http\.StatusBadGateway.*err\.Error\(\)' "$HTTPAPI"
|
||||
FAIL=1
|
||||
fi
|
||||
|
||||
echo "==> ERR-01: no err.Error() in 4xx writeProblem (store/cdn/csv)"
|
||||
if grep -rE 'writeProblem\(w, http\.Status(NotFound|UnprocessableEntity|BadRequest).*, err\.Error\(\)' "$HTTPAPI" 2>/dev/null; then
|
||||
FAIL=1
|
||||
fi
|
||||
if grep -rE 'writeStoreErr.*err\.Error|writeProblem.*Unprocessable.*err\.Error' "$HTTPAPI" 2>/dev/null; then
|
||||
FAIL=1
|
||||
fi
|
||||
|
||||
echo "==> ARCH-01: no SQL/pgx queries in httpapi"
|
||||
if grep -rE 'pool\.(Query|Exec|QueryRow)|SELECT |INSERT INTO |UPDATE .* SET |DELETE FROM ' "$HTTPAPI" 2>/dev/null; then
|
||||
FAIL=1
|
||||
|
||||
Generated
+2
-3
@@ -14,7 +14,8 @@
|
||||
"svelte-sonner": "^1.1.0",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwind-variants": "^3.2.2",
|
||||
"tw-animate-css": "^1.4.0"
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@internationalized/date": "^3.12.0",
|
||||
@@ -3137,9 +3138,7 @@
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
|
||||
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
|
||||
+2
-1
@@ -39,6 +39,7 @@
|
||||
"svelte-sonner": "^1.1.0",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwind-variants": "^3.2.2",
|
||||
"tw-animate-css": "^1.4.0"
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"zod": "^4.4.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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,212 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { defaults, superForm } from 'sveltekit-superforms';
|
||||
import { zod4 } from 'sveltekit-superforms/adapters';
|
||||
import {
|
||||
birdSettingsSchema,
|
||||
emptyBirdSettingsForm,
|
||||
type BirdSettingsForm
|
||||
} from '$lib/settings/bird-settings.schema.js';
|
||||
import {
|
||||
buildPayloadFromFormFields,
|
||||
loadSettings,
|
||||
partitionSettings,
|
||||
patchSettings
|
||||
} from '$lib/settings/settings-api.js';
|
||||
import { BIRD_SETTING_KEYS } from '$lib/settings/settings-known-keys.js';
|
||||
import { Button } from '$lib/ui/core/button/index.js';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle
|
||||
} from '$lib/ui/core/card/index.js';
|
||||
import { Input } from '$lib/ui/core/input/index.js';
|
||||
import { Alert, AlertDescription, AlertTitle } from '$lib/ui/core/alert/index.js';
|
||||
import FormField from '$lib/ui/patterns/form/form-field.svelte';
|
||||
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
|
||||
import Save from '@lucide/svelte/icons/save';
|
||||
import Info from '@lucide/svelte/icons/info';
|
||||
|
||||
let loading = $state(false);
|
||||
let saving = $state(false);
|
||||
let loaded = $state(false);
|
||||
|
||||
const { form, errors, reset, validateForm } = superForm(
|
||||
defaults(emptyBirdSettingsForm(), zod4(birdSettingsSchema)),
|
||||
{
|
||||
validators: zod4(birdSettingsSchema),
|
||||
SPA: true,
|
||||
dataType: 'json'
|
||||
}
|
||||
);
|
||||
|
||||
let hasValidationErrors = $derived(
|
||||
BIRD_SETTING_KEYS.some((key) => Boolean($errors[key as keyof BirdSettingsForm]?.length))
|
||||
);
|
||||
|
||||
let canSave = $derived.by(() => {
|
||||
if (loading || saving || hasValidationErrors || !loaded) return false;
|
||||
return BIRD_SETTING_KEYS.some((key) => {
|
||||
const value = String($form[key as keyof BirdSettingsForm] ?? '').trim();
|
||||
return value !== '' && !$errors[key as keyof BirdSettingsForm]?.length;
|
||||
});
|
||||
});
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
try {
|
||||
const settings = await loadSettings();
|
||||
const { partitioned } = partitionSettings(settings);
|
||||
reset({ data: partitioned.bird });
|
||||
loaded = true;
|
||||
} catch (e) {
|
||||
notifyApiError(e);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
const validation = await validateForm({ update: true });
|
||||
if (!validation.valid) {
|
||||
notify.error('Исправьте ошибки в полях настроек');
|
||||
return;
|
||||
}
|
||||
if (!canSave) {
|
||||
notify.error('Нечего сохранять или есть ошибки в полях');
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = buildPayloadFromFormFields(
|
||||
BIRD_SETTING_KEYS,
|
||||
$form as Record<string, string>,
|
||||
$errors as Partial<Record<string, string[]>>
|
||||
);
|
||||
|
||||
saving = true;
|
||||
try {
|
||||
await patchSettings(payload);
|
||||
notify.success('Параметры BIRD сохранены');
|
||||
await load();
|
||||
} catch (e) {
|
||||
notifyApiError(e);
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
void load();
|
||||
});
|
||||
</script>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Control plane</CardTitle>
|
||||
<CardDescription>
|
||||
Глобальные параметры BIRD для pipeline refresh/apply. Сохранение через
|
||||
<code class="text-xs">PATCH /v1/settings</code> (роль operator).
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-5">
|
||||
<Alert class="border-info/30 bg-info/5">
|
||||
<Info class="text-info" />
|
||||
<AlertTitle>Подстановка в конфиг</AlertTitle>
|
||||
<AlertDescription>
|
||||
Значения используются при генерации BIRD-конфигурации в pipeline (router id, local AS,
|
||||
адреса). Пиры и спикеры настраиваются на соседних вкладках.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
{#if loading && !loaded}
|
||||
<p class="text-sm text-muted-foreground">Загрузка…</p>
|
||||
{:else if !loaded}
|
||||
<Button variant="outline" onclick={load}>Загрузить параметры</Button>
|
||||
{:else}
|
||||
<div class="space-y-3">
|
||||
<FormField
|
||||
id="bird-router-id"
|
||||
label="Router ID (bird_router_id)"
|
||||
error={$errors.bird_router_id?.[0]}
|
||||
>
|
||||
<Input id="bird-router-id" bind:value={$form.bird_router_id} placeholder="203.0.113.1" />
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
id="bird-local-ipv4"
|
||||
label="Локальный IPv4 (bird_local_ipv4)"
|
||||
error={$errors.bird_local_ipv4?.[0]}
|
||||
>
|
||||
<Input
|
||||
id="bird-local-ipv4"
|
||||
bind:value={$form.bird_local_ipv4}
|
||||
placeholder="198.51.100.10"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
id="bird-local-ipv6"
|
||||
label="Локальный IPv6 (bird_local_ipv6)"
|
||||
error={$errors.bird_local_ipv6?.[0]}
|
||||
>
|
||||
<Input
|
||||
id="bird-local-ipv6"
|
||||
bind:value={$form.bird_local_ipv6}
|
||||
placeholder="2001:db8::10"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
id="bird-local-asn"
|
||||
label="Локальный ASN (bird_local_asn)"
|
||||
error={$errors.bird_local_asn?.[0]}
|
||||
>
|
||||
<Input
|
||||
id="bird-local-asn"
|
||||
type="number"
|
||||
min="1"
|
||||
bind:value={$form.bird_local_asn}
|
||||
placeholder="65001"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
id="bird-bgp-source-ipv4"
|
||||
label="BGP source IPv4 (bird_bgp_source_ipv4)"
|
||||
error={$errors.bird_bgp_source_ipv4?.[0]}
|
||||
>
|
||||
<Input
|
||||
id="bird-bgp-source-ipv4"
|
||||
bind:value={$form.bird_bgp_source_ipv4}
|
||||
placeholder="198.51.100.11"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
id="bird-bgp-source-ipv6"
|
||||
label="BGP source IPv6 (bird_bgp_source_ipv6)"
|
||||
error={$errors.bird_bgp_source_ipv6?.[0]}
|
||||
>
|
||||
<Input
|
||||
id="bird-bgp-source-ipv6"
|
||||
bind:value={$form.bird_bgp_source_ipv6}
|
||||
placeholder="2001:db8::11"
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
{#if hasValidationErrors}
|
||||
<p class="text-sm text-destructive">
|
||||
Есть ошибки в полях. Исправьте их, чтобы сохранить изменения.
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<Button onclick={save} disabled={!canSave}>
|
||||
<Save />
|
||||
{saving ? 'Сохранение…' : 'Применить параметры'}
|
||||
</Button>
|
||||
{/if}
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -0,0 +1,290 @@
|
||||
<script lang="ts">
|
||||
import { apiMutate } from '$lib/api/client.js';
|
||||
import type { PeerRow, BgpPeerCreate, SpeakerRow } from '$lib/api/types.js';
|
||||
import { Badge } from '$lib/ui/core/badge/index.js';
|
||||
import { Button } from '$lib/ui/core/button/index.js';
|
||||
import { Label } from '$lib/ui/core/label/index.js';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription
|
||||
} from '$lib/ui/core/card/index.js';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
DialogDescription
|
||||
} from '$lib/ui/core/dialog/index.js';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger } from '$lib/ui/core/select/index.js';
|
||||
import { Switch } from '$lib/ui/core/switch/index.js';
|
||||
import FormField from '$lib/ui/patterns/form/form-field.svelte';
|
||||
import AppInput from '$lib/ui/patterns/form/app-input.svelte';
|
||||
import AppDataTable from '$lib/ui/patterns/data-table/app-data-table.svelte';
|
||||
import { confirm } from '$lib/ui/patterns/confirm/confirm-state.svelte.js';
|
||||
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
|
||||
import Plus from '@lucide/svelte/icons/plus';
|
||||
import Pencil from '@lucide/svelte/icons/pencil';
|
||||
import Trash2 from '@lucide/svelte/icons/trash-2';
|
||||
|
||||
type Props = {
|
||||
items: PeerRow[];
|
||||
speakers: SpeakerRow[];
|
||||
loading?: boolean;
|
||||
initialLoading?: boolean;
|
||||
error?: string | null;
|
||||
onRefresh: () => void | Promise<void>;
|
||||
};
|
||||
|
||||
let {
|
||||
items,
|
||||
speakers,
|
||||
loading = false,
|
||||
initialLoading = false,
|
||||
error = null,
|
||||
onRefresh
|
||||
}: Props = $props();
|
||||
|
||||
let dialogOpen = $state(false);
|
||||
let editTarget = $state<PeerRow | null>(null);
|
||||
let form = $state<BgpPeerCreate & { bgp_speaker_id?: string | null }>({
|
||||
name: '',
|
||||
neighbor: '',
|
||||
remote_asn: 0,
|
||||
bgp_speaker_id: null,
|
||||
enabled: true
|
||||
});
|
||||
let saving = $state(false);
|
||||
let toggleId = $state<string | null>(null);
|
||||
|
||||
const speakerById = $derived.by(() => new Map(speakers.map((s) => [s.id, s])));
|
||||
|
||||
const columns = [
|
||||
{ id: 'name', label: 'Имя', sortable: true, sortValue: (p: PeerRow) => p.name ?? '' },
|
||||
{ id: 'neighbor', label: 'Адрес', sortable: true, sortValue: (p: PeerRow) => p.neighbor },
|
||||
{
|
||||
id: 'remote_asn',
|
||||
label: 'Remote ASN',
|
||||
sortable: true,
|
||||
sortValue: (p: PeerRow) => p.remote_asn ?? 0
|
||||
},
|
||||
{ id: 'enabled', label: 'Вкл.', class: 'w-[4.5rem] text-center' },
|
||||
{ id: 'session_state', label: 'Состояние сессии' },
|
||||
{ id: 'speaker', label: 'Спикер' },
|
||||
{ id: 'actions', label: '', class: 'w-20' }
|
||||
] as const;
|
||||
|
||||
function speakerLabelById(id: string | null | undefined) {
|
||||
if (!id) return '—';
|
||||
return speakerById.get(id)?.endpoint ?? id;
|
||||
}
|
||||
|
||||
function sessionBadge(state: string) {
|
||||
if (state === 'Established') return 'default';
|
||||
if (state === 'Active' || state === 'Connect') return 'secondary';
|
||||
return 'outline';
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
editTarget = null;
|
||||
form = { name: '', neighbor: '', remote_asn: 0, bgp_speaker_id: null, enabled: true };
|
||||
dialogOpen = true;
|
||||
}
|
||||
|
||||
function openEdit(p: PeerRow) {
|
||||
editTarget = p;
|
||||
form = {
|
||||
name: p.name ?? '',
|
||||
neighbor: p.neighbor,
|
||||
remote_asn: p.remote_asn ?? 0,
|
||||
bgp_speaker_id: p.bgp_speaker_id,
|
||||
enabled: p.enabled !== false
|
||||
};
|
||||
dialogOpen = true;
|
||||
}
|
||||
|
||||
function requestDelete(p: PeerRow) {
|
||||
void confirm({
|
||||
title: 'Удалить пира?',
|
||||
description: p.neighbor,
|
||||
confirmLabel: 'Удалить',
|
||||
destructive: true,
|
||||
onConfirm: async () => {
|
||||
await apiMutate(`/v1/peers/${p.id}`, 'DELETE', undefined, { idempotent: false });
|
||||
notify.success('Пир удалён');
|
||||
await onRefresh();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function setEnabled(p: PeerRow, enabled: boolean) {
|
||||
toggleId = p.id;
|
||||
try {
|
||||
await apiMutate(`/v1/peers/${p.id}`, 'PATCH', { enabled });
|
||||
notify.success(enabled ? 'Пир включён' : 'Пир отключён');
|
||||
await onRefresh();
|
||||
} catch (e) {
|
||||
notifyApiError(e);
|
||||
} finally {
|
||||
toggleId = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!form.neighbor.trim()) {
|
||||
notify.error('Укажите адрес соседа');
|
||||
return;
|
||||
}
|
||||
if (!form.remote_asn || form.remote_asn <= 0) {
|
||||
notify.error('Remote ASN должен быть больше 0');
|
||||
return;
|
||||
}
|
||||
saving = true;
|
||||
try {
|
||||
if (editTarget) {
|
||||
await apiMutate(`/v1/peers/${editTarget.id}`, 'PATCH', form);
|
||||
notify.success('Пир обновлён');
|
||||
} else {
|
||||
await apiMutate('/v1/peers', 'POST', {
|
||||
...form,
|
||||
enabled: form.enabled !== false
|
||||
});
|
||||
notify.success('Пир создан');
|
||||
}
|
||||
dialogOpen = false;
|
||||
await onRefresh();
|
||||
} catch (e) {
|
||||
notifyApiError(e);
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Card>
|
||||
<CardHeader
|
||||
class="flex flex-col gap-3 border-b py-3 sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<div class="min-w-0 flex-1">
|
||||
<CardTitle class="text-base">BGP-пиры</CardTitle>
|
||||
<CardDescription>Настройка BGP-соседей и привязка к спикерам</CardDescription>
|
||||
</div>
|
||||
<div class="flex shrink-0 flex-wrap items-center justify-end gap-2">
|
||||
<Button size="sm" onclick={openCreate}><Plus />Добавить</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent class="p-4 pt-0">
|
||||
<AppDataTable
|
||||
columns={[...columns]}
|
||||
rows={items}
|
||||
rowKey={(p) => p.id}
|
||||
loading={initialLoading || loading}
|
||||
{error}
|
||||
emptyTitle="Нет BGP-пиров"
|
||||
emptyDescription="Добавьте первого BGP-соседа для установки сессии."
|
||||
>
|
||||
{#snippet cell({ row: p, column })}
|
||||
{#if column.id === 'name'}
|
||||
<span>{p.name?.trim() || '—'}</span>
|
||||
{:else if column.id === 'neighbor'}
|
||||
<span class="font-mono text-sm">{p.neighbor}</span>
|
||||
{:else if column.id === 'remote_asn'}
|
||||
<span class="font-mono text-sm">{p.remote_asn ?? '—'}</span>
|
||||
{:else if column.id === 'enabled'}
|
||||
<div class="flex justify-center">
|
||||
<Switch
|
||||
size="sm"
|
||||
checked={p.enabled !== false}
|
||||
disabled={loading || toggleId === p.id}
|
||||
onCheckedChange={(v) => setEnabled(p, v)}
|
||||
/>
|
||||
</div>
|
||||
{:else if column.id === 'session_state'}
|
||||
<Badge variant={sessionBadge(p.session_state)}>{p.session_state || '—'}</Badge>
|
||||
{:else if column.id === 'speaker'}
|
||||
<span class="text-xs text-muted-foreground">{speakerLabelById(p.bgp_speaker_id)}</span>
|
||||
{:else if column.id === 'actions'}
|
||||
<div class="flex gap-1">
|
||||
<Button variant="ghost" size="icon-sm" onclick={() => openEdit(p)}>
|
||||
<Pencil class="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
class="text-destructive"
|
||||
onclick={() => requestDelete(p)}
|
||||
>
|
||||
<Trash2 class="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</AppDataTable>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Dialog bind:open={dialogOpen}>
|
||||
<DialogContent class="sm:max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editTarget ? 'Редактировать пира' : 'Новый пир'}</DialogTitle>
|
||||
<DialogDescription>BGP-сосед для установки сессии</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div class="space-y-4 py-2">
|
||||
<FormField label="Имя пира (опционально)" id="p-name">
|
||||
<AppInput id="p-name" placeholder="Core-RTR-1" bind:value={form.name} />
|
||||
</FormField>
|
||||
<FormField label="Адрес соседа" id="p-neighbor" required>
|
||||
<AppInput id="p-neighbor" placeholder="192.0.2.1" bind:value={form.neighbor} />
|
||||
</FormField>
|
||||
<FormField label="Remote ASN" id="p-asn" required>
|
||||
<AppInput id="p-asn" type="number" placeholder="65000" bind:value={form.remote_asn} />
|
||||
</FormField>
|
||||
<FormField label="Спикер (опционально)" id="p-speaker">
|
||||
<Select
|
||||
type="single"
|
||||
value={form.bgp_speaker_id ?? ''}
|
||||
onValueChange={(v) => {
|
||||
form = { ...form, bgp_speaker_id: v || null };
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id="p-speaker" class="w-full">
|
||||
{form.bgp_speaker_id ? speakerLabelById(form.bgp_speaker_id) : 'Не выбрано'}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">Не выбрано</SelectItem>
|
||||
{#each speakers as s (s.id)}
|
||||
<SelectItem value={s.id}>{s.endpoint} ({s.id.slice(0, 8)}…)</SelectItem>
|
||||
{/each}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormField>
|
||||
<div
|
||||
class="flex flex-row items-center justify-between gap-4 rounded-lg border border-border bg-muted/30 p-3"
|
||||
>
|
||||
<div class="grid min-w-0 flex-1 gap-1 pr-2">
|
||||
<Label for="p-enabled" class="leading-snug text-foreground">Включён</Label>
|
||||
<p class="text-xs leading-snug text-muted-foreground">
|
||||
Выключенный пир не попадает в конфиг BIRD до следующей ревизии.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="p-enabled"
|
||||
class="shrink-0"
|
||||
checked={form.enabled !== false}
|
||||
onCheckedChange={(v) => {
|
||||
form = { ...form, enabled: v };
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onclick={() => (dialogOpen = false)}>Отмена</Button>
|
||||
<Button onclick={save} disabled={saving}>
|
||||
{saving ? 'Сохранение…' : editTarget ? 'Сохранить' : 'Создать'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
@@ -0,0 +1,177 @@
|
||||
<script lang="ts">
|
||||
import { apiMutate } from '$lib/api/client.js';
|
||||
import type { SpeakerRow, BgpSpeakerCreate } from '$lib/api/types.js';
|
||||
import { Badge } from '$lib/ui/core/badge/index.js';
|
||||
import { Button } from '$lib/ui/core/button/index.js';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription
|
||||
} from '$lib/ui/core/card/index.js';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter
|
||||
} from '$lib/ui/core/dialog/index.js';
|
||||
import FormField from '$lib/ui/patterns/form/form-field.svelte';
|
||||
import AppInput from '$lib/ui/patterns/form/app-input.svelte';
|
||||
import AppDataTable from '$lib/ui/patterns/data-table/app-data-table.svelte';
|
||||
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
|
||||
import Plus from '@lucide/svelte/icons/plus';
|
||||
import Pencil from '@lucide/svelte/icons/pencil';
|
||||
import Play from '@lucide/svelte/icons/play';
|
||||
|
||||
type Props = {
|
||||
items: SpeakerRow[];
|
||||
loading?: boolean;
|
||||
initialLoading?: boolean;
|
||||
error?: string | null;
|
||||
onRefresh: () => void | Promise<void>;
|
||||
};
|
||||
|
||||
let { items, loading = false, initialLoading = false, error = null, onRefresh }: Props = $props();
|
||||
|
||||
let dialogOpen = $state(false);
|
||||
let editTarget = $state<SpeakerRow | null>(null);
|
||||
let form = $state<BgpSpeakerCreate>({ endpoint: '', role: 'operator' });
|
||||
let saving = $state(false);
|
||||
let applyingId = $state<string | null>(null);
|
||||
|
||||
const columns = [
|
||||
{
|
||||
id: 'endpoint',
|
||||
label: 'Endpoint',
|
||||
sortable: true,
|
||||
sortValue: (s: SpeakerRow) => s.endpoint
|
||||
},
|
||||
{ id: 'role', label: 'Роль', sortable: true, sortValue: (s: SpeakerRow) => s.role },
|
||||
{ id: 'last_applied_revision_id', label: 'Последняя ревизия' },
|
||||
{ id: 'actions', label: '', class: 'w-32' }
|
||||
] as const;
|
||||
|
||||
function openCreate() {
|
||||
editTarget = null;
|
||||
form = { endpoint: '', role: 'operator' };
|
||||
dialogOpen = true;
|
||||
}
|
||||
|
||||
function openEdit(s: SpeakerRow) {
|
||||
editTarget = s;
|
||||
form = { endpoint: s.endpoint, role: s.role };
|
||||
dialogOpen = true;
|
||||
}
|
||||
|
||||
async function applySpeaker(id: string) {
|
||||
applyingId = id;
|
||||
try {
|
||||
await apiMutate(`/v1/speakers/${id}/apply`, 'POST', {});
|
||||
notify.success('Apply запущен');
|
||||
} catch (e) {
|
||||
notifyApiError(e);
|
||||
} finally {
|
||||
applyingId = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!form.endpoint.trim()) {
|
||||
notify.error('Укажите endpoint');
|
||||
return;
|
||||
}
|
||||
saving = true;
|
||||
try {
|
||||
if (editTarget) {
|
||||
await apiMutate(`/v1/speakers/${editTarget.id}`, 'PATCH', form);
|
||||
notify.success('Спикер обновлён');
|
||||
} else {
|
||||
await apiMutate('/v1/speakers', 'POST', form);
|
||||
notify.success('Спикер создан');
|
||||
}
|
||||
dialogOpen = false;
|
||||
await onRefresh();
|
||||
} catch (e) {
|
||||
notifyApiError(e);
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Card>
|
||||
<CardHeader
|
||||
class="flex flex-col gap-3 border-b py-3 sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<div class="min-w-0 flex-1">
|
||||
<CardTitle class="text-base">Спикеры</CardTitle>
|
||||
<CardDescription>BIRD-агенты, применяющие конфигурацию на нодах</CardDescription>
|
||||
</div>
|
||||
<div class="flex shrink-0 flex-wrap items-center justify-end gap-2">
|
||||
<Button size="sm" onclick={openCreate}><Plus />Добавить</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent class="p-4 pt-0">
|
||||
<AppDataTable
|
||||
columns={[...columns]}
|
||||
rows={items}
|
||||
rowKey={(s) => s.id}
|
||||
loading={initialLoading || loading}
|
||||
{error}
|
||||
emptyTitle="Нет спикеров"
|
||||
emptyDescription="Добавьте BIRD-агент для применения конфигурации."
|
||||
>
|
||||
{#snippet cell({ row: s, column })}
|
||||
{#if column.id === 'endpoint'}
|
||||
<span class="font-mono text-sm">{s.endpoint}</span>
|
||||
{:else if column.id === 'role'}
|
||||
<Badge variant="outline">{s.role}</Badge>
|
||||
{:else if column.id === 'last_applied_revision_id'}
|
||||
<span class="font-mono text-xs text-muted-foreground">
|
||||
{s.last_applied_revision_id ? s.last_applied_revision_id.slice(0, 8) + '…' : '—'}
|
||||
</span>
|
||||
{:else if column.id === 'actions'}
|
||||
<div class="flex gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
title="Запустить применение ревизии на спикере"
|
||||
onclick={() => applySpeaker(s.id)}
|
||||
disabled={applyingId === s.id}
|
||||
>
|
||||
<Play class="size-3" />
|
||||
{applyingId === s.id ? 'Apply…' : 'Apply'}
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon-sm" onclick={() => openEdit(s)}>
|
||||
<Pencil class="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</AppDataTable>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Dialog bind:open={dialogOpen}>
|
||||
<DialogContent class="sm:max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editTarget ? 'Редактировать спикера' : 'Новый спикер'}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div class="space-y-4 py-2">
|
||||
<FormField label="Endpoint" id="s-endpoint" required>
|
||||
<AppInput id="s-endpoint" placeholder="http://bird-agent:8081" bind:value={form.endpoint} />
|
||||
</FormField>
|
||||
<FormField label="Роль" id="s-role">
|
||||
<AppInput id="s-role" placeholder="operator" bind:value={form.role} />
|
||||
</FormField>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onclick={() => (dialogOpen = false)}>Отмена</Button>
|
||||
<Button onclick={save} disabled={saving}>
|
||||
{saving ? 'Сохранение…' : editTarget ? 'Сохранить' : 'Создать'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
@@ -1,13 +1,15 @@
|
||||
<script lang="ts">
|
||||
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';
|
||||
} 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';
|
||||
|
||||
type Props = {
|
||||
@@ -19,7 +21,6 @@
|
||||
onDiffRevAChange: (value: string) => void;
|
||||
onDiffRevBChange: (value: string) => void;
|
||||
onLoadDiff: () => void;
|
||||
formatDate: (d?: string | null) => string;
|
||||
};
|
||||
|
||||
let {
|
||||
@@ -30,11 +31,13 @@
|
||||
diffLoading,
|
||||
onDiffRevAChange,
|
||||
onDiffRevBChange,
|
||||
onLoadDiff,
|
||||
formatDate
|
||||
onLoadDiff
|
||||
}: 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)[] {
|
||||
if (!d) return [];
|
||||
if (d.prefixes && Array.isArray(d.prefixes.added)) return d.prefixes.added;
|
||||
@@ -82,26 +85,36 @@
|
||||
</CardHeader>
|
||||
<CardContent class="min-h-0 space-y-4">
|
||||
<div class="flex flex-col gap-2 sm:flex-row">
|
||||
<select
|
||||
value={diffRevA}
|
||||
onchange={(e) => onDiffRevAChange((e.currentTarget as HTMLSelectElement).value)}
|
||||
class="h-8 min-w-0 flex-1 rounded-md border border-border bg-background px-2 text-sm"
|
||||
>
|
||||
<option value="">Ревизия A</option>
|
||||
{#each revisions as rev (rev.id)}
|
||||
<option value={rev.id}>{rev.id.slice(0, 8)}… ({formatDate(rev.created_at)})</option>
|
||||
{/each}
|
||||
</select>
|
||||
<select
|
||||
value={diffRevB}
|
||||
onchange={(e) => onDiffRevBChange((e.currentTarget as HTMLSelectElement).value)}
|
||||
class="h-8 min-w-0 flex-1 rounded-md border border-border bg-background px-2 text-sm"
|
||||
>
|
||||
<option value="">Ревизия B</option>
|
||||
{#each revisions as rev (rev.id)}
|
||||
<option value={rev.id}>{rev.id.slice(0, 8)}… ({formatDate(rev.created_at)})</option>
|
||||
{/each}
|
||||
</select>
|
||||
<Select type="single" value={diffRevA} onValueChange={(v) => onDiffRevAChange(v ?? '')}>
|
||||
<SelectTrigger class="min-w-0 flex-1">
|
||||
{diffRevA
|
||||
? revisions.find((r) => r.id === diffRevA)
|
||||
? revisionLabel(revisions.find((r) => r.id === diffRevA)!)
|
||||
: diffRevA
|
||||
: 'Ревизия A'}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">Ревизия A</SelectItem>
|
||||
{#each revisions as rev (rev.id)}
|
||||
<SelectItem value={rev.id}>{revisionLabel(rev)}</SelectItem>
|
||||
{/each}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select type="single" value={diffRevB} onValueChange={(v) => onDiffRevBChange(v ?? '')}>
|
||||
<SelectTrigger class="min-w-0 flex-1">
|
||||
{diffRevB
|
||||
? revisions.find((r) => r.id === diffRevB)
|
||||
? revisionLabel(revisions.find((r) => r.id === diffRevB)!)
|
||||
: diffRevB
|
||||
: 'Ревизия B'}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">Ревизия B</SelectItem>
|
||||
{#each revisions as rev (rev.id)}
|
||||
<SelectItem value={rev.id}>{revisionLabel(rev)}</SelectItem>
|
||||
{/each}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
size="sm"
|
||||
class="shrink-0 self-start sm:self-auto"
|
||||
@@ -117,10 +130,9 @@
|
||||
class="grid min-h-0 grid-cols-1 overflow-hidden rounded-md border border-border sm:grid-cols-2"
|
||||
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="flex shrink-0 items-center border-b border-border bg-muted/60 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="mr-2 w-10 shrink-0 text-right text-muted-foreground select-none">+</span>
|
||||
<span>Добавлено ({addedSorted.length})</span>
|
||||
@@ -137,7 +149,7 @@
|
||||
<tbody>
|
||||
{#each addedSorted as line, i (`a-${i}-${line}`)}
|
||||
<tr
|
||||
class="border-b border-l-2 border-border/50 border-l-green-500/80 bg-green-500/[0.08] hover:bg-muted/30 dark:bg-green-500/15"
|
||||
class="border-b border-l-2 border-success/30 bg-success/5 hover:bg-muted/30"
|
||||
>
|
||||
<td
|
||||
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"
|
||||
@@ -157,10 +169,9 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- − Удалено -->
|
||||
<div class="flex min-h-0 min-w-0 flex-col">
|
||||
<div
|
||||
class="flex shrink-0 items-center border-b border-border bg-muted/60 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="mr-2 w-10 shrink-0 text-right text-muted-foreground select-none">−</span>
|
||||
<span>Удалено ({removedSorted.length})</span>
|
||||
@@ -177,7 +188,7 @@
|
||||
<tbody>
|
||||
{#each removedSorted as line, i (`r-${i}-${line}`)}
|
||||
<tr
|
||||
class="border-b border-l-2 border-border/50 border-l-red-500/80 bg-red-500/[0.08] hover:bg-muted/30 dark:bg-red-500/15"
|
||||
class="border-b border-l-2 border-destructive/30 bg-destructive/5 hover:bg-muted/30"
|
||||
>
|
||||
<td
|
||||
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"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/ui/button/index.js';
|
||||
import { Input } from '$lib/components/ui/input/index.js';
|
||||
import { Label } from '$lib/components/ui/label/index.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 Filter from '@lucide/svelte/icons/filter';
|
||||
import Search from '@lucide/svelte/icons/search';
|
||||
import X from '@lucide/svelte/icons/x';
|
||||
|
||||
@@ -2,15 +2,17 @@
|
||||
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
|
||||
import type { JobRow } from '$lib/api/types.js';
|
||||
import type { JobDetailedReport, JobLogEntry } from './types.js';
|
||||
import { Badge } from '$lib/components/ui/badge/index.js';
|
||||
import { Button } from '$lib/components/ui/button/index.js';
|
||||
import { Badge } from '$lib/ui/core/badge/index.js';
|
||||
import { Button } from '$lib/ui/core/button/index.js';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
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 Eye from '@lucide/svelte/icons/eye';
|
||||
import X from '@lucide/svelte/icons/x';
|
||||
@@ -49,7 +51,6 @@
|
||||
getJobLogEntries: (job: JobRow) => JobLogEntry[];
|
||||
getJobLogTotal: (job: JobRow, entries?: JobLogEntry[]) => number;
|
||||
jobStatusVariant: (status: string) => 'default' | 'secondary' | 'outline' | 'destructive';
|
||||
formatDate: (d?: string | null) => string;
|
||||
};
|
||||
|
||||
let {
|
||||
@@ -69,8 +70,7 @@
|
||||
isJobExpanded,
|
||||
getJobLogEntries,
|
||||
getJobLogTotal,
|
||||
jobStatusVariant,
|
||||
formatDate
|
||||
jobStatusVariant
|
||||
}: Props = $props();
|
||||
|
||||
const reportCols = reportRowColumns as import('@tanstack/table-core').ColumnDef<
|
||||
@@ -178,7 +178,7 @@
|
||||
<CalendarClock class="size-3.5 shrink-0" aria-hidden="true" />
|
||||
Создана
|
||||
</p>
|
||||
<p class="mt-1">{formatDate(job.created_at)}</p>
|
||||
<p class="mt-1">{formatDateTime(job.created_at)}</p>
|
||||
</div>
|
||||
<div
|
||||
class="rounded-md border border-chart-3/25 bg-chart-3/5 px-2.5 py-2 dark:bg-chart-3/10"
|
||||
@@ -187,7 +187,7 @@
|
||||
<PlayCircle class="size-3.5 shrink-0" aria-hidden="true" />
|
||||
Запущена
|
||||
</p>
|
||||
<p class="mt-1">{formatDate(job.started_at)}</p>
|
||||
<p class="mt-1">{formatDateTime(job.started_at)}</p>
|
||||
</div>
|
||||
<div
|
||||
class="rounded-md border border-chart-4/25 bg-chart-4/5 px-2.5 py-2 dark:bg-chart-4/10"
|
||||
@@ -196,7 +196,7 @@
|
||||
<Flag class="size-3.5 shrink-0" aria-hidden="true" />
|
||||
Завершена
|
||||
</p>
|
||||
<p class="mt-1">{formatDate(job.finished_at)}</p>
|
||||
<p class="mt-1">{formatDateTime(job.finished_at)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -450,9 +450,14 @@
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="text-muted-foreground py-10 text-center text-sm">
|
||||
{jobsLoading ? 'Загрузка…' : 'Нет задач'}
|
||||
</div>
|
||||
{#if jobsLoading}
|
||||
<div class="py-8 text-center text-sm text-muted-foreground">Загрузка…</div>
|
||||
{:else}
|
||||
<EmptyState
|
||||
title="Нет задач"
|
||||
description="Задачи появятся после refresh, apply или rollback."
|
||||
/>
|
||||
{/if}
|
||||
{/each}
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -1,8 +1,8 @@
|
||||
<script lang="ts">
|
||||
import type { BirdStatus } from '$lib/api/types.js';
|
||||
import { Badge } from '$lib/components/ui/badge/index.js';
|
||||
import { Button } from '$lib/components/ui/button/index.js';
|
||||
import { Card } from '$lib/components/ui/card/index.js';
|
||||
import { Badge } from '$lib/ui/core/badge/index.js';
|
||||
import { Button } from '$lib/ui/core/button/index.js';
|
||||
import { Card } from '$lib/ui/core/card/index.js';
|
||||
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
|
||||
import Play from '@lucide/svelte/icons/play';
|
||||
import RotateCcw from '@lucide/svelte/icons/rotate-ccw';
|
||||
@@ -114,9 +114,14 @@
|
||||
</div>
|
||||
<p class="font-semibold">Состояние BIRD</p>
|
||||
{#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}
|
||||
</div>
|
||||
<p class="max-w-[56ch] text-sm text-foreground/80">
|
||||
|
||||
@@ -1,21 +1,15 @@
|
||||
<script lang="ts">
|
||||
import type { 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 {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow
|
||||
} from '$lib/components/ui/table/index.js';
|
||||
} from '$lib/ui/core/card/index.js';
|
||||
import AppDataTable from '$lib/ui/patterns/data-table/app-data-table.svelte';
|
||||
import { formatDateTime } from '$lib/modules/display.js';
|
||||
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
|
||||
import Undo from '@lucide/svelte/icons/undo';
|
||||
import Eye from '@lucide/svelte/icons/eye';
|
||||
@@ -28,7 +22,6 @@
|
||||
onOpenPreview: (rev: RevisionRow) => void;
|
||||
onRollbackRequest: (rev: RevisionRow) => void;
|
||||
onDownloadDiagnosticLog: (rev: RevisionRow) => void;
|
||||
formatDate: (d?: string | null) => string;
|
||||
};
|
||||
|
||||
let {
|
||||
@@ -37,9 +30,31 @@
|
||||
onReload,
|
||||
onOpenPreview,
|
||||
onRollbackRequest,
|
||||
onDownloadDiagnosticLog,
|
||||
formatDate
|
||||
onDownloadDiagnosticLog
|
||||
}: Props = $props();
|
||||
|
||||
const columns = [
|
||||
{
|
||||
id: 'id',
|
||||
label: 'ID',
|
||||
sortable: true,
|
||||
sortValue: (rev: RevisionRow) => rev.id
|
||||
},
|
||||
{
|
||||
id: 'created',
|
||||
label: 'Создана',
|
||||
sortable: true,
|
||||
sortValue: (rev: RevisionRow) => rev.created_at ?? ''
|
||||
},
|
||||
{
|
||||
id: 'prefixes',
|
||||
label: 'Префиксов',
|
||||
sortable: true,
|
||||
sortValue: (rev: RevisionRow) => rev.materialized_prefix_count ?? 0
|
||||
},
|
||||
{ id: 'hash', label: 'Хэш' },
|
||||
{ id: 'actions', label: '', class: 'w-32' }
|
||||
] as const;
|
||||
</script>
|
||||
|
||||
<Card>
|
||||
@@ -61,55 +76,45 @@
|
||||
<RefreshCw class={revLoading ? 'animate-spin' : ''} />
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent class="min-w-0 p-0">
|
||||
<div class="max-w-full overflow-x-auto overscroll-x-contain [scrollbar-gutter:stable]">
|
||||
<Table class="min-w-[44rem]">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>ID</TableHead>
|
||||
<TableHead>Создана</TableHead>
|
||||
<TableHead>Префиксов</TableHead>
|
||||
<TableHead>Хэш</TableHead>
|
||||
<TableHead class="w-32"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{#each revisions as rev (rev.id)}
|
||||
<TableRow>
|
||||
<TableCell class="font-mono text-xs">{rev.id.slice(0, 8)}…</TableCell>
|
||||
<TableCell class="text-sm">{formatDate(rev.created_at)}</TableCell>
|
||||
<TableCell>{rev.materialized_prefix_count}</TableCell>
|
||||
<TableCell class="font-mono text-xs text-muted-foreground"
|
||||
>{rev.content_hash.slice(0, 12)}…</TableCell
|
||||
>
|
||||
<TableCell>
|
||||
<div class="flex gap-1">
|
||||
<Button variant="ghost" size="icon-sm" onclick={() => onOpenPreview(rev)}>
|
||||
<Eye class="size-3.5" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon-sm" onclick={() => onRollbackRequest(rev)}>
|
||||
<Undo class="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
title="Скачать диагностический лог"
|
||||
onclick={() => onDownloadDiagnosticLog(rev)}
|
||||
>
|
||||
<Download class="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{:else}
|
||||
<TableRow>
|
||||
<TableCell colspan={5} class="text-muted-foreground py-8 text-center">
|
||||
{revLoading ? 'Загрузка…' : 'Нет ревизий'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{/each}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<CardContent class="min-w-0 p-4 pt-0">
|
||||
<AppDataTable
|
||||
columns={[...columns]}
|
||||
rows={revisions}
|
||||
rowKey={(rev) => rev.id}
|
||||
loading={revLoading}
|
||||
emptyTitle="Нет ревизий"
|
||||
emptyDescription="Ревизии появятся после обновления модулей."
|
||||
>
|
||||
{#snippet cell({ row: rev, column })}
|
||||
{#if column.id === 'id'}
|
||||
<span class="font-mono text-xs">{rev.id.slice(0, 8)}…</span>
|
||||
{:else if column.id === 'created'}
|
||||
<span class="text-sm">{formatDateTime(rev.created_at)}</span>
|
||||
{:else if column.id === 'prefixes'}
|
||||
{rev.materialized_prefix_count}
|
||||
{:else if column.id === 'hash'}
|
||||
<span class="font-mono text-xs text-muted-foreground"
|
||||
>{rev.content_hash.slice(0, 12)}…</span
|
||||
>
|
||||
{:else if column.id === 'actions'}
|
||||
<div class="flex gap-1">
|
||||
<Button variant="ghost" size="icon-sm" onclick={() => onOpenPreview(rev)}>
|
||||
<Eye class="size-3.5" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon-sm" onclick={() => onRollbackRequest(rev)}>
|
||||
<Undo class="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
title="Скачать диагностический лог"
|
||||
onclick={() => onDownloadDiagnosticLog(rev)}
|
||||
>
|
||||
<Download class="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</AppDataTable>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -0,0 +1,223 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { defaults, superForm } from 'sveltekit-superforms';
|
||||
import { zod4 } from 'sveltekit-superforms/adapters';
|
||||
import {
|
||||
emptyRevisionSettingsForm,
|
||||
revisionSettingsSchema
|
||||
} from '$lib/settings/revision-settings.schema.js';
|
||||
import {
|
||||
buildPayloadFromFormFields,
|
||||
loadSettings,
|
||||
partitionSettings,
|
||||
patchSettings,
|
||||
type AdditionalSettingEntry
|
||||
} from '$lib/settings/settings-api.js';
|
||||
import { REVISION_SETTING_KEYS } from '$lib/settings/settings-known-keys.js';
|
||||
import { Button } from '$lib/ui/core/button/index.js';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle
|
||||
} from '$lib/ui/core/card/index.js';
|
||||
import { Input } from '$lib/ui/core/input/index.js';
|
||||
import { Alert, AlertDescription, AlertTitle } from '$lib/ui/core/alert/index.js';
|
||||
import FormField from '$lib/ui/patterns/form/form-field.svelte';
|
||||
import EmptyState from '$lib/ui/patterns/empty-state/empty-state.svelte';
|
||||
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
|
||||
import Save from '@lucide/svelte/icons/save';
|
||||
import Plus from '@lucide/svelte/icons/plus';
|
||||
import Trash2 from '@lucide/svelte/icons/trash-2';
|
||||
import Info from '@lucide/svelte/icons/info';
|
||||
|
||||
let loading = $state(false);
|
||||
let saving = $state(false);
|
||||
let loaded = $state(false);
|
||||
let additionalSettings = $state<AdditionalSettingEntry[]>([]);
|
||||
let additionalIdCounter = $state(1);
|
||||
|
||||
const { form, errors, reset, validateForm } = superForm(
|
||||
defaults(emptyRevisionSettingsForm(), zod4(revisionSettingsSchema)),
|
||||
{
|
||||
validators: zod4(revisionSettingsSchema),
|
||||
SPA: true,
|
||||
dataType: 'json'
|
||||
}
|
||||
);
|
||||
|
||||
let hasValidationErrors = $derived(Boolean($errors.revision_retention_minutes?.length));
|
||||
|
||||
function addAdditionalSetting() {
|
||||
additionalSettings.push({ id: additionalIdCounter++, key: '', value: '' });
|
||||
}
|
||||
|
||||
function removeAdditionalSetting(id: number) {
|
||||
additionalSettings = additionalSettings.filter((entry) => entry.id !== id);
|
||||
}
|
||||
|
||||
let canSave = $derived.by(() => {
|
||||
if (loading || saving || hasValidationErrors || !loaded) return false;
|
||||
|
||||
const hasRetention = String($form.revision_retention_minutes ?? '').trim() !== '';
|
||||
const hasAdditional = additionalSettings.some((entry) => entry.key.trim() !== '');
|
||||
|
||||
return hasRetention || hasAdditional;
|
||||
});
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
try {
|
||||
const settings = await loadSettings();
|
||||
const { partitioned, nextId } = partitionSettings(settings, additionalIdCounter);
|
||||
reset({ data: partitioned.revision });
|
||||
additionalSettings = partitioned.additional;
|
||||
additionalIdCounter = nextId;
|
||||
loaded = true;
|
||||
} catch (e) {
|
||||
notifyApiError(e);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
const validation = await validateForm({ update: true });
|
||||
if (!validation.valid) {
|
||||
notify.error('Исправьте ошибки в полях настроек');
|
||||
return;
|
||||
}
|
||||
if (!canSave) {
|
||||
notify.error('Нечего сохранять или есть ошибки в полях');
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = buildPayloadFromFormFields(
|
||||
REVISION_SETTING_KEYS,
|
||||
$form as Record<string, string>,
|
||||
$errors as Partial<Record<string, string[]>>
|
||||
);
|
||||
|
||||
for (const entry of additionalSettings) {
|
||||
const key = entry.key.trim();
|
||||
if (!key) continue;
|
||||
payload[key] = entry.value;
|
||||
}
|
||||
|
||||
saving = true;
|
||||
try {
|
||||
await patchSettings(payload);
|
||||
notify.success('Системные настройки сохранены');
|
||||
await load();
|
||||
} catch (e) {
|
||||
notifyApiError(e);
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
void load();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-6">
|
||||
<Alert class="border-info/30 bg-info/5">
|
||||
<Info class="text-info" />
|
||||
<AlertTitle>Operator-only</AlertTitle>
|
||||
<AlertDescription>
|
||||
Изменение параметров через <code class="text-xs">PATCH /v1/settings</code> требует роли operator.
|
||||
При отсутствии прав API вернёт 403.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Хранение ревизий</CardTitle>
|
||||
<CardDescription>
|
||||
Автоматическая очистка старых ревизий. Последняя раскатанная ревизия не удаляется.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{#if loading && !loaded}
|
||||
<p class="text-sm text-muted-foreground">Загрузка…</p>
|
||||
{:else if !loaded}
|
||||
<Button variant="outline" onclick={load}>Загрузить настройки</Button>
|
||||
{:else}
|
||||
<FormField
|
||||
id="revision-retention-minutes"
|
||||
label="Время жизни ревизий, мин (revision_retention_minutes)"
|
||||
error={$errors.revision_retention_minutes?.[0]}
|
||||
description="Допустимый диапазон: 15–43200 минут."
|
||||
>
|
||||
<Input
|
||||
id="revision-retention-minutes"
|
||||
type="number"
|
||||
min="15"
|
||||
max="43200"
|
||||
bind:value={$form.revision_retention_minutes}
|
||||
placeholder="43200"
|
||||
/>
|
||||
</FormField>
|
||||
{/if}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div class="space-y-1">
|
||||
<CardTitle>Дополнительные параметры</CardTitle>
|
||||
<CardDescription>Произвольные KV-пары в global_settings.</CardDescription>
|
||||
</div>
|
||||
{#if loaded}
|
||||
<Button variant="outline" size="sm" onclick={addAdditionalSetting}>
|
||||
<Plus class="size-4" />
|
||||
Добавить строку
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{#if !loaded}
|
||||
<p class="text-sm text-muted-foreground">Загрузите настройки выше.</p>
|
||||
{:else if additionalSettings.length === 0}
|
||||
<EmptyState
|
||||
title="Нет дополнительных параметров"
|
||||
description="Добавьте KV-пару при необходимости."
|
||||
/>
|
||||
{:else}
|
||||
<div class="space-y-2">
|
||||
{#each additionalSettings as entry (entry.id)}
|
||||
<div class="grid grid-cols-1 gap-2 md:grid-cols-[1fr_1fr_auto]">
|
||||
<Input bind:value={entry.key} placeholder="Ключ (например, bird_log_level)" />
|
||||
<Input bind:value={entry.value} placeholder="Значение (строка)" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="Удалить строку"
|
||||
onclick={() => removeAdditionalSetting(entry.id)}
|
||||
>
|
||||
<Trash2 class="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{#if loaded}
|
||||
{#if hasValidationErrors}
|
||||
<p class="text-sm text-destructive">
|
||||
Есть ошибки в полях. Исправьте их, чтобы сохранить изменения.
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<Button onclick={save} disabled={!canSave}>
|
||||
<Save />
|
||||
{saving ? 'Сохранение…' : 'Применить настройки'}
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -6,9 +6,9 @@
|
||||
getCoreRowModel,
|
||||
getPaginationRowModel
|
||||
} from '@tanstack/table-core';
|
||||
import { createSvelteTable, FlexRender } from '$lib/components/ui/data-table/index.js';
|
||||
import * as Table from '$lib/components/ui/table/index.js';
|
||||
import { Button } from '$lib/components/ui/button/index.js';
|
||||
import { createSvelteTable, FlexRender } from '$lib/ui/core/data-table/index.js';
|
||||
import * as Table from '$lib/ui/core/table/index.js';
|
||||
import { Button } from '$lib/ui/core/button/index.js';
|
||||
|
||||
type Props = {
|
||||
rows: RowData[];
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
<script lang="ts">
|
||||
import { resolve } from '$app/paths';
|
||||
import type { JobRow } from '$lib/api/types.js';
|
||||
import { formatDateTime } from '$lib/modules/display.js';
|
||||
import { jobKindTitle } from '$lib/operations/job-kind-label.js';
|
||||
import { jobStatusRu, jobStatusBadgeVariant } from '$lib/ui-labels.js';
|
||||
import { Badge } from '$lib/ui/core/badge/index.js';
|
||||
import { Button } from '$lib/ui/core/button/index.js';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription
|
||||
} from '$lib/ui/core/card/index.js';
|
||||
import AppDataTable from '$lib/ui/patterns/data-table/app-data-table.svelte';
|
||||
import ArrowRight from '@lucide/svelte/icons/arrow-right';
|
||||
import ExternalLink from '@lucide/svelte/icons/external-link';
|
||||
|
||||
type Props = {
|
||||
items: JobRow[];
|
||||
moduleNameById: ReadonlyMap<string, string>;
|
||||
loading?: boolean;
|
||||
initialLoading?: boolean;
|
||||
error?: string | null;
|
||||
};
|
||||
|
||||
let {
|
||||
items,
|
||||
moduleNameById,
|
||||
loading = false,
|
||||
initialLoading = false,
|
||||
error = null
|
||||
}: Props = $props();
|
||||
|
||||
const columns = [
|
||||
{ id: 'kind', label: 'Вид', sortable: true, sortValue: (j: JobRow) => j.kind },
|
||||
{
|
||||
id: 'status',
|
||||
label: 'Статус',
|
||||
sortable: true,
|
||||
sortValue: (j: JobRow) => j.status
|
||||
},
|
||||
{
|
||||
id: 'created',
|
||||
label: 'Создана',
|
||||
sortable: true,
|
||||
sortValue: (j: JobRow) => j.created_at ?? ''
|
||||
},
|
||||
{ id: 'actions', label: '', class: 'w-10' }
|
||||
] as const;
|
||||
</script>
|
||||
|
||||
<Card>
|
||||
<CardHeader
|
||||
class="flex flex-col gap-3 border-b py-3 sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<div class="min-w-0 flex-1">
|
||||
<CardTitle class="text-base">Последние задачи</CardTitle>
|
||||
<CardDescription>Фоновые задачи ingest, refresh и apply</CardDescription>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" href={resolve('/operations?tab=jobs')}>
|
||||
Все
|
||||
<ArrowRight class="size-3.5" />
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent class="p-4 pt-0">
|
||||
<AppDataTable
|
||||
columns={[...columns]}
|
||||
rows={items}
|
||||
rowKey={(j) => j.job_id}
|
||||
loading={initialLoading || loading}
|
||||
{error}
|
||||
emptyTitle="Нет задач"
|
||||
emptyDescription="Задачи появятся после refresh или деплоя."
|
||||
>
|
||||
{#snippet cell({ row: j, column })}
|
||||
{#if column.id === 'kind'}
|
||||
<span class="font-medium">{jobKindTitle(j, moduleNameById)}</span>
|
||||
{:else if column.id === 'status'}
|
||||
<Badge variant={jobStatusBadgeVariant(j.status)}>{jobStatusRu(j.status)}</Badge>
|
||||
{:else if column.id === 'created'}
|
||||
<span class="text-xs whitespace-nowrap text-muted-foreground">
|
||||
{formatDateTime(j.created_at)}
|
||||
</span>
|
||||
{:else if column.id === 'actions'}
|
||||
<Button variant="ghost" size="icon-sm" href={resolve('/operations?tab=jobs')}>
|
||||
<ExternalLink class="size-3.5" />
|
||||
</Button>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</AppDataTable>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -0,0 +1,89 @@
|
||||
<script lang="ts">
|
||||
import { resolve } from '$app/paths';
|
||||
import type { RevisionRow } from '$lib/api/types.js';
|
||||
import { formatDateTime } from '$lib/modules/display.js';
|
||||
import { Button } from '$lib/ui/core/button/index.js';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription
|
||||
} from '$lib/ui/core/card/index.js';
|
||||
import AppDataTable from '$lib/ui/patterns/data-table/app-data-table.svelte';
|
||||
import ArrowRight from '@lucide/svelte/icons/arrow-right';
|
||||
import ExternalLink from '@lucide/svelte/icons/external-link';
|
||||
|
||||
type Props = {
|
||||
items: RevisionRow[];
|
||||
loading?: boolean;
|
||||
initialLoading?: boolean;
|
||||
error?: string | null;
|
||||
};
|
||||
|
||||
let { items, loading = false, initialLoading = false, error = null }: Props = $props();
|
||||
|
||||
const columns = [
|
||||
{
|
||||
id: 'id',
|
||||
label: 'ID',
|
||||
sortable: true,
|
||||
sortValue: (rev: RevisionRow) => rev.id
|
||||
},
|
||||
{
|
||||
id: 'created',
|
||||
label: 'Создана',
|
||||
sortable: true,
|
||||
sortValue: (rev: RevisionRow) => rev.created_at ?? ''
|
||||
},
|
||||
{
|
||||
id: 'prefixes',
|
||||
label: 'Префиксов',
|
||||
sortable: true,
|
||||
sortValue: (rev: RevisionRow) => rev.materialized_prefix_count ?? 0
|
||||
},
|
||||
{ id: 'actions', label: '', class: 'w-10' }
|
||||
] as const;
|
||||
</script>
|
||||
|
||||
<Card>
|
||||
<CardHeader
|
||||
class="flex flex-col gap-3 border-b py-3 sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<div class="min-w-0 flex-1">
|
||||
<CardTitle class="text-base">Последние ревизии</CardTitle>
|
||||
<CardDescription>Снимки конфигурации BIRD после обновления модулей</CardDescription>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" href={resolve('/operations')}>
|
||||
Все
|
||||
<ArrowRight class="size-3.5" />
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent class="p-4 pt-0">
|
||||
<AppDataTable
|
||||
columns={[...columns]}
|
||||
rows={items}
|
||||
rowKey={(rev) => rev.id}
|
||||
loading={initialLoading || loading}
|
||||
{error}
|
||||
emptyTitle="Нет ревизий"
|
||||
emptyDescription="Ревизии появятся после обновления модулей."
|
||||
>
|
||||
{#snippet cell({ row: rev, column })}
|
||||
{#if column.id === 'id'}
|
||||
<span class="font-mono text-xs">{rev.id.slice(0, 8)}…</span>
|
||||
{:else if column.id === 'created'}
|
||||
<span class="text-sm whitespace-nowrap text-muted-foreground">
|
||||
{formatDateTime(rev.created_at)}
|
||||
</span>
|
||||
{:else if column.id === 'prefixes'}
|
||||
<span class="tabular-nums">{rev.materialized_prefix_count}</span>
|
||||
{:else if column.id === 'actions'}
|
||||
<Button variant="ghost" size="icon-sm" href={resolve('/operations')}>
|
||||
<ExternalLink class="size-3.5" />
|
||||
</Button>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</AppDataTable>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { ModuleRow } from '$lib/api/types.js';
|
||||
|
||||
/** Форматирование ISO-даты для таблиц модулей и расписания. */
|
||||
export function formatDateTime(value: string | null | undefined): string {
|
||||
if (typeof value !== 'string' || value.trim().length === 0) return '—';
|
||||
const parsed = new Date(value);
|
||||
if (Number.isNaN(parsed.getTime())) return '—';
|
||||
return parsed.toLocaleString('ru-RU');
|
||||
}
|
||||
|
||||
/** Подпись интервала refresh: секунды, cron или комбинация. */
|
||||
export function moduleIntervalLabel(moduleRow: ModuleRow): string {
|
||||
const cron = typeof moduleRow.cron_expr === 'string' ? moduleRow.cron_expr.trim() : '';
|
||||
const raw = moduleRow.refresh_interval_sec as unknown;
|
||||
const interval =
|
||||
typeof raw === 'number'
|
||||
? raw
|
||||
: typeof raw === 'string' && raw.trim().length > 0
|
||||
? Number(raw)
|
||||
: null;
|
||||
const intervalLabel = interval !== null && Number.isFinite(interval) ? `${interval}с` : '';
|
||||
if (cron && intervalLabel) return `${intervalLabel} (${cron})`;
|
||||
if (cron) return cron;
|
||||
if (intervalLabel) return intervalLabel;
|
||||
return '—';
|
||||
}
|
||||
|
||||
export function moduleTypeBadgeVariant(
|
||||
type: string
|
||||
): 'default' | 'secondary' | 'outline' | 'destructive' {
|
||||
switch (type) {
|
||||
case 'AS_PREFIXES':
|
||||
return 'default';
|
||||
case 'CDN_CIDRS':
|
||||
return 'secondary';
|
||||
case 'DOMAINS':
|
||||
return 'outline';
|
||||
case 'IP_RANGES':
|
||||
return 'outline';
|
||||
default:
|
||||
return 'outline';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
/** Pure-helpers для страницы /monitoring. */
|
||||
|
||||
export type OverallStatus = 'ok' | 'warn' | 'error' | 'unknown';
|
||||
|
||||
export type JobsKpi = { running: number; failed: number; total: number };
|
||||
|
||||
export type ReadyStatus = { status: string; checks?: Record<string, unknown> };
|
||||
|
||||
export type HealthStatus = { ok: boolean; status?: string; error?: string };
|
||||
|
||||
export type CheckBadge = {
|
||||
label: string;
|
||||
variant: 'default' | 'secondary' | 'destructive' | 'outline';
|
||||
class?: string;
|
||||
hint?: string;
|
||||
};
|
||||
|
||||
export function toErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error && error.message.trim() !== '') return error.message;
|
||||
return 'Ошибка запроса';
|
||||
}
|
||||
|
||||
export function summarizeJobsStatuses(items: Array<{ status?: string }>): JobsKpi {
|
||||
let running = 0;
|
||||
let failed = 0;
|
||||
for (const row of items) {
|
||||
const status = String(row.status ?? '').toLowerCase();
|
||||
if (status === 'queued' || status === 'running' || status === 'cancel_requested') {
|
||||
running += 1;
|
||||
}
|
||||
if (status === 'failed' || status === 'error' || status === 'canceled') {
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
return { running, failed, total: items.length };
|
||||
}
|
||||
|
||||
export function deriveOverallStatus(input: {
|
||||
health: HealthStatus | null;
|
||||
ready: ReadyStatus | null;
|
||||
jobs: JobsKpi | null;
|
||||
birdConfigured: boolean;
|
||||
birdHealthy: boolean | null;
|
||||
}): OverallStatus {
|
||||
const { health, ready, jobs, birdConfigured, birdHealthy } = input;
|
||||
if (health === null && ready === null && jobs === null) return 'unknown';
|
||||
if (!health?.ok) return 'error';
|
||||
if (ready !== null && ready.status !== 'ready') return 'error';
|
||||
if (jobs !== null && jobs.failed > 0) return 'warn';
|
||||
if (birdConfigured && birdHealthy === false) return 'warn';
|
||||
return 'ok';
|
||||
}
|
||||
|
||||
export function overallStatusLabel(status: OverallStatus): string {
|
||||
switch (status) {
|
||||
case 'ok':
|
||||
return 'В норме';
|
||||
case 'warn':
|
||||
return 'Внимание';
|
||||
case 'error':
|
||||
return 'Ошибка';
|
||||
default:
|
||||
return 'Нет данных';
|
||||
}
|
||||
}
|
||||
|
||||
export function overallStatusHint(
|
||||
status: OverallStatus,
|
||||
input: {
|
||||
healthOk: boolean | undefined;
|
||||
jobsFailed: number;
|
||||
}
|
||||
): string {
|
||||
if (status === 'unknown') return 'Нет данных. Запустите обновление.';
|
||||
if (status === 'error') {
|
||||
if (!input.healthOk) return 'Проверьте доступность API и логи сервиса.';
|
||||
return 'Readiness не в норме: проверьте postgres/store/jobs.';
|
||||
}
|
||||
if (status === 'warn') {
|
||||
if (input.jobsFailed > 0) return 'Есть ошибки в задачах: откройте операции и последние jobs.';
|
||||
return 'Проверьте BGP-сессии и вывод birdc.';
|
||||
}
|
||||
return 'Критичных отклонений не обнаружено.';
|
||||
}
|
||||
|
||||
export function overallBadgeVariant(status: OverallStatus): CheckBadge['variant'] {
|
||||
if (status === 'ok') return 'default';
|
||||
if (status === 'warn') return 'secondary';
|
||||
if (status === 'error') return 'destructive';
|
||||
return 'outline';
|
||||
}
|
||||
|
||||
export function overallBadgeClass(status: OverallStatus): string | undefined {
|
||||
if (status === 'ok') return 'border-success/30 bg-success/15 text-success';
|
||||
if (status === 'warn') return 'border-warning/30 bg-warning/15 text-warning';
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Нормализация значений ready.checks и health/readiness в badge. */
|
||||
export function checkStatusBadge(value: unknown): CheckBadge {
|
||||
const raw = String(value ?? '').trim();
|
||||
const lower = raw.toLowerCase();
|
||||
|
||||
if (lower === 'ok' || lower === 'ready' || lower === 'true' || lower === 'up') {
|
||||
return {
|
||||
label: 'OK',
|
||||
variant: 'default',
|
||||
class: 'border-success/30 bg-success/15 text-success'
|
||||
};
|
||||
}
|
||||
if (lower === 'memory') {
|
||||
return {
|
||||
label: 'In-memory',
|
||||
variant: 'secondary',
|
||||
hint: 'Очередь задач в памяти процесса, не shared между воркерами.'
|
||||
};
|
||||
}
|
||||
if (
|
||||
lower === 'failed' ||
|
||||
lower === 'false' ||
|
||||
lower === 'error' ||
|
||||
lower === 'down' ||
|
||||
lower === 'unavailable'
|
||||
) {
|
||||
return { label: 'Ошибка', variant: 'destructive' };
|
||||
}
|
||||
if (raw === '') {
|
||||
return { label: '—', variant: 'outline' };
|
||||
}
|
||||
return { label: raw, variant: 'outline' };
|
||||
}
|
||||
|
||||
/** Человекочитаемое имя проверки readiness. */
|
||||
export function checkDisplayName(key: string): string {
|
||||
switch (key) {
|
||||
case 'postgres':
|
||||
return 'PostgreSQL';
|
||||
case 'store':
|
||||
return 'Хранилище';
|
||||
case 'jobs':
|
||||
return 'Очередь задач';
|
||||
default:
|
||||
return key;
|
||||
}
|
||||
}
|
||||
|
||||
export function livenessBadge(health: HealthStatus | null): CheckBadge {
|
||||
if (health === null) return { label: '—', variant: 'outline' };
|
||||
if (health.ok) {
|
||||
return {
|
||||
label: 'В норме',
|
||||
variant: 'default',
|
||||
class: 'border-success/30 bg-success/15 text-success'
|
||||
};
|
||||
}
|
||||
return { label: 'Недоступен', variant: 'destructive' };
|
||||
}
|
||||
|
||||
export function readinessBadge(ready: ReadyStatus | null): CheckBadge {
|
||||
if (ready === null) return { label: '—', variant: 'outline' };
|
||||
if (ready.status === 'ready') {
|
||||
return {
|
||||
label: 'Готов',
|
||||
variant: 'default',
|
||||
class: 'border-success/30 bg-success/15 text-success'
|
||||
};
|
||||
}
|
||||
return { label: ready.status || 'Не готов', variant: 'destructive' };
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { z } from 'zod';
|
||||
import { optionalIPv4, optionalIPv6 } from './ip-validation.js';
|
||||
|
||||
export const birdSettingsSchema = z.object({
|
||||
bird_router_id: optionalIPv4('router id'),
|
||||
bird_local_ipv4: optionalIPv4('local IPv4'),
|
||||
bird_local_ipv6: optionalIPv6('local IPv6'),
|
||||
bird_local_asn: z.string().refine((v) => v.trim() === '' || /^[1-9]\d*$/.test(v.trim()), {
|
||||
message: 'ASN должен быть целым числом больше 0'
|
||||
}),
|
||||
bird_bgp_source_ipv4: optionalIPv4('BGP source IPv4'),
|
||||
bird_bgp_source_ipv6: optionalIPv6('BGP source IPv6')
|
||||
});
|
||||
|
||||
export type BirdSettingsForm = z.infer<typeof birdSettingsSchema>;
|
||||
|
||||
export const emptyBirdSettingsForm = (): BirdSettingsForm => ({
|
||||
bird_router_id: '',
|
||||
bird_local_ipv4: '',
|
||||
bird_local_ipv6: '',
|
||||
bird_local_asn: '',
|
||||
bird_bgp_source_ipv4: '',
|
||||
bird_bgp_source_ipv6: ''
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
function isValidIPv4(value: string): boolean {
|
||||
const parts = value.split('.');
|
||||
if (parts.length !== 4) return false;
|
||||
for (const part of parts) {
|
||||
if (!/^\d{1,3}$/.test(part)) return false;
|
||||
if (part.length > 1 && part.startsWith('0')) return false;
|
||||
const n = Number(part);
|
||||
if (!Number.isInteger(n) || n < 0 || n > 255) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function isValidIPv6(value: string): boolean {
|
||||
if (!/^[0-9A-Fa-f:.]+$/.test(value)) return false;
|
||||
if ((value.match(/::/g) ?? []).length > 1) return false;
|
||||
const hasCompression = value.includes('::');
|
||||
const [leftRaw, rightRaw = ''] = value.split('::');
|
||||
const left = leftRaw === '' ? [] : leftRaw.split(':');
|
||||
const right = rightRaw === '' ? [] : rightRaw.split(':');
|
||||
if (left.some((part) => part === '') || right.some((part) => part === '')) return false;
|
||||
let segments = [...left, ...right];
|
||||
let ipv4TailSegments = 0;
|
||||
const lastSegment = segments.at(-1);
|
||||
if (lastSegment && lastSegment.includes('.')) {
|
||||
if (!isValidIPv4(lastSegment)) return false;
|
||||
segments = segments.slice(0, -1);
|
||||
ipv4TailSegments = 2;
|
||||
}
|
||||
for (const segment of segments) {
|
||||
if (!/^[0-9A-Fa-f]{1,4}$/.test(segment)) return false;
|
||||
}
|
||||
const totalSegments = segments.length + ipv4TailSegments;
|
||||
if (hasCompression) return totalSegments < 8;
|
||||
return totalSegments === 8;
|
||||
}
|
||||
|
||||
export const optionalIPv4 = (label: string) =>
|
||||
z.string().refine((v) => v.trim() === '' || isValidIPv4(v.trim()), {
|
||||
message: `Введите корректный IPv4 адрес (${label})`
|
||||
});
|
||||
|
||||
export const optionalIPv6 = (label: string) =>
|
||||
z.string().refine((v) => v.trim() === '' || isValidIPv6(v.trim()), {
|
||||
message: `Введите корректный IPv6 адрес (${label})`
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const revisionSettingsSchema = z.object({
|
||||
revision_retention_minutes: z.string().refine(
|
||||
(v) => {
|
||||
const s = v.trim();
|
||||
if (s === '') return true;
|
||||
const ttl = Number(s);
|
||||
return /^\d+$/.test(s) && Number.isInteger(ttl) && ttl >= 15 && ttl <= 43200;
|
||||
},
|
||||
{ message: 'TTL ревизий должен быть целым числом от 15 до 43200 минут' }
|
||||
)
|
||||
});
|
||||
|
||||
export type RevisionSettingsForm = z.infer<typeof revisionSettingsSchema>;
|
||||
|
||||
export const emptyRevisionSettingsForm = (): RevisionSettingsForm => ({
|
||||
revision_retention_minutes: ''
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import { apiJSON, apiMutate } from '$lib/api/client.js';
|
||||
import type { AppSettings } from '$lib/api/types.js';
|
||||
import { emptyBirdSettingsForm, type BirdSettingsForm } from './bird-settings.schema.js';
|
||||
import {
|
||||
emptyRevisionSettingsForm,
|
||||
type RevisionSettingsForm
|
||||
} from './revision-settings.schema.js';
|
||||
import {
|
||||
BIRD_SETTING_KEYS,
|
||||
KNOWN_SETTING_KEYS,
|
||||
NUMERIC_SETTING_KEYS,
|
||||
type BirdSettingKey,
|
||||
type KnownSettingKey,
|
||||
type RevisionSettingKey
|
||||
} from './settings-known-keys.js';
|
||||
|
||||
export type AdditionalSettingEntry = { id: number; key: string; value: string };
|
||||
|
||||
export type PartitionedSettings = {
|
||||
bird: BirdSettingsForm;
|
||||
revision: RevisionSettingsForm;
|
||||
additional: AdditionalSettingEntry[];
|
||||
};
|
||||
|
||||
function parseKnownValue(key: KnownSettingKey, value: unknown): string {
|
||||
if (NUMERIC_SETTING_KEYS.has(key)) {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return String(value);
|
||||
if (typeof value === 'string') return value;
|
||||
return '';
|
||||
}
|
||||
if (typeof value === 'string') return value;
|
||||
return '';
|
||||
}
|
||||
|
||||
export function partitionSettings(
|
||||
settings: AppSettings,
|
||||
nextId = 1
|
||||
): { partitioned: PartitionedSettings; nextId: number } {
|
||||
const bird = emptyBirdSettingsForm();
|
||||
const revision = emptyRevisionSettingsForm();
|
||||
const additional: AdditionalSettingEntry[] = [];
|
||||
let idCounter = nextId;
|
||||
|
||||
for (const [key, value] of Object.entries(settings as Record<string, unknown>)) {
|
||||
if ((BIRD_SETTING_KEYS as readonly string[]).includes(key)) {
|
||||
bird[key as BirdSettingKey] = parseKnownValue(key as KnownSettingKey, value);
|
||||
} else if (key === 'revision_retention_minutes') {
|
||||
revision.revision_retention_minutes = parseKnownValue(key as RevisionSettingKey, value);
|
||||
} else {
|
||||
additional.push({
|
||||
id: idCounter++,
|
||||
key,
|
||||
value: typeof value === 'string' ? value : String(value)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
partitioned: { bird, revision, additional },
|
||||
nextId: idCounter
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadSettings(): Promise<AppSettings> {
|
||||
return apiJSON<AppSettings>('/v1/settings');
|
||||
}
|
||||
|
||||
export async function patchSettings(payload: Record<string, string | number>): Promise<void> {
|
||||
await apiMutate('/v1/settings', 'PATCH', payload);
|
||||
}
|
||||
|
||||
export function buildPayloadFromFormFields(
|
||||
keys: readonly KnownSettingKey[],
|
||||
form: Record<string, string>,
|
||||
errors: Partial<Record<string, string[]>>
|
||||
): Record<string, string | number> {
|
||||
const payload: Record<string, string | number> = {};
|
||||
for (const key of keys) {
|
||||
const value = String(form[key] ?? '').trim();
|
||||
if (!value || errors[key]?.length) continue;
|
||||
if (NUMERIC_SETTING_KEYS.has(key)) payload[key] = Number(value);
|
||||
else payload[key] = value;
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
export function isKnownSettingKey(key: string): key is KnownSettingKey {
|
||||
return (KNOWN_SETTING_KEYS as readonly string[]).includes(key);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
export const BIRD_SETTING_KEYS = [
|
||||
'bird_router_id',
|
||||
'bird_local_ipv4',
|
||||
'bird_local_ipv6',
|
||||
'bird_local_asn',
|
||||
'bird_bgp_source_ipv4',
|
||||
'bird_bgp_source_ipv6'
|
||||
] as const;
|
||||
|
||||
export const REVISION_SETTING_KEYS = ['revision_retention_minutes'] as const;
|
||||
|
||||
export const KNOWN_SETTING_KEYS = [...BIRD_SETTING_KEYS, ...REVISION_SETTING_KEYS] as const;
|
||||
|
||||
export type BirdSettingKey = (typeof BIRD_SETTING_KEYS)[number];
|
||||
export type RevisionSettingKey = (typeof REVISION_SETTING_KEYS)[number];
|
||||
export type KnownSettingKey = (typeof KNOWN_SETTING_KEYS)[number];
|
||||
|
||||
export const NUMERIC_SETTING_KEYS = new Set<KnownSettingKey>([
|
||||
'bird_local_asn',
|
||||
'revision_retention_minutes'
|
||||
]);
|
||||
@@ -0,0 +1,15 @@
|
||||
import { z } from 'zod';
|
||||
import { birdSettingsSchema, emptyBirdSettingsForm } from './bird-settings.schema.js';
|
||||
import { emptyRevisionSettingsForm, revisionSettingsSchema } from './revision-settings.schema.js';
|
||||
|
||||
/** @deprecated Используйте birdSettingsSchema и revisionSettingsSchema отдельно. */
|
||||
export const settingsKnownSchema = birdSettingsSchema.merge(revisionSettingsSchema);
|
||||
|
||||
/** @deprecated Используйте BirdSettingsForm и RevisionSettingsForm. */
|
||||
export type SettingsKnownForm = z.infer<typeof settingsKnownSchema>;
|
||||
|
||||
/** @deprecated Используйте emptyBirdSettingsForm и emptyRevisionSettingsForm. */
|
||||
export const emptySettingsKnownForm = (): SettingsKnownForm => ({
|
||||
...emptyBirdSettingsForm(),
|
||||
...emptyRevisionSettingsForm()
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { browser } from '$app/environment';
|
||||
import { applyTheme, readTheme, THEME_STORAGE_KEY, type ThemePreference } from './theme.js';
|
||||
|
||||
class ThemePreferencesState {
|
||||
pref = $state<ThemePreference>('system');
|
||||
|
||||
init(): void {
|
||||
if (!browser) return;
|
||||
this.pref = readTheme();
|
||||
applyTheme(this.pref);
|
||||
}
|
||||
|
||||
set(next: ThemePreference): void {
|
||||
this.pref = next;
|
||||
}
|
||||
|
||||
persist(): void {
|
||||
if (!browser) return;
|
||||
applyTheme(this.pref);
|
||||
try {
|
||||
localStorage.setItem(THEME_STORAGE_KEY, this.pref);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const themeState = new ThemePreferencesState();
|
||||
@@ -1,5 +1,3 @@
|
||||
/** Русские подписи для enum из API (задачи, модули, логи refresh). */
|
||||
|
||||
export function jobStatusRu(status: string): string {
|
||||
switch (status) {
|
||||
case 'queued':
|
||||
@@ -17,6 +15,18 @@ export function jobStatusRu(status: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
/** Variant Badge для статуса задачи (shadcn). */
|
||||
export function jobStatusBadgeVariant(
|
||||
status: string
|
||||
): 'default' | 'secondary' | 'outline' | 'destructive' {
|
||||
const lower = status.toLowerCase();
|
||||
if (lower === 'succeeded') return 'default';
|
||||
if (lower === 'running' || lower === 'queued') return 'secondary';
|
||||
if (lower === 'failed' || lower === 'error' || lower === 'canceled' || lower === 'cancelled')
|
||||
return 'destructive';
|
||||
return 'outline';
|
||||
}
|
||||
|
||||
/** Подпись типа задачи для фильтров (значения API те же). */
|
||||
export function jobKindFilterRu(kind: string): string {
|
||||
switch (kind) {
|
||||
@@ -33,6 +43,32 @@ export function jobKindFilterRu(kind: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
/** Включён / выключен модуль (поле enabled). */
|
||||
export function moduleEnabledRu(enabled: boolean): string {
|
||||
return enabled ? 'Вкл' : 'Выкл';
|
||||
}
|
||||
|
||||
/** Variant Badge для enabled модуля. */
|
||||
export function moduleEnabledBadgeVariant(
|
||||
enabled: boolean
|
||||
): 'default' | 'secondary' | 'outline' | 'destructive' {
|
||||
return enabled ? 'default' : 'secondary';
|
||||
}
|
||||
|
||||
/** Политика DoH-резолвера для модулей DOMAINS. */
|
||||
export function dohPolicyRu(policy: string | null | undefined): string {
|
||||
switch (policy) {
|
||||
case 'primary_only':
|
||||
return 'Только первый';
|
||||
case 'failover':
|
||||
return 'Резервирование';
|
||||
case 'union':
|
||||
return 'Объединение';
|
||||
default:
|
||||
return 'Только первый';
|
||||
}
|
||||
}
|
||||
|
||||
export function moduleTypeRu(type: string): string {
|
||||
switch (type) {
|
||||
case 'AS_PREFIXES':
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import PanelLeftClose from '@lucide/svelte/icons/panel-left-close';
|
||||
import PanelLeftOpen from '@lucide/svelte/icons/panel-left-open';
|
||||
import AppNavLinks from './app-nav-links.svelte';
|
||||
import AppVersion from './app-version.svelte';
|
||||
import ThemeMenu from './theme-menu.svelte';
|
||||
|
||||
let {
|
||||
@@ -54,4 +55,5 @@
|
||||
</div>
|
||||
<Separator />
|
||||
<AppNavLinks {collapsed} />
|
||||
<AppVersion />
|
||||
</aside>
|
||||
@@ -0,0 +1,22 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { apiJSON } from '$lib/api/client.js';
|
||||
import { formatVersionSidebarLabel, type VersionInfo } from '$lib/api/version-info.js';
|
||||
|
||||
let label = $state<string | null>(null);
|
||||
|
||||
onMount(() => {
|
||||
void (async () => {
|
||||
try {
|
||||
const info = await apiJSON<VersionInfo>('/v1/version');
|
||||
label = formatVersionSidebarLabel(info);
|
||||
} catch {
|
||||
label = null;
|
||||
}
|
||||
})();
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if label}
|
||||
<p class="truncate px-3 pb-3 text-xs text-sidebar-foreground/45" title={label}>{label}</p>
|
||||
{/if}
|
||||
Loaded 100 of 115 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user