Compare commits
30
Commits
c263fd5c7e
..
v1.5.4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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` (none)
|
||||
|
||||
Заголовок — EN, императив, ≤72 символов. Тело — RU.
|
||||
@@ -0,0 +1,178 @@
|
||||
---
|
||||
description: >-
|
||||
Conventional Commits EvoBGP. Триггеры commit/коммит/закоммить/staged/commit message —
|
||||
ОБЯЗАТЕЛЬНО сначала Shell scripts/commit/staged-context.ps1, скилл commit-message.
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# Conventional Commits (EvoBGP)
|
||||
|
||||
Полное описание релизного пайплайна: [docs/releasing.md](../../docs/releasing.md).
|
||||
|
||||
## Триггеры (применить правило + скилл)
|
||||
|
||||
Любой запрос на коммит или сообщение коммита: `commit`, `коммит`, `закоммить`, `git commit`, `commit message`, `conventional commit`, `staged`, «сгенерируй коммит», **`/commit-message`** — в т.ч. если это указано в плане или [AGENTS.md](../../AGENTS.md).
|
||||
|
||||
### Не путать с кнопкой ✨ в Source Control
|
||||
|
||||
Команда **`cursor.generateGitCommitMessage`** (sparkle в поле commit message) **не** читает Rules, Skills и `staged-context.ps1` — только staged diff и история коммитов ([ограничение Cursor](https://forum.cursor.com/t/how-to-set-prompt-for-generate-commit-message/148606)).
|
||||
|
||||
**Замена для EvoBGP:** Agent → `/commit-message` или команда [`.cursor/commands/commit-message.md`](../commands/commit-message.md).
|
||||
|
||||
## Обязательный запуск скрипта (MUST)
|
||||
|
||||
1. Прочитать скилл [`.cursor/skills/commit-message/SKILL.md`](../skills/commit-message/SKILL.md).
|
||||
2. **Первым действием** выполнить Shell (из корня репо):
|
||||
|
||||
```powershell
|
||||
powershell -NoProfile -File scripts/commit/staged-context.ps1
|
||||
```
|
||||
|
||||
3. Сообщение коммита строить **только** по JSON из stdout скрипта (`groups`, `diff_excerpt`, `stat`).
|
||||
4. **Запрещено** генерировать commit message без успешного (exit 0) запуска скрипта; не заменять скрипт одним `git diff --cached`.
|
||||
|
||||
## Формат (строго)
|
||||
|
||||
```
|
||||
<type>(<scope>): <short summary in English>
|
||||
|
||||
<тело на русском: что изменено и зачем>
|
||||
```
|
||||
|
||||
- **Заголовок** — только английский; императив, без точки в конце; ≤72 символов.
|
||||
- **Тело** — только русский; полные предложения; пустая строка после заголовка.
|
||||
- Запрещены vague-сообщения: `fix bug`, `update code`, `wip`, `misc`.
|
||||
|
||||
## Типы (semantic-release)
|
||||
|
||||
| type | Когда | Версия |
|
||||
|------|--------|--------|
|
||||
| `feat` | **новая** пользовательская возможность (раньше нельзя было) | minor |
|
||||
| `fix` | восстановление **ожидаемого** поведения; баг, регрессия, падение UI | patch |
|
||||
| `perf` | ускорение без смены API | patch |
|
||||
| `refactor` | реструктуризация **без** новой возможности и **без** исправления бага | — |
|
||||
| `docs` | только документация | — |
|
||||
| `test` | тесты | — |
|
||||
| `ci` | CI/CD (`.gitea/`, workflows); правки, из‑за которых нужны новые образы | patch |
|
||||
| `chore` | обслуживание, deps, `.cursor/` | — |
|
||||
|
||||
### Выбор type: semver, а не «красивые слова»
|
||||
|
||||
**Главный вопрос:** что изменится для пользователя после релиза?
|
||||
|
||||
1. Появилось **новое** действие / экран / API / настройка, которых не было → `feat`
|
||||
2. То, что **должно было работать**, не работало (кнопки, диалоги, сохранение, 500) → `fix`
|
||||
3. Только перестройка кода или UI на другой паттерн, поведение для пользователя то же → `refactor`
|
||||
4. Ускорение без изменения контракта → `perf`
|
||||
|
||||
**Не путать с формулировкой diff:**
|
||||
|
||||
| В diff / задаче часто пишут | Неверный type | Верный type, если… |
|
||||
|-----------------------------|---------------|---------------------|
|
||||
| enhance, improve, polish UI | `feat` | …только чиним сломанное после прошлого PR → `fix` |
|
||||
| refactor pages, unify tables | `feat` | …новой возможности нет, лишь перенос на AppDataTable → `refactor` |
|
||||
| follow-up после feat(web) | `feat` | …исправляем баги того же экрана → `fix` |
|
||||
|
||||
**Follow-up rule:** коммит сразу после `feat` в той же области, который **не добавляет** новую возможность, а устраняет дефект (effect loop, не открывается dialog, confirm не срабатывает) — **`fix`**, не `feat`.
|
||||
|
||||
**Split при смешанном diff:** новая страница/flow → `feat`; отдельным коммитом правки багов → `fix`. Не объединять в один `feat`.
|
||||
|
||||
**Breaking changes** — только `feat!` / `fix!` / `BREAKING CHANGE:` когда пользователь **обязан** менять конфиг, API или привычный workflow.
|
||||
|
||||
### Обязательно в пояснении агенту
|
||||
|
||||
При каждом предложении коммита указать:
|
||||
|
||||
- **Semver impact:** `minor` | `patch` | `none` | `major`
|
||||
- **Почему не другой type** (одно предложение), если diff большой или формулировка двусмысленная
|
||||
|
||||
Пример неправильно / правильно:
|
||||
|
||||
```
|
||||
# Плохо — patch-фикс, minor-bump
|
||||
feat(web): enhance module entry dialogs and selection handling
|
||||
|
||||
# Хорошо
|
||||
fix(web): stop effect loop breaking module action buttons
|
||||
|
||||
Исправлен effect_update_depth_exceeded и bind:open у Dialog; кнопки редактирования/удаления снова работают.
|
||||
```
|
||||
|
||||
```
|
||||
# Плохо — рефакторинг без новой фичи
|
||||
feat(web): migrate modules list to AppDataTable
|
||||
|
||||
# Хорошо — если не было нового user-facing
|
||||
refactor(web): migrate modules list to AppDataTable
|
||||
|
||||
Единый паттерн таблиц; поведение списка модулей без изменений.
|
||||
```
|
||||
|
||||
```
|
||||
# Хорошо feat — действительно новое
|
||||
feat(web): add module create dialog on /modules
|
||||
|
||||
Диалог создания модуля с POST /v1/modules; раньше создание было только через API.
|
||||
```
|
||||
|
||||
## Breaking changes
|
||||
|
||||
- Заголовок: `feat!` / `fix!` **или** в теле строка `BREAKING CHANGE:` (на английском ключевое слово) + описание impact **на русском**.
|
||||
|
||||
## 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` (none) |
|
||||
|
||||
**Follow-up:** правки сразу после `feat` в том же scope без новой возможности → **`fix`**, не `feat` (слова *enhance/improve/refactor* в задаче не делают commit `feat`).
|
||||
|
||||
**Запрещено** по умолчанию ставить `feat` для «большого diff» в `web/` — type по **semver impact**, не по объёму.
|
||||
|
||||
### Шаг B — type, scope, текст
|
||||
|
||||
- **type** — результат шага A, не «chore по умолчанию» и не `feat` из-за слова enhance.
|
||||
- **scope** — из JSON группы или доминирующий при merge.
|
||||
- **summary** — конкретный, английский, императив; для `fix` — что **починено** (`fix broken …`, `prevent … loop`).
|
||||
- **body** — русский: что, зачем, edge cases, breaking impact.
|
||||
|
||||
## Вывод пользователю
|
||||
|
||||
Для **каждого** коммита:
|
||||
|
||||
### 1. Готовое сообщение
|
||||
|
||||
```
|
||||
<type>(<scope>): <summary>
|
||||
|
||||
<тело>
|
||||
```
|
||||
|
||||
### 2. Пояснение (RU)
|
||||
|
||||
- **Semver impact:** `minor` | `patch` | `none` | `major` — и почему не другой type.
|
||||
- Риски и impact.
|
||||
- Split: сколько коммитов и почему.
|
||||
|
||||
## Не смешивать
|
||||
|
||||
Один commit message — одна primary intent. Не объединять несвязанный `fix` и `feat` в один заголовок.
|
||||
|
||||
## Ссылки
|
||||
|
||||
- Правило: `.cursor/rules/conventional-commits.mdc`
|
||||
- Скрипт: `scripts/commit/staged-context.ps1`
|
||||
- Просмотр групп: `powershell -File scripts/commit/staged-context.ps1 | ConvertFrom-Json`
|
||||
+29
-51
@@ -1,75 +1,53 @@
|
||||
# Gitea Actions
|
||||
|
||||
В репозитории включён 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,49 @@
|
||||
{
|
||||
"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" },
|
||||
{ "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,76 @@
|
||||
# Релизы и версионирование 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` | patch (1.5.1 → 1.5.2) |
|
||||
| `feat!`, `fix!` или `BREAKING CHANGE:` в теле | major (1.0.0 → 2.0.0) |
|
||||
| `docs`, `chore`, `test`, `refactor` | без релиза |
|
||||
|
||||
Отдельного суффикса `1.x.y.fix` в semver нет: «fix» в Conventional Commits означает **patch** (третья цифра). Для починки пайплайна без смены продукта — `fix(ci):` или `ci:` (оба дают patch после настройки `.releaserc.json`).
|
||||
|
||||
Первый релиз при отсутствии git-тегов — **1.0.0**, если есть releasable-коммиты.
|
||||
|
||||
**Как не перепутать `feat` и `fix`:** см. раздел «Выбор type: semver, а не «красивые слова»» в [.cursor/rules/conventional-commits.mdc](../.cursor/rules/conventional-commits.mdc). Кратко: `feat` — новая возможность (minor); `fix` — починка ожидаемого поведения (patch); follow-up баги после недавнего `feat` — всегда `fix`, даже если diff большой.
|
||||
|
||||
Подробные правила сообщений коммитов: [.cursor/rules/conventional-commits.mdc](../.cursor/rules/conventional-commits.mdc).
|
||||
|
||||
## CI-пайплайн (один push в main)
|
||||
|
||||
```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,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>
|
||||
@@ -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,79 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
function isValidIPv4(value: string): boolean {
|
||||
const parts = value.split('.');
|
||||
if (parts.length !== 4) return false;
|
||||
for (const part of parts) {
|
||||
if (!/^\d{1,3}$/.test(part)) return false;
|
||||
if (part.length > 1 && part.startsWith('0')) return false;
|
||||
const n = Number(part);
|
||||
if (!Number.isInteger(n) || n < 0 || n > 255) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function isValidIPv6(value: string): boolean {
|
||||
if (!/^[0-9A-Fa-f:.]+$/.test(value)) return false;
|
||||
if ((value.match(/::/g) ?? []).length > 1) return false;
|
||||
const hasCompression = value.includes('::');
|
||||
const [leftRaw, rightRaw = ''] = value.split('::');
|
||||
const left = leftRaw === '' ? [] : leftRaw.split(':');
|
||||
const right = rightRaw === '' ? [] : rightRaw.split(':');
|
||||
if (left.some((part) => part === '') || right.some((part) => part === '')) return false;
|
||||
let segments = [...left, ...right];
|
||||
let ipv4TailSegments = 0;
|
||||
const lastSegment = segments.at(-1);
|
||||
if (lastSegment && lastSegment.includes('.')) {
|
||||
if (!isValidIPv4(lastSegment)) return false;
|
||||
segments = segments.slice(0, -1);
|
||||
ipv4TailSegments = 2;
|
||||
}
|
||||
for (const segment of segments) {
|
||||
if (!/^[0-9A-Fa-f]{1,4}$/.test(segment)) return false;
|
||||
}
|
||||
const totalSegments = segments.length + ipv4TailSegments;
|
||||
if (hasCompression) return totalSegments < 8;
|
||||
return totalSegments === 8;
|
||||
}
|
||||
|
||||
const optionalIPv4 = (label: string) =>
|
||||
z.string().refine((v) => v.trim() === '' || isValidIPv4(v.trim()), {
|
||||
message: `Введите корректный IPv4 адрес (${label})`
|
||||
});
|
||||
|
||||
const optionalIPv6 = (label: string) =>
|
||||
z.string().refine((v) => v.trim() === '' || isValidIPv6(v.trim()), {
|
||||
message: `Введите корректный IPv6 адрес (${label})`
|
||||
});
|
||||
|
||||
export const settingsKnownSchema = z.object({
|
||||
bird_router_id: optionalIPv4('router id'),
|
||||
bird_local_ipv4: optionalIPv4('local IPv4'),
|
||||
bird_local_ipv6: optionalIPv6('local IPv6'),
|
||||
bird_local_asn: z.string().refine((v) => v.trim() === '' || /^[1-9]\d*$/.test(v.trim()), {
|
||||
message: 'ASN должен быть целым числом больше 0'
|
||||
}),
|
||||
bird_bgp_source_ipv4: optionalIPv4('BGP source IPv4'),
|
||||
bird_bgp_source_ipv6: optionalIPv6('BGP source IPv6'),
|
||||
revision_retention_minutes: z.string().refine(
|
||||
(v) => {
|
||||
const s = v.trim();
|
||||
if (s === '') return true;
|
||||
const ttl = Number(s);
|
||||
return /^\d+$/.test(s) && Number.isInteger(ttl) && ttl >= 15 && ttl <= 43200;
|
||||
},
|
||||
{ message: 'TTL ревизий должен быть целым числом от 15 до 43200 минут' }
|
||||
)
|
||||
});
|
||||
|
||||
export type SettingsKnownForm = z.infer<typeof settingsKnownSchema>;
|
||||
|
||||
export const emptySettingsKnownForm = (): SettingsKnownForm => ({
|
||||
bird_router_id: '',
|
||||
bird_local_ipv4: '',
|
||||
bird_local_ipv6: '',
|
||||
bird_local_asn: '',
|
||||
bird_bgp_source_ipv4: '',
|
||||
bird_bgp_source_ipv6: '',
|
||||
revision_retention_minutes: ''
|
||||
});
|
||||
@@ -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}
|
||||
@@ -7,8 +7,6 @@ import Gauge from '@lucide/svelte/icons/gauge';
|
||||
import LayoutDashboard from '@lucide/svelte/icons/layout-dashboard';
|
||||
import Network from '@lucide/svelte/icons/network';
|
||||
import Settings from '@lucide/svelte/icons/settings';
|
||||
import Zap from '@lucide/svelte/icons/zap';
|
||||
|
||||
export type NavItem = {
|
||||
href: string;
|
||||
label: string;
|
||||
@@ -20,8 +18,7 @@ export const mainNav: NavItem[] = [
|
||||
{ href: '/modules', label: 'Модули', icon: Boxes },
|
||||
{ href: '/directories', label: 'Справочники', icon: BookOpen },
|
||||
{ href: '/network', label: 'Сеть', icon: Network },
|
||||
{ href: '/revisions', label: 'Ревизии', icon: Activity },
|
||||
{ href: '/operations', label: 'Операции', icon: Zap },
|
||||
{ href: '/operations', label: 'Ревизии', icon: Activity },
|
||||
{ href: '/schedule', label: 'Расписание', icon: CalendarClock },
|
||||
{ href: '/monitoring', label: 'Мониторинг', icon: Gauge }
|
||||
];
|
||||
|
||||
@@ -15,7 +15,12 @@
|
||||
const open = $derived(!!state?.open);
|
||||
|
||||
function onOpenChange(v: boolean) {
|
||||
if (!v) closeConfirm();
|
||||
if (!v && state && !state.loading) closeConfirm();
|
||||
}
|
||||
|
||||
async function handleConfirm() {
|
||||
if (!state || state.loading) return;
|
||||
await state.onConfirm();
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -37,7 +42,10 @@
|
||||
? 'text-destructive-foreground bg-destructive hover:bg-destructive/90'
|
||||
: ''}
|
||||
disabled={state.loading}
|
||||
onclick={state.onConfirm}
|
||||
onclick={(e) => {
|
||||
e.preventDefault();
|
||||
void handleConfirm();
|
||||
}}
|
||||
>
|
||||
{state.loading ? '…' : (state.confirmLabel ?? 'Подтвердить')}
|
||||
</AlertDialogAction>
|
||||
|
||||
@@ -21,8 +21,9 @@ export function confirm(options: ConfirmOptions) {
|
||||
open: true,
|
||||
loading: false,
|
||||
onConfirm: async () => {
|
||||
if (!confirmState.current) return;
|
||||
confirmState.current = { ...confirmState.current, loading: true };
|
||||
const current = confirmState.current;
|
||||
if (!current || current.loading) return;
|
||||
confirmState.current = { ...current, loading: true };
|
||||
try {
|
||||
await options.onConfirm();
|
||||
resolve(true);
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
<script lang="ts">
|
||||
import type { Component } from 'svelte';
|
||||
import { Badge } from '$lib/ui/core/badge/index.js';
|
||||
import { Button } from '$lib/ui/core/button/index.js';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription
|
||||
} from '$lib/ui/core/card/index.js';
|
||||
import CardSkeleton from '$lib/ui/patterns/feedback/card-skeleton.svelte';
|
||||
import { cn } from '$lib/utils.js';
|
||||
import ArrowRight from '@lucide/svelte/icons/arrow-right';
|
||||
|
||||
export type KpiAccent = {
|
||||
border: string;
|
||||
bg: string;
|
||||
iconBg: string;
|
||||
iconText: string;
|
||||
};
|
||||
|
||||
export type KpiCardItem = {
|
||||
id: string;
|
||||
label: string;
|
||||
value: string;
|
||||
description: string;
|
||||
icon: Component;
|
||||
accent: KpiAccent;
|
||||
badge: string;
|
||||
badgeClass?: string;
|
||||
badgeVariant?: 'default' | 'secondary' | 'destructive' | 'outline';
|
||||
valueClass?: string;
|
||||
error?: string | null;
|
||||
href?: string;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
cards: KpiCardItem[];
|
||||
loading?: boolean;
|
||||
skeletonCount?: number;
|
||||
class?: string;
|
||||
};
|
||||
|
||||
let { cards, loading = false, skeletonCount = 3, class: className }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class={cn('grid gap-4', className)}>
|
||||
{#if loading}
|
||||
{#each Array(skeletonCount) as _, i (i)}
|
||||
<CardSkeleton />
|
||||
{/each}
|
||||
{:else}
|
||||
{#each cards as card (card.id)}
|
||||
{@const Icon = card.icon}
|
||||
{@const a = card.accent}
|
||||
<Card
|
||||
class={cn(
|
||||
'overflow-hidden border-l-4 shadow-sm',
|
||||
card.href ? 'transition-colors hover:border-primary/35' : '',
|
||||
a.border,
|
||||
a.bg
|
||||
)}
|
||||
>
|
||||
<CardHeader class="pb-2">
|
||||
{#if card.href}
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<CardDescription class="flex min-w-0 items-center gap-2">
|
||||
<span
|
||||
class={cn(
|
||||
'flex size-9 shrink-0 items-center justify-center rounded-lg',
|
||||
a.iconBg
|
||||
)}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Icon class={cn('size-4', a.iconText)} />
|
||||
</span>
|
||||
<span class="truncate">{card.label}</span>
|
||||
</CardDescription>
|
||||
<Button variant="ghost" size="icon-sm" href={card.href}>
|
||||
<ArrowRight class="size-3.5" aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
{:else}
|
||||
<CardDescription class="flex min-w-0 items-center gap-2">
|
||||
<span
|
||||
class={cn('flex size-9 shrink-0 items-center justify-center rounded-lg', a.iconBg)}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Icon class={cn('size-4', a.iconText)} />
|
||||
</span>
|
||||
<span class="truncate">{card.label}</span>
|
||||
</CardDescription>
|
||||
{/if}
|
||||
<CardTitle class={cn('font-bold tabular-nums', card.valueClass ?? 'text-3xl')}
|
||||
>{card.value}</CardTitle
|
||||
>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-2">
|
||||
<Badge variant={card.badgeVariant ?? 'outline'} class={card.badgeClass}
|
||||
>{card.badge}</Badge
|
||||
>
|
||||
{#if card.error}
|
||||
<p class="text-xs text-destructive">{card.error}</p>
|
||||
{:else}
|
||||
<p class="text-xs text-muted-foreground">{card.description}</p>
|
||||
{/if}
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
+244
-124
@@ -1,25 +1,37 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { apiJSON, apiFetch, apiPageAll } from '$lib/api/client.js';
|
||||
import { resolve } from '$app/paths';
|
||||
import { apiJSON, apiFetch } from '$lib/api/client.js';
|
||||
import type {
|
||||
ModuleRow,
|
||||
ModulesResponse,
|
||||
RevisionRow,
|
||||
RevisionsResponse,
|
||||
PeerRow,
|
||||
PeersResponse,
|
||||
SpeakerRow,
|
||||
SpeakersResponse,
|
||||
JobRow,
|
||||
JobsResponse
|
||||
} from '$lib/api/types.js';
|
||||
import { Badge } from '$lib/components/ui/badge/index.js';
|
||||
import { Button } from '$lib/components/ui/button/index.js';
|
||||
import { Badge } from '$lib/ui/core/badge/index.js';
|
||||
import { Button } from '$lib/ui/core/button/index.js';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription
|
||||
} from '$lib/components/ui/card/index.js';
|
||||
} from '$lib/ui/core/card/index.js';
|
||||
import { Alert, AlertDescription, AlertTitle } from '$lib/ui/core/alert/index.js';
|
||||
import { Skeleton } from '$lib/ui/core/skeleton/index.js';
|
||||
import CardSkeleton from '$lib/ui/patterns/feedback/card-skeleton.svelte';
|
||||
import KpiMetricsGrid from '$lib/ui/patterns/kpi/kpi-metrics-grid.svelte';
|
||||
import PageHeader from '$lib/ui/app/page-header/page-header.svelte';
|
||||
import { notifyApiError } from '$lib/ui/app/toast.js';
|
||||
import OverviewRecentJobsCard from '$lib/components/overview/OverviewRecentJobsCard.svelte';
|
||||
import OverviewRecentRevisionsCard from '$lib/components/overview/OverviewRecentRevisionsCard.svelte';
|
||||
import { cn } from '$lib/utils.js';
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const resolve = (path: string) => path as any;
|
||||
import CheckCircle from '@lucide/svelte/icons/check-circle';
|
||||
import XCircle from '@lucide/svelte/icons/x-circle';
|
||||
import Boxes from '@lucide/svelte/icons/boxes';
|
||||
@@ -27,51 +39,34 @@
|
||||
import Radio from '@lucide/svelte/icons/radio';
|
||||
import Activity from '@lucide/svelte/icons/activity';
|
||||
import Clock from '@lucide/svelte/icons/clock';
|
||||
import ArrowRight from '@lucide/svelte/icons/arrow-right';
|
||||
import LayoutDashboard from '@lucide/svelte/icons/layout-dashboard';
|
||||
import PageHeader from '$lib/ui/app/page-header/page-header.svelte';
|
||||
import Info from '@lucide/svelte/icons/info';
|
||||
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
|
||||
import Plus from '@lucide/svelte/icons/plus';
|
||||
import Tags from '@lucide/svelte/icons/tags';
|
||||
import Share2 from '@lucide/svelte/icons/share-2';
|
||||
import Play from '@lucide/svelte/icons/play';
|
||||
import Gauge from '@lucide/svelte/icons/gauge';
|
||||
|
||||
let healthy = $state<boolean | null>(null);
|
||||
let modules = $state(0);
|
||||
let revisions = $state(0);
|
||||
let peers = $state(0);
|
||||
let speakers = $state(0);
|
||||
let moduleItems = $state<ModuleRow[]>([]);
|
||||
let modulesHasMore = $state(false);
|
||||
let revisionItems = $state<RevisionRow[]>([]);
|
||||
let revisionsHasMore = $state(false);
|
||||
let peerItems = $state<PeerRow[]>([]);
|
||||
let peersHasMore = $state(false);
|
||||
let speakerItems = $state<SpeakerRow[]>([]);
|
||||
let speakersHasMore = $state(false);
|
||||
let jobItems = $state<JobRow[]>([]);
|
||||
let recentJobs = $state<JobRow[]>([]);
|
||||
let recentRevisions = $state<RevisionRow[]>([]);
|
||||
let runningJobs = $state(0);
|
||||
let loading = $state(true);
|
||||
let initialLoading = $state(true);
|
||||
let refreshing = $state(false);
|
||||
let loadError = $state<string | null>(null);
|
||||
let lastUpdated = $state<Date | null>(null);
|
||||
|
||||
async function countAll(path: string): Promise<number> {
|
||||
const items = await apiPageAll<unknown>(path, 500);
|
||||
return items.length;
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
loading = true;
|
||||
try {
|
||||
const h = await apiFetch('/v1/health');
|
||||
healthy = h.ok;
|
||||
} catch {
|
||||
healthy = false;
|
||||
}
|
||||
try {
|
||||
const [mCount, rCount, pCount, sCount, j] = await Promise.all([
|
||||
countAll('/v1/modules'),
|
||||
countAll('/v1/revisions'),
|
||||
countAll('/v1/peers'),
|
||||
countAll('/v1/speakers'),
|
||||
apiJSON<JobsResponse>('/v1/jobs?limit=100')
|
||||
]);
|
||||
modules = mCount;
|
||||
revisions = rCount;
|
||||
peers = pCount;
|
||||
speakers = sCount;
|
||||
runningJobs = (j.items ?? []).filter(
|
||||
(i) => i.status === 'running' || i.status === 'queued'
|
||||
).length;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
loading = false;
|
||||
});
|
||||
const moduleNameById = $derived(new Map(moduleItems.map((m) => [m.id, m.name])));
|
||||
|
||||
const statAccents = [
|
||||
{
|
||||
@@ -106,123 +101,248 @@
|
||||
}
|
||||
] as const;
|
||||
|
||||
const stats = $derived([
|
||||
function countBadge(count: number, hasMore: boolean, suffix: string) {
|
||||
if (hasMore) return '200+';
|
||||
return suffix;
|
||||
}
|
||||
|
||||
const kpiCards = $derived.by(() => [
|
||||
{
|
||||
id: 'modules',
|
||||
label: 'Модули',
|
||||
value: modules,
|
||||
href: '/modules',
|
||||
value: initialLoading ? '—' : String(moduleItems.length),
|
||||
href: '/modules' as const,
|
||||
icon: Boxes,
|
||||
description: 'AS, CDN, домены, IP',
|
||||
accent: statAccents[0]
|
||||
accent: statAccents[0],
|
||||
badge: countBadge(moduleItems.length, modulesHasMore, 'в системе')
|
||||
},
|
||||
{
|
||||
id: 'peers',
|
||||
label: 'Пиры',
|
||||
value: peers,
|
||||
href: '/network',
|
||||
value: initialLoading ? '—' : String(peerItems.length),
|
||||
href: '/network' as const,
|
||||
icon: GitBranch,
|
||||
description: 'BGP-соседи',
|
||||
accent: statAccents[1]
|
||||
accent: statAccents[1],
|
||||
badge: countBadge(peerItems.length, peersHasMore, 'peers')
|
||||
},
|
||||
{
|
||||
id: 'speakers',
|
||||
label: 'Спикеры',
|
||||
value: speakers,
|
||||
href: '/network',
|
||||
value: initialLoading ? '—' : String(speakerItems.length),
|
||||
href: '/network' as const,
|
||||
icon: Radio,
|
||||
description: 'BIRD-агенты',
|
||||
accent: statAccents[2]
|
||||
accent: statAccents[2],
|
||||
badge: countBadge(speakerItems.length, speakersHasMore, 'agents')
|
||||
},
|
||||
{
|
||||
id: 'revisions',
|
||||
label: 'Ревизии',
|
||||
value: revisions,
|
||||
href: '/operations',
|
||||
value: initialLoading ? '—' : String(revisionItems.length),
|
||||
href: '/operations' as const,
|
||||
icon: Activity,
|
||||
description: 'История конфигураций',
|
||||
accent: statAccents[3]
|
||||
accent: statAccents[3],
|
||||
badge: countBadge(revisionItems.length, revisionsHasMore, 'configs')
|
||||
},
|
||||
{
|
||||
id: 'jobs',
|
||||
label: 'Активных задач',
|
||||
value: runningJobs,
|
||||
href: '/operations',
|
||||
value: initialLoading ? '—' : String(runningJobs),
|
||||
href: '/operations?tab=jobs' as const,
|
||||
icon: Clock,
|
||||
description: 'Выполняются сейчас',
|
||||
accent: statAccents[4]
|
||||
description: 'queued и running в выборке',
|
||||
accent: statAccents[4],
|
||||
badge: 'running'
|
||||
}
|
||||
]);
|
||||
|
||||
function toErrorMessage(e: unknown): string {
|
||||
return e instanceof Error ? e.message : String(e);
|
||||
}
|
||||
|
||||
async function load() {
|
||||
if (!initialLoading) refreshing = true;
|
||||
loadError = null;
|
||||
try {
|
||||
try {
|
||||
const h = await apiFetch('/v1/health');
|
||||
healthy = h.ok;
|
||||
if (!h.ok) {
|
||||
loadError = `GET /v1/health: HTTP ${h.status}`;
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
healthy = false;
|
||||
loadError = toErrorMessage(e);
|
||||
notifyApiError(e);
|
||||
return;
|
||||
}
|
||||
|
||||
const [m, p, s, r, j] = await Promise.allSettled([
|
||||
apiJSON<ModulesResponse>('/v1/modules?limit=200'),
|
||||
apiJSON<PeersResponse>('/v1/peers?limit=200'),
|
||||
apiJSON<SpeakersResponse>('/v1/speakers?limit=200'),
|
||||
apiJSON<RevisionsResponse>('/v1/revisions?limit=200'),
|
||||
apiJSON<JobsResponse>('/v1/jobs?limit=20')
|
||||
]);
|
||||
|
||||
const firstReject = [m, p, s, r, j].find((x) => x.status === 'rejected');
|
||||
if (firstReject?.status === 'rejected') {
|
||||
loadError = toErrorMessage(firstReject.reason);
|
||||
notifyApiError(firstReject.reason);
|
||||
}
|
||||
|
||||
if (m.status === 'fulfilled') {
|
||||
moduleItems = m.value.items ?? [];
|
||||
modulesHasMore = m.value.has_more;
|
||||
}
|
||||
if (p.status === 'fulfilled') {
|
||||
peerItems = p.value.items ?? [];
|
||||
peersHasMore = p.value.has_more;
|
||||
}
|
||||
if (s.status === 'fulfilled') {
|
||||
speakerItems = s.value.items ?? [];
|
||||
speakersHasMore = s.value.has_more;
|
||||
}
|
||||
if (r.status === 'fulfilled') {
|
||||
revisionItems = r.value.items ?? [];
|
||||
revisionsHasMore = r.value.has_more;
|
||||
}
|
||||
if (j.status === 'fulfilled') {
|
||||
jobItems = j.value.items ?? [];
|
||||
recentJobs = jobItems.slice(0, 10);
|
||||
recentRevisions = revisionItems.slice(0, 10);
|
||||
runningJobs = jobItems.filter(
|
||||
(i) => i.status === 'running' || i.status === 'queued'
|
||||
).length;
|
||||
}
|
||||
|
||||
if (!loadError) lastUpdated = new Date();
|
||||
} finally {
|
||||
initialLoading = false;
|
||||
refreshing = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(load);
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-6">
|
||||
<PageHeader
|
||||
title="Обзор"
|
||||
description="Состояние панели управления EvoBGP."
|
||||
description={lastUpdated
|
||||
? `Состояние панели управления EvoBGP. Обновлено: ${lastUpdated.toLocaleTimeString('ru-RU')}`
|
||||
: 'Состояние панели управления EvoBGP.'}
|
||||
icon={LayoutDashboard}
|
||||
iconClass="bg-primary/10 text-primary"
|
||||
>
|
||||
{#snippet actions()}
|
||||
<Button variant="outline" size="sm" onclick={load} disabled={refreshing}>
|
||||
<RefreshCw class={refreshing ? 'animate-spin' : ''} />
|
||||
Обновить
|
||||
</Button>
|
||||
{/snippet}
|
||||
</PageHeader>
|
||||
|
||||
<Alert class="border-info/30 bg-info/5">
|
||||
<Info class="text-info" />
|
||||
<AlertTitle>Панель управления EvoBGP</AlertTitle>
|
||||
<AlertDescription>
|
||||
Сводка по модулям, сети и фоновым задачам. Настройка префиксов — на странице
|
||||
<Button variant="link" class="h-auto p-0" href={resolve('/modules')}>Модули</Button>, деплой и
|
||||
ревизии —
|
||||
<Button variant="link" class="h-auto p-0" href={resolve('/operations')}>Операции</Button>,
|
||||
здоровье системы —
|
||||
<Button variant="link" class="h-auto p-0" href={resolve('/monitoring')}>Мониторинг</Button>.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
{#if healthy === null}
|
||||
<Alert>
|
||||
<Skeleton class="size-5 rounded-full" />
|
||||
<AlertTitle>Проверка API…</AlertTitle>
|
||||
<AlertDescription>Запрос к <code class="text-xs">/v1/health</code></AlertDescription>
|
||||
</Alert>
|
||||
{:else if healthy && !loadError}
|
||||
<Alert class="border-success/30 bg-success/5">
|
||||
<CheckCircle class="text-success" />
|
||||
<AlertTitle>API работает</AlertTitle>
|
||||
<AlertDescription>Сервер отвечает на запросы health-check.</AlertDescription>
|
||||
</Alert>
|
||||
{:else if healthy && loadError}
|
||||
<Alert class="border-warning/30 bg-warning/5">
|
||||
<Info class="text-warning" />
|
||||
<AlertTitle>API доступен, данные не загружены</AlertTitle>
|
||||
<AlertDescription>
|
||||
{loadError}. Для локального демо укажите Bearer-токен
|
||||
<code class="text-xs">dev</code> в
|
||||
<Button variant="link" class="h-auto p-0" href={resolve('/settings')}>Настройках</Button>
|
||||
(нужен <code class="text-xs">EVOBGP_DEV_INSECURE=1</code> на API).
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
{:else}
|
||||
<Alert variant="destructive" class="border-destructive/30 bg-destructive/5">
|
||||
<XCircle class="text-destructive" />
|
||||
<AlertTitle>API недоступен</AlertTitle>
|
||||
<AlertDescription>
|
||||
{loadError ??
|
||||
'Не удалось получить ответ от сервера. Проверьте, что API запущен (порт 8080), в dev — `npm run dev` с прокси Vite, в Docker — контейнер evobgp-api / evobgp-all и nginx в evobgp-web.'}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
{/if}
|
||||
|
||||
<KpiMetricsGrid
|
||||
cards={kpiCards}
|
||||
loading={initialLoading}
|
||||
skeletonCount={5}
|
||||
class="sm:grid-cols-2 lg:grid-cols-3"
|
||||
/>
|
||||
|
||||
<!-- Health -->
|
||||
<Card>
|
||||
<CardContent class="flex items-center gap-3 py-4">
|
||||
{#if healthy === null}
|
||||
<div class="size-3 animate-pulse rounded-full bg-muted"></div>
|
||||
<span class="text-sm text-muted-foreground">Проверка…</span>
|
||||
{:else if healthy}
|
||||
<CheckCircle class="size-5 text-green-500" />
|
||||
<span class="font-medium text-green-700 dark:text-green-400">API работает</span>
|
||||
{:else}
|
||||
<XCircle class="size-5 text-red-500" />
|
||||
<span class="font-medium text-red-700 dark:text-red-400">API недоступен</span>
|
||||
{/if}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- Stats grid -->
|
||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{#each stats as stat (stat.label)}
|
||||
{@const Icon = stat.icon}
|
||||
{@const a = stat.accent}
|
||||
<Card
|
||||
class={cn(
|
||||
'overflow-hidden border-l-4 shadow-sm transition-colors hover:border-primary/35',
|
||||
a.border,
|
||||
a.bg
|
||||
)}
|
||||
>
|
||||
<CardHeader class="pb-2">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<CardDescription class="flex min-w-0 items-center gap-2">
|
||||
<span
|
||||
class={cn('flex size-9 shrink-0 items-center justify-center rounded-lg', a.iconBg)}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Icon class={cn('size-4', a.iconText)} />
|
||||
</span>
|
||||
<span class="truncate">{stat.label}</span>
|
||||
</CardDescription>
|
||||
<Button variant="ghost" size="icon-sm" href={resolve(stat.href)}>
|
||||
<ArrowRight class="size-3.5" aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
<CardTitle class="text-3xl font-bold tabular-nums">
|
||||
{loading ? '—' : stat.value}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p class="text-xs text-muted-foreground">{stat.description}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/each}
|
||||
<div class="grid gap-4 lg:grid-cols-2">
|
||||
<OverviewRecentJobsCard
|
||||
items={recentJobs}
|
||||
{moduleNameById}
|
||||
loading={refreshing}
|
||||
{initialLoading}
|
||||
error={loadError}
|
||||
/>
|
||||
<OverviewRecentRevisionsCard
|
||||
items={recentRevisions}
|
||||
loading={refreshing}
|
||||
{initialLoading}
|
||||
error={loadError}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Quick links -->
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardHeader class="border-b py-3">
|
||||
<CardTitle class="text-base">Быстрые действия</CardTitle>
|
||||
<CardDescription>Частые переходы к настройке и деплою</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent class="flex flex-wrap gap-2">
|
||||
<Button variant="outline" size="sm" href={resolve('/modules')}>Создать модуль</Button>
|
||||
<Button variant="outline" size="sm" href={resolve('/directories')}>Добавить community</Button>
|
||||
<Button variant="outline" size="sm" href={resolve('/network')}>Добавить пира</Button>
|
||||
<Button variant="outline" size="sm" href={resolve('/operations')}>Деплой (Apply)</Button>
|
||||
<Button variant="outline" size="sm" href={resolve('/monitoring')}>Мониторинг</Button>
|
||||
<CardContent class="flex flex-wrap gap-2 p-4 pt-4">
|
||||
<Button variant="outline" size="sm" href={resolve('/modules')}>
|
||||
<Plus class="size-4" />
|
||||
Создать модуль
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" href={resolve('/directories')}>
|
||||
<Tags class="size-4" />
|
||||
Добавить community
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" href={resolve('/network')}>
|
||||
<Share2 class="size-4" />
|
||||
Добавить пира
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" href={resolve('/operations')}>
|
||||
<Play class="size-4" />
|
||||
Деплой (Apply)
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" href={resolve('/monitoring')}>
|
||||
<Gauge class="size-4" />
|
||||
Мониторинг
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
@@ -1,218 +1,180 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { apiJSON, apiMutate } from '$lib/api/client.js';
|
||||
import { resolve } from '$app/paths';
|
||||
import { apiJSON } from '$lib/api/client.js';
|
||||
import type {
|
||||
BgpCommunity,
|
||||
BgpCommunityCreate,
|
||||
CommunitiesResponse,
|
||||
DohProfile,
|
||||
DohProfileCreate,
|
||||
DohProfilesResponse
|
||||
} from '$lib/api/types.js';
|
||||
import { Button } from '$lib/components/ui/button/index.js';
|
||||
import { Badge } from '$lib/ui/core/badge/index.js';
|
||||
import { Button } from '$lib/ui/core/button/index.js';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription
|
||||
} from '$lib/components/ui/card/index.js';
|
||||
import FormField from '$lib/ui/patterns/form/form-field.svelte';
|
||||
import AppInput from '$lib/ui/patterns/form/app-input.svelte';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '$lib/components/ui/tabs/index.js';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
DialogDescription
|
||||
} from '$lib/components/ui/dialog/index.js';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogContent,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogCancel,
|
||||
AlertDialogAction
|
||||
} from '$lib/components/ui/alert-dialog/index.js';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow
|
||||
} from '$lib/components/ui/table/index.js';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import Plus from '@lucide/svelte/icons/plus';
|
||||
import Pencil from '@lucide/svelte/icons/pencil';
|
||||
import Trash2 from '@lucide/svelte/icons/trash-2';
|
||||
} from '$lib/ui/core/card/index.js';
|
||||
import { Alert, AlertDescription, AlertTitle } from '$lib/ui/core/alert/index.js';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '$lib/ui/core/tabs/index.js';
|
||||
import CardSkeleton from '$lib/ui/patterns/feedback/card-skeleton.svelte';
|
||||
import KpiMetricsGrid from '$lib/ui/patterns/kpi/kpi-metrics-grid.svelte';
|
||||
import PageHeader from '$lib/ui/app/page-header/page-header.svelte';
|
||||
import { notifyApiError } from '$lib/ui/app/toast.js';
|
||||
import DirectoriesCommunitiesCard from '$lib/components/directories/DirectoriesCommunitiesCard.svelte';
|
||||
import DirectoriesDohProfilesCard from '$lib/components/directories/DirectoriesDohProfilesCard.svelte';
|
||||
import { cn } from '$lib/utils.js';
|
||||
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
|
||||
import BookOpen from '@lucide/svelte/icons/book-open';
|
||||
import PageHeader from '$lib/ui/app/page-header/page-header.svelte';
|
||||
import Info from '@lucide/svelte/icons/info';
|
||||
import Tags from '@lucide/svelte/icons/tags';
|
||||
import Globe from '@lucide/svelte/icons/globe';
|
||||
import Library from '@lucide/svelte/icons/library';
|
||||
import ArrowRight from '@lucide/svelte/icons/arrow-right';
|
||||
|
||||
// --- Communities ---
|
||||
let communities = $state<BgpCommunity[]>([]);
|
||||
let commLoading = $state(false);
|
||||
let commDialog = $state(false);
|
||||
let commEdit = $state<BgpCommunity | null>(null);
|
||||
let commForm = $state<BgpCommunityCreate>({ community: '', title: '' });
|
||||
let commSaving = $state(false);
|
||||
let commDeleteTarget = $state<BgpCommunity | null>(null);
|
||||
|
||||
// --- DoH Profiles ---
|
||||
let dohProfiles = $state<DohProfile[]>([]);
|
||||
let dohLoading = $state(false);
|
||||
let dohDialog = $state(false);
|
||||
let dohEdit = $state<DohProfile | null>(null);
|
||||
let dohForm = $state<DohProfileCreate & { timeout_ms?: number | null }>({
|
||||
url: '',
|
||||
timeout_ms: null,
|
||||
vault_secret_ref: null
|
||||
});
|
||||
let dohSaving = $state(false);
|
||||
let dohDeleteTarget = $state<DohProfile | null>(null);
|
||||
let loading = $state(false);
|
||||
let initialLoading = $state(true);
|
||||
let loadError = $state<string | null>(null);
|
||||
let lastUpdated = $state<Date | null>(null);
|
||||
|
||||
async function loadComm() {
|
||||
commLoading = true;
|
||||
try {
|
||||
const r = await apiJSON<CommunitiesResponse>('/v1/communities?limit=200');
|
||||
communities = r.items;
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
commLoading = false;
|
||||
const statAccents = [
|
||||
{
|
||||
border: 'border-l-chart-2',
|
||||
bg: 'bg-chart-2/5',
|
||||
iconBg: 'bg-chart-2/15',
|
||||
iconText: 'text-chart-2'
|
||||
},
|
||||
{
|
||||
border: 'border-l-chart-3',
|
||||
bg: 'bg-chart-3/5',
|
||||
iconBg: 'bg-chart-3/15',
|
||||
iconText: 'text-chart-3'
|
||||
},
|
||||
{
|
||||
border: 'border-l-chart-1',
|
||||
bg: 'bg-chart-1/5',
|
||||
iconBg: 'bg-chart-1/15',
|
||||
iconText: 'text-chart-1'
|
||||
}
|
||||
] as const;
|
||||
|
||||
const kpiCards = $derived.by(() => [
|
||||
{
|
||||
id: 'communities',
|
||||
label: 'Сообщества BGP',
|
||||
value: initialLoading ? '—' : String(communities.length),
|
||||
description: 'теги префиксов в AS- и CDN-модулях',
|
||||
icon: Tags,
|
||||
accent: statAccents[0],
|
||||
badge: 'community'
|
||||
},
|
||||
{
|
||||
id: 'doh',
|
||||
label: 'DoH профили',
|
||||
value: initialLoading ? '—' : String(dohProfiles.length),
|
||||
description: 'резолвинг доменных модулей',
|
||||
icon: Globe,
|
||||
accent: statAccents[1],
|
||||
badge: 'DNS-over-HTTPS'
|
||||
},
|
||||
{
|
||||
id: 'shared',
|
||||
label: 'Справочники',
|
||||
value: 'Общие',
|
||||
description: 'используются всеми модулями tenant',
|
||||
icon: Library,
|
||||
accent: statAccents[2],
|
||||
badge: 'tenant-wide',
|
||||
href: '/modules' as const
|
||||
}
|
||||
]);
|
||||
|
||||
async function loadCommunities() {
|
||||
const r = await apiJSON<CommunitiesResponse>('/v1/communities?limit=200');
|
||||
communities = r.items;
|
||||
}
|
||||
|
||||
async function loadDoh() {
|
||||
dohLoading = true;
|
||||
const r = await apiJSON<DohProfilesResponse>('/v1/doh-profiles?limit=200');
|
||||
dohProfiles = r.items;
|
||||
}
|
||||
|
||||
async function load() {
|
||||
if (!initialLoading) loading = true;
|
||||
loadError = null;
|
||||
try {
|
||||
const r = await apiJSON<DohProfilesResponse>('/v1/doh-profiles?limit=200');
|
||||
dohProfiles = r.items;
|
||||
await Promise.all([loadCommunities(), loadDoh()]);
|
||||
lastUpdated = new Date();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : String(e));
|
||||
loadError = e instanceof Error ? e.message : String(e);
|
||||
notifyApiError(e);
|
||||
} finally {
|
||||
dohLoading = false;
|
||||
loading = false;
|
||||
initialLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
loadComm();
|
||||
loadDoh();
|
||||
});
|
||||
|
||||
// --- Community actions ---
|
||||
function commDisplay(c: BgpCommunity | null) {
|
||||
if (!c) return '';
|
||||
const t = c.title?.trim();
|
||||
return t || c.community;
|
||||
}
|
||||
|
||||
function openCommCreate() {
|
||||
commEdit = null;
|
||||
commForm = { community: '', title: '' };
|
||||
commDialog = true;
|
||||
}
|
||||
function openCommEdit(c: BgpCommunity) {
|
||||
commEdit = c;
|
||||
commForm = { community: c.community, title: c.title ?? '' };
|
||||
commDialog = true;
|
||||
}
|
||||
async function saveComm() {
|
||||
if (!commForm.community.trim()) {
|
||||
toast.error('Укажите community');
|
||||
return;
|
||||
}
|
||||
commSaving = true;
|
||||
async function refreshCommunities() {
|
||||
try {
|
||||
const body = { ...commForm, title: commForm.title?.trim() || undefined };
|
||||
if (commEdit) {
|
||||
await apiMutate(`/v1/communities/${commEdit.id}`, 'PATCH', body);
|
||||
toast.success('Запись сообщества обновлена');
|
||||
} else {
|
||||
await apiMutate('/v1/communities', 'POST', body);
|
||||
toast.success('Сообщество создано');
|
||||
}
|
||||
commDialog = false;
|
||||
await loadComm();
|
||||
loadError = null;
|
||||
await loadCommunities();
|
||||
lastUpdated = new Date();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
commSaving = false;
|
||||
}
|
||||
}
|
||||
async function deleteComm() {
|
||||
if (!commDeleteTarget) return;
|
||||
try {
|
||||
await apiMutate(`/v1/communities/${commDeleteTarget.id}`, 'DELETE', undefined, {
|
||||
idempotent: false
|
||||
});
|
||||
toast.success('Удалено');
|
||||
commDeleteTarget = null;
|
||||
await loadComm();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : String(e));
|
||||
loadError = e instanceof Error ? e.message : String(e);
|
||||
notifyApiError(e);
|
||||
}
|
||||
}
|
||||
|
||||
// --- DoH actions ---
|
||||
function openDohCreate() {
|
||||
dohEdit = null;
|
||||
dohForm = { url: '', timeout_ms: null, vault_secret_ref: null };
|
||||
dohDialog = true;
|
||||
}
|
||||
function openDohEdit(d: DohProfile) {
|
||||
dohEdit = d;
|
||||
dohForm = { url: d.url, timeout_ms: d.timeout_ms, vault_secret_ref: d.vault_secret_ref };
|
||||
dohDialog = true;
|
||||
}
|
||||
async function saveDoh() {
|
||||
if (!dohForm.url.trim()) {
|
||||
toast.error('Укажите URL');
|
||||
return;
|
||||
}
|
||||
dohSaving = true;
|
||||
async function refreshDoh() {
|
||||
try {
|
||||
if (dohEdit) {
|
||||
await apiMutate(`/v1/doh-profiles/${dohEdit.id}`, 'PATCH', dohForm);
|
||||
toast.success('DoH профиль обновлён');
|
||||
} else {
|
||||
await apiMutate('/v1/doh-profiles', 'POST', dohForm);
|
||||
toast.success('DoH профиль создан');
|
||||
}
|
||||
dohDialog = false;
|
||||
loadError = null;
|
||||
await loadDoh();
|
||||
lastUpdated = new Date();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
dohSaving = false;
|
||||
}
|
||||
}
|
||||
async function deleteDoh() {
|
||||
if (!dohDeleteTarget) return;
|
||||
try {
|
||||
await apiMutate(`/v1/doh-profiles/${dohDeleteTarget.id}`, 'DELETE', undefined, {
|
||||
idempotent: false
|
||||
});
|
||||
toast.success('Удалено');
|
||||
dohDeleteTarget = null;
|
||||
await loadDoh();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : String(e));
|
||||
loadError = e instanceof Error ? e.message : String(e);
|
||||
notifyApiError(e);
|
||||
}
|
||||
}
|
||||
|
||||
onMount(load);
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-6">
|
||||
<PageHeader
|
||||
title="Справочники"
|
||||
description="Сообщества BGP и DoH-профили для резолвинга доменов."
|
||||
description={lastUpdated
|
||||
? `Сообщества BGP и DoH-профили для резолвинга доменов. Обновлено: ${lastUpdated.toLocaleTimeString('ru-RU')}`
|
||||
: 'Сообщества BGP и DoH-профили для резолвинга доменов.'}
|
||||
icon={BookOpen}
|
||||
iconClass="bg-chart-2/15 text-chart-2"
|
||||
>
|
||||
{#snippet actions()}
|
||||
<Button variant="outline" size="sm" onclick={load} disabled={loading}>
|
||||
<RefreshCw class={loading ? 'animate-spin' : ''} />
|
||||
Обновить
|
||||
</Button>
|
||||
{/snippet}
|
||||
</PageHeader>
|
||||
|
||||
<Alert class="border-info/30 bg-info/5">
|
||||
<Info class="text-info" />
|
||||
<AlertTitle>О справочниках</AlertTitle>
|
||||
<AlertDescription>
|
||||
Сообщества BGP используются в AS- и CDN-модулях для тегирования префиксов. DoH-профили — в
|
||||
доменных модулях для DNS-over-HTTPS резолвинга. Настройка модулей — на странице
|
||||
<Button variant="link" class="h-auto p-0" href={resolve('/modules')}>Модули</Button>.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<KpiMetricsGrid
|
||||
cards={kpiCards}
|
||||
loading={initialLoading}
|
||||
skeletonCount={3}
|
||||
class="sm:grid-cols-3"
|
||||
/>
|
||||
|
||||
<Tabs value="communities">
|
||||
@@ -221,215 +183,24 @@
|
||||
<TabsTrigger value="doh">DoH профили</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<!-- Communities -->
|
||||
<TabsContent value="communities" class="mt-4">
|
||||
<Card>
|
||||
<CardHeader class="flex flex-row items-center justify-between pb-2">
|
||||
<div>
|
||||
<CardTitle class="text-base">Сообщества BGP</CardTitle>
|
||||
<CardDescription>Используются для тегирования префиксов</CardDescription>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<Button variant="outline" size="sm" onclick={loadComm} disabled={commLoading}>
|
||||
<RefreshCw class={commLoading ? 'animate-spin' : ''} />
|
||||
</Button>
|
||||
<Button size="sm" onclick={openCommCreate}><Plus />Добавить</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent class="p-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Код сообщества</TableHead>
|
||||
<TableHead>Название</TableHead>
|
||||
<TableHead>ID</TableHead>
|
||||
<TableHead class="w-20"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{#each communities as c (c.id)}
|
||||
<TableRow>
|
||||
<TableCell class="font-mono text-sm font-medium">{c.community}</TableCell>
|
||||
<TableCell>{c.title?.trim() || '—'}</TableCell>
|
||||
<TableCell class="font-mono text-xs text-muted-foreground">{c.id}</TableCell>
|
||||
<TableCell>
|
||||
<div class="flex gap-1">
|
||||
<Button variant="ghost" size="icon-sm" onclick={() => openCommEdit(c)}
|
||||
><Pencil class="size-3.5" /></Button
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
class="text-destructive"
|
||||
onclick={() => (commDeleteTarget = c)}><Trash2 class="size-3.5" /></Button
|
||||
>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{:else}
|
||||
<TableRow>
|
||||
<TableCell colspan={4} class="text-muted-foreground text-center py-8">
|
||||
{commLoading ? 'Загрузка…' : 'Нет записей. Создайте первую.'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{/each}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<DirectoriesCommunitiesCard
|
||||
items={communities}
|
||||
{loading}
|
||||
{initialLoading}
|
||||
error={loadError}
|
||||
onRefresh={refreshCommunities}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<!-- DoH Profiles -->
|
||||
<TabsContent value="doh" class="mt-4">
|
||||
<Card>
|
||||
<CardHeader class="flex flex-row items-center justify-between pb-2">
|
||||
<div>
|
||||
<CardTitle class="text-base">DoH профили</CardTitle>
|
||||
<CardDescription>DNS-over-HTTPS серверы для резолвинга доменных модулей</CardDescription
|
||||
>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<Button variant="outline" size="sm" onclick={loadDoh} disabled={dohLoading}>
|
||||
<RefreshCw class={dohLoading ? 'animate-spin' : ''} />
|
||||
</Button>
|
||||
<Button size="sm" onclick={openDohCreate}><Plus />Добавить</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent class="p-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>URL</TableHead>
|
||||
<TableHead>Таймаут (мс)</TableHead>
|
||||
<TableHead>ID</TableHead>
|
||||
<TableHead class="w-20"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{#each dohProfiles as d (d.id)}
|
||||
<TableRow>
|
||||
<TableCell class="font-mono text-sm">{d.url}</TableCell>
|
||||
<TableCell class="text-muted-foreground">{d.timeout_ms ?? '—'}</TableCell>
|
||||
<TableCell class="font-mono text-xs text-muted-foreground">{d.id}</TableCell>
|
||||
<TableCell>
|
||||
<div class="flex gap-1">
|
||||
<Button variant="ghost" size="icon-sm" onclick={() => openDohEdit(d)}
|
||||
><Pencil class="size-3.5" /></Button
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
class="text-destructive"
|
||||
onclick={() => (dohDeleteTarget = d)}><Trash2 class="size-3.5" /></Button
|
||||
>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{:else}
|
||||
<TableRow>
|
||||
<TableCell colspan={4} class="text-muted-foreground text-center py-8">
|
||||
{dohLoading ? 'Загрузка…' : 'Нет DoH профилей.'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{/each}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<DirectoriesDohProfilesCard
|
||||
items={dohProfiles}
|
||||
{loading}
|
||||
{initialLoading}
|
||||
error={loadError}
|
||||
onRefresh={refreshDoh}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
<!-- Community Dialog -->
|
||||
<Dialog bind:open={commDialog}>
|
||||
<DialogContent class="sm:max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{commEdit ? 'Редактировать сообщество BGP' : 'Новое сообщество BGP'}</DialogTitle
|
||||
>
|
||||
</DialogHeader>
|
||||
<div class="flex flex-col gap-4 py-2">
|
||||
<FormField label="Код сообщества" id="c-community" required>
|
||||
<AppInput id="c-community" bind:value={commForm.community} placeholder="65001:120" />
|
||||
</FormField>
|
||||
<FormField label="Название" id="c-title" description="Человекочитаемое имя для списков">
|
||||
<AppInput id="c-title" bind:value={commForm.title} placeholder="Название" />
|
||||
</FormField>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onclick={() => (commDialog = false)}>Отмена</Button>
|
||||
<Button onclick={saveComm} disabled={commSaving}
|
||||
>{commSaving ? 'Сохранение…' : commEdit ? 'Сохранить' : 'Создать'}</Button
|
||||
>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<AlertDialog
|
||||
open={!!commDeleteTarget}
|
||||
onOpenChange={(v) => {
|
||||
if (!v) commDeleteTarget = null;
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Удалить сообщество «{commDisplay(commDeleteTarget)}»?</AlertDialogTitle>
|
||||
<AlertDialogDescription
|
||||
>Это приведёт к удалению привязки во всех модулях.</AlertDialogDescription
|
||||
>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel onclick={() => (commDeleteTarget = null)}>Отмена</AlertDialogCancel>
|
||||
<AlertDialogAction onclick={deleteComm}>Удалить</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
<!-- DoH Dialog -->
|
||||
<Dialog bind:open={dohDialog}>
|
||||
<DialogContent class="sm:max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{dohEdit ? 'Редактировать' : 'Новый'} DoH профиль</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div class="flex flex-col gap-4 py-2">
|
||||
<FormField label="URL" id="doh-url" required>
|
||||
<AppInput
|
||||
id="doh-url"
|
||||
bind:value={dohForm.url}
|
||||
placeholder="https://dns.google/dns-query"
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Таймаут (мс)" id="doh-timeout">
|
||||
<AppInput
|
||||
id="doh-timeout"
|
||||
type="number"
|
||||
bind:value={dohForm.timeout_ms}
|
||||
placeholder="5000"
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onclick={() => (dohDialog = false)}>Отмена</Button>
|
||||
<Button onclick={saveDoh} disabled={dohSaving}
|
||||
>{dohSaving ? 'Сохранение…' : dohEdit ? 'Сохранить' : 'Создать'}</Button
|
||||
>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<AlertDialog
|
||||
open={!!dohDeleteTarget}
|
||||
onOpenChange={(v) => {
|
||||
if (!v) dohDeleteTarget = null;
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Удалить DoH профиль?</AlertDialogTitle>
|
||||
<AlertDialogDescription>{dohDeleteTarget?.url}</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel onclick={() => (dohDeleteTarget = null)}>Отмена</AlertDialogCancel>
|
||||
<AlertDialogAction onclick={deleteDoh}>Удалить</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
+130
-175
@@ -1,65 +1,106 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { resolve } from '$app/paths';
|
||||
import { apiJSON, apiMutate } from '$lib/api/client.js';
|
||||
import type { ModuleRow, ModulesResponse, ModuleCreate } 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, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card/index.js';
|
||||
import { Input } from '$lib/components/ui/input/index.js';
|
||||
import { Label } from '$lib/components/ui/label/index.js';
|
||||
import type { ModuleRow, ModulesResponse } from '$lib/api/types.js';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
DialogDescription
|
||||
} from '$lib/components/ui/dialog/index.js';
|
||||
formatDateTime,
|
||||
moduleIntervalLabel,
|
||||
moduleTypeBadgeVariant
|
||||
} from '$lib/modules/display.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 {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger
|
||||
} from '$lib/components/ui/select/index.js';
|
||||
import { Switch } from '$lib/components/ui/switch/index.js';
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription
|
||||
} from '$lib/ui/core/card/index.js';
|
||||
import { Alert, AlertDescription, AlertTitle } from '$lib/ui/core/alert/index.js';
|
||||
import { Checkbox } from '$lib/ui/core/checkbox/index.js';
|
||||
import AppDataTable from '$lib/ui/patterns/data-table/app-data-table.svelte';
|
||||
import CardSkeleton from '$lib/ui/patterns/feedback/card-skeleton.svelte';
|
||||
import KpiMetricsGrid from '$lib/ui/patterns/kpi/kpi-metrics-grid.svelte';
|
||||
import PageHeader from '$lib/ui/app/page-header/page-header.svelte';
|
||||
import { confirm } from '$lib/ui/patterns/confirm/confirm-state.svelte.js';
|
||||
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
|
||||
import { resolve } from '$app/paths';
|
||||
import ModuleCreateDialog from '$lib/components/modules/ModuleCreateDialog.svelte';
|
||||
import { cn } from '$lib/utils.js';
|
||||
import Plus from '@lucide/svelte/icons/plus';
|
||||
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
|
||||
import ExternalLink from '@lucide/svelte/icons/external-link';
|
||||
import Trash2 from '@lucide/svelte/icons/trash-2';
|
||||
import { moduleTypeRu } from '$lib/ui-labels.js';
|
||||
import Boxes from '@lucide/svelte/icons/boxes';
|
||||
import Info from '@lucide/svelte/icons/info';
|
||||
import CheckCircle2 from '@lucide/svelte/icons/check-circle-2';
|
||||
import CircleOff from '@lucide/svelte/icons/circle-off';
|
||||
|
||||
let rows = $state<ModuleRow[]>([]);
|
||||
let loading = $state(false);
|
||||
let initialLoading = $state(true);
|
||||
let dialogOpen = $state(false);
|
||||
let saving = $state(false);
|
||||
let selectedModuleIds = $state(new Set<string>());
|
||||
let deletingBulkModules = $state(false);
|
||||
let selectedModuleIds = $state(new Set<string>());
|
||||
let lastUpdated = $state<Date | null>(null);
|
||||
|
||||
const selectedModulesCount = $derived(selectedModuleIds.size);
|
||||
const allModulesSelected = $derived(rows.length > 0 && selectedModuleIds.size === rows.length);
|
||||
const someModulesSelected = $derived(selectedModuleIds.size > 0 && !allModulesSelected);
|
||||
const enabledCount = $derived(rows.filter((m) => m.enabled).length);
|
||||
const disabledCount = $derived(rows.filter((m) => !m.enabled).length);
|
||||
|
||||
let form = $state<ModuleCreate>({
|
||||
type: 'AS_PREFIXES',
|
||||
name: '',
|
||||
enabled: true,
|
||||
priority: 0
|
||||
});
|
||||
|
||||
const moduleTypes = [
|
||||
{ value: 'AS_PREFIXES', label: 'AS (номера)' },
|
||||
{ value: 'CDN_CIDRS', label: 'CDN CIDRs' },
|
||||
{ value: 'DOMAINS', label: 'Домены' },
|
||||
{ value: 'IP_RANGES', label: 'IP Ranges' }
|
||||
const statAccents = [
|
||||
{
|
||||
border: 'border-l-chart-1',
|
||||
bg: 'bg-chart-1/5',
|
||||
iconBg: 'bg-chart-1/15',
|
||||
iconText: 'text-chart-1'
|
||||
},
|
||||
{
|
||||
border: 'border-l-chart-2',
|
||||
bg: 'bg-chart-2/5',
|
||||
iconBg: 'bg-chart-2/15',
|
||||
iconText: 'text-chart-2'
|
||||
},
|
||||
{
|
||||
border: 'border-l-chart-4',
|
||||
bg: 'bg-chart-4/5',
|
||||
iconBg: 'bg-chart-4/15',
|
||||
iconText: 'text-chart-4'
|
||||
}
|
||||
] as const;
|
||||
|
||||
const kpiCards = $derived.by(() => [
|
||||
{
|
||||
id: 'total',
|
||||
label: 'Всего модулей',
|
||||
value: initialLoading ? '—' : String(rows.length),
|
||||
description: 'AS, CDN, домены, IP',
|
||||
icon: Boxes,
|
||||
accent: statAccents[0],
|
||||
badge: 'в системе'
|
||||
},
|
||||
{
|
||||
id: 'enabled',
|
||||
label: 'Включено',
|
||||
value: initialLoading ? '—' : String(enabledCount),
|
||||
description: 'участвуют в ревизиях',
|
||||
icon: CheckCircle2,
|
||||
accent: statAccents[1],
|
||||
badge: 'активных'
|
||||
},
|
||||
{
|
||||
id: 'disabled',
|
||||
label: 'Выключено',
|
||||
value: initialLoading ? '—' : String(disabledCount),
|
||||
description: disabledCount > 0 ? 'не участвуют в сборке' : 'все модули включены',
|
||||
icon: CircleOff,
|
||||
accent: statAccents[2],
|
||||
badge: disabledCount > 0 ? 'отключены' : 'нет отключённых'
|
||||
}
|
||||
]);
|
||||
|
||||
const moduleColumns = [
|
||||
{ id: 'select', label: '', class: 'w-10' },
|
||||
{ id: 'name', label: 'Название', sortable: true, sortValue: (m: ModuleRow) => m.name },
|
||||
@@ -82,78 +123,23 @@
|
||||
] as const;
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
if (!initialLoading) loading = true;
|
||||
try {
|
||||
const m = await apiJSON<ModulesResponse>('/v1/modules?limit=200');
|
||||
rows = m.items ?? [];
|
||||
const validIds = new Set(rows.map((item) => item.id));
|
||||
selectedModuleIds = new Set([...selectedModuleIds].filter((id) => validIds.has(id)));
|
||||
lastUpdated = new Date();
|
||||
} catch (e) {
|
||||
notifyApiError(e);
|
||||
} finally {
|
||||
loading = false;
|
||||
initialLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(load);
|
||||
|
||||
async function create() {
|
||||
if (!form.name.trim()) {
|
||||
notify.error('Укажите название модуля');
|
||||
return;
|
||||
}
|
||||
saving = true;
|
||||
try {
|
||||
await apiMutate('/v1/modules', 'POST', form);
|
||||
notify.success('Модуль создан');
|
||||
dialogOpen = false;
|
||||
form = { type: 'AS_PREFIXES', name: '', enabled: true, priority: 0 };
|
||||
await load();
|
||||
} catch (e) {
|
||||
notifyApiError(e);
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
function typeBadgeVariant(type: string) {
|
||||
switch (type) {
|
||||
case 'AS_PREFIXES':
|
||||
return 'default';
|
||||
case 'CDN_CIDRS':
|
||||
return 'secondary';
|
||||
case 'DOMAINS':
|
||||
return 'outline';
|
||||
case 'IP_RANGES':
|
||||
return 'outline';
|
||||
default:
|
||||
return 'outline';
|
||||
}
|
||||
}
|
||||
|
||||
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 '—';
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
function toggleModuleSelection(id: string) {
|
||||
const next = new Set(selectedModuleIds);
|
||||
if (next.has(id)) next.delete(id);
|
||||
@@ -202,7 +188,9 @@
|
||||
<div class="flex flex-col gap-6">
|
||||
<PageHeader
|
||||
title="Модули префиксов"
|
||||
description="Управление модулями — AS, CDN, домены, IP-диапазоны."
|
||||
description={lastUpdated
|
||||
? `Управление модулями — AS, CDN, домены, IP-диапазоны. Обновлено: ${lastUpdated.toLocaleTimeString('ru-RU')}`
|
||||
: 'Управление модулями — AS, CDN, домены, IP-диапазоны.'}
|
||||
icon={Boxes}
|
||||
iconClass="bg-chart-1/15 text-chart-1"
|
||||
>
|
||||
@@ -218,9 +206,31 @@
|
||||
{/snippet}
|
||||
</PageHeader>
|
||||
|
||||
<Alert class="border-info/30 bg-info/5">
|
||||
<Info class="text-info" />
|
||||
<AlertTitle>О модулях префиксов</AlertTitle>
|
||||
<AlertDescription>
|
||||
Модули собирают префиксы из AS (RIPEstat), CDN URL, доменов (DoH) и статических CIDR.
|
||||
Расписание и ручной refresh — на странице
|
||||
<Button variant="link" class="h-auto p-0" href={resolve('/schedule')}>Расписание</Button>.
|
||||
Результаты обновления и ревизии — в
|
||||
<Button variant="link" class="h-auto p-0" href={resolve('/operations')}>Операциях</Button>.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<KpiMetricsGrid
|
||||
cards={kpiCards}
|
||||
loading={initialLoading}
|
||||
skeletonCount={3}
|
||||
class="sm:grid-cols-3"
|
||||
/>
|
||||
|
||||
<Card>
|
||||
<CardHeader class="flex flex-wrap items-center justify-between gap-2 border-b py-3">
|
||||
<CardTitle class="text-base">Список модулей</CardTitle>
|
||||
<div>
|
||||
<CardTitle class="text-base">Список модулей</CardTitle>
|
||||
<CardDescription>Клик по названию открывает карточку модуля и его записи.</CardDescription>
|
||||
</div>
|
||||
{#if selectedModulesCount > 0}
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="text-sm text-muted-foreground">Выбрано: {selectedModulesCount}</span>
|
||||
@@ -241,10 +251,22 @@
|
||||
columns={[...moduleColumns]}
|
||||
{rows}
|
||||
rowKey={(m) => m.id}
|
||||
{loading}
|
||||
loading={initialLoading || loading}
|
||||
emptyTitle="Нет модулей"
|
||||
emptyDescription="Создайте первый модуль."
|
||||
>
|
||||
{#snippet toolbar()}
|
||||
{#if rows.length > 0}
|
||||
<div class="flex items-center gap-2">
|
||||
<Checkbox
|
||||
checked={allModulesSelected}
|
||||
onCheckedChange={(v) => toggleAllModules(v === true)}
|
||||
aria-label="Выбрать все модули"
|
||||
/>
|
||||
<span class="text-sm text-muted-foreground">Выбрать все</span>
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
{#snippet cell({ row: m, column })}
|
||||
{#if column.id === 'select'}
|
||||
<Checkbox
|
||||
@@ -253,9 +275,14 @@
|
||||
onCheckedChange={() => toggleModuleSelection(m.id)}
|
||||
/>
|
||||
{:else if column.id === 'name'}
|
||||
<span class="font-medium">{m.name}</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium">{m.name}</span>
|
||||
<Button variant="ghost" size="icon-sm" href={resolve(`/modules/${m.id}`)}>
|
||||
<ExternalLink class="size-3.5" aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
{:else if column.id === 'type'}
|
||||
<Badge variant={typeBadgeVariant(m.type)}>{moduleTypeRu(m.type)}</Badge>
|
||||
<Badge variant={moduleTypeBadgeVariant(m.type)}>{moduleTypeRu(m.type)}</Badge>
|
||||
{:else if column.id === 'priority'}
|
||||
<span class="text-muted-foreground">{m.priority}</span>
|
||||
{:else if column.id === 'interval'}
|
||||
@@ -265,11 +292,9 @@
|
||||
>{formatDateTime(m.last_refreshed_at)}</span
|
||||
>
|
||||
{:else if column.id === 'status'}
|
||||
{#if m.enabled}
|
||||
<Badge variant="default" class="text-xs">вкл</Badge>
|
||||
{:else}
|
||||
<Badge variant="secondary" class="text-xs">выкл</Badge>
|
||||
{/if}
|
||||
<Badge variant={moduleEnabledBadgeVariant(!!m.enabled)} class="text-xs">
|
||||
{moduleEnabledRu(!!m.enabled)}
|
||||
</Badge>
|
||||
{:else if column.id === 'actions'}
|
||||
<Button variant="ghost" size="icon-sm" href={resolve(`/modules/${m.id}`)}>
|
||||
<ExternalLink class="size-3.5" />
|
||||
@@ -281,74 +306,4 @@
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- Create Dialog -->
|
||||
<Dialog bind:open={dialogOpen}>
|
||||
<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={() => (dialogOpen = false)}>Отмена</Button>
|
||||
<Button onclick={create} disabled={saving}>{saving ? 'Создание…' : 'Создать'}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<ModuleCreateDialog bind:open={dialogOpen} onClose={() => {}} onCreated={load} />
|
||||
File diff suppressed because it is too large.
Load diff
Loaded 100 of 106 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user