Compare commits
9
Commits
aff27e8f7b
...
v1.0.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6ca34b9008 | ||
|
|
bb334b10f5 | ||
|
|
4c23232c4e | ||
|
|
c263fd5c7e | ||
|
|
6bee5adc34 | ||
|
|
64516c316d | ||
|
|
ea65451160 | ||
|
|
250cdea35d | ||
|
|
1dd713514c |
No files matched your search
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"extends": ["@commitlint/config-conventional"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
---
|
||||||
|
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`, «сгенерируй коммит» — в т.ч. если это указано в плане или [AGENTS.md](../../AGENTS.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` | исправление бага | patch |
|
||||||
|
| `perf` | ускорение без смены API | patch |
|
||||||
|
| `refactor` | реструктуризация без смены поведения | — |
|
||||||
|
| `docs` | только документация | — |
|
||||||
|
| `test` | тесты | — |
|
||||||
|
| `ci` | CI/CD (`.gitea/`, workflows) | — |
|
||||||
|
| `chore` | обслуживание, deps, `.cursor/` | — |
|
||||||
|
|
||||||
|
## 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):** почему выбран 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-токене с валидной сессией.
|
||||||
|
```
|
||||||
@@ -234,7 +234,7 @@ npx @redocly/cli lint docs/openapi.yaml
|
|||||||
# birdfmt: go test ./internal/birdfmt/... -count=1
|
# birdfmt: go test ./internal/birdfmt/... -count=1
|
||||||
```
|
```
|
||||||
|
|
||||||
**Рекомендуется (не внедрено):** CI job `web`; `.golangci.yml`; pre-commit gofmt/prettier.
|
**Рекомендуется (частично внедрено):** CI job `web` (Gitea); `scripts/lint-httpapi.sh` в job `go`; `.golangci.yml` (локально); pre-commit gofmt/prettier.
|
||||||
|
|
||||||
**Только code review:** слои SQL; роли; idempotency; OpenAPI bodies; secrets в compose.
|
**Только code review:** слои SQL; роли; idempotency; OpenAPI bodies; secrets в compose.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
---
|
||||||
|
name: commit-message
|
||||||
|
description: >-
|
||||||
|
ОБЯЗАТЕЛЬНО при commit, коммит, закоммить, commit message, conventional commit,
|
||||||
|
staged, semantic-release, «сгенерируй коммит», git commit: ПЕРВЫМ делом Shell —
|
||||||
|
scripts/commit/staged-context.ps1; затем Conventional Commit (заголовок EN, тело RU).
|
||||||
|
---
|
||||||
|
|
||||||
|
# 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`:
|
||||||
|
|
||||||
|
- **type** — по смыслу diff (`feat` / `fix` / …), не по умолчанию `chore`.
|
||||||
|
- **scope** — из JSON группы или доминирующий при merge.
|
||||||
|
- **summary** — конкретный, английский, императив.
|
||||||
|
- **body** — русский: что, зачем, edge cases, breaking impact.
|
||||||
|
|
||||||
|
## Вывод пользователю
|
||||||
|
|
||||||
|
Для **каждого** коммита:
|
||||||
|
|
||||||
|
### 1. Готовое сообщение
|
||||||
|
|
||||||
|
```
|
||||||
|
<type>(<scope>): <summary>
|
||||||
|
|
||||||
|
<тело>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Пояснение (RU)
|
||||||
|
|
||||||
|
- Почему выбран type/scope.
|
||||||
|
- Риски и 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`
|
||||||
+32
-42
@@ -1,75 +1,65 @@
|
|||||||
# Gitea Actions
|
# Gitea Actions
|
||||||
|
|
||||||
В репозитории включён workflow [workflows/ci.yaml](workflows/ci.yaml).
|
Workflow-файлы в [workflows/](workflows/):
|
||||||
|
|
||||||
Job **changes** вычисляет два флага:
|
| Workflow | Триггер | Назначение |
|
||||||
|
|----------|---------|------------|
|
||||||
|
| [ci.yaml](workflows/ci.yaml) | push/PR в `main`/`master` | openapi, web, go, bird2, commitlint (PR) |
|
||||||
|
| [release.yaml](workflows/release.yaml) | после успешного CI (push в main) | semantic-release → tag, CHANGELOG, Gitea Release |
|
||||||
|
| [publish.yaml](workflows/publish.yaml) | push тега `v*` | docker buildx bake → Container Registry |
|
||||||
|
|
||||||
- **`openapi`** — в диффе есть `docs/openapi.yaml` или `redocly.yaml` → запускается **openapi** (Redocly lint).
|
Подробнее о версионировании: [docs/releasing.md](../docs/releasing.md).
|
||||||
- **`code`** — в диффе есть **хотя бы один файл вне «только документация»** → запускаются **go**, **bird2** и при push в main/master **docker-images**.
|
|
||||||
|
|
||||||
Если изменены **только** файлы из списка ниже, **`code=false`**: Go-тесты, bird2 и сборка образов **не** запускаются; при необходимости всё ещё выполняется **openapi**, если трогали OpenAPI или `redocly.yaml`.
|
## CI (quality gates)
|
||||||
|
|
||||||
**Список путей «только документация» (без полной сборки):** всё под `docs/`, корневой `README.md`, `web/README.md`, `.gitea/README.md`, `redocly.yaml`.
|
Job **changes** вычисляет флаги по путям в diff (`openapi`, `go`, `web`, `bird_conf`, …). Изменение `.gitea/workflows/*` поднимает полный прогон.
|
||||||
|
|
||||||
Любой другой путь (в т.ч. `.gitea/workflows/ci.yaml`, `go.mod`, `web/package.json`, `deploy/**`, `**/*.go`) включает полный пайплайн.
|
На **pull request** дополнительно запускается **commitlint** (Conventional Commits).
|
||||||
|
|
||||||
Требования к runner:
|
Требования к runner:
|
||||||
|
|
||||||
- Метка `ubuntu-latest` (или измените `runs-on` под свои labels).
|
- Метка `ubuntu-latest`.
|
||||||
- Job `bird2` ставит пакет **bird2** из репозитория **Ubuntu** на runner (как образ `evobgp-bird2` на `ubuntu:noble`) и вызывает `bird -c … -p` по файлам в checkout (без вложенного Docker: иначе bind-mount `/workspace/...` часто не виден демону Docker на хосте).
|
- Job `bird2` ставит **bird2** из репозитория Ubuntu и вызывает `bird -c … -p`.
|
||||||
- Job **docker-images** требует **Docker** и (для push) доступ к Container Registry Gitea с runner’а.
|
- Job **publish** требует Docker и доступ к Container Registry.
|
||||||
|
|
||||||
## Сборка и публикация образов (`docker-images`)
|
## Release (semantic-release)
|
||||||
|
|
||||||
Запускается только при **push** в ветки `main` или `master` (после успешного job `go`). Собираются образы из `deploy/docker/*` (все бинарники через `gobinary`, плюс `evobgp-web`, `evobgp-agent`, `evobgp-bird2`).
|
После успешного CI на push в `main`/`master` workflow **Release** запускает `npx semantic-release` (корневой `package.json`, [.releaserc.json](../.releaserc.json)).
|
||||||
|
|
||||||
### Секреты репозитория (для push в реестр)
|
Результат при releasable-коммитах:
|
||||||
|
|
||||||
Хост Container Registry в workflow **зашит**: **`git.shts.su`** (`docker/login-action` и теги образов).
|
- git tag `vX.Y.Z`;
|
||||||
|
- commit `CHANGELOG.md` с `[skip ci]`;
|
||||||
|
- Gitea Release с release notes.
|
||||||
|
|
||||||
В настройках репозитория Gitea: **Settings → Actions → Secrets** (или аналог):
|
### Секреты
|
||||||
|
|
||||||
| Секрет | Назначение |
|
Один PAT — **`ACTIONS_PAT`**: push/t/tags, releases, запись в Container Registry. Если не задан — fallback на **`gitea.token`** (нужны права на releases и packages в Gitea).
|
||||||
|--------|------------|
|
|
||||||
| `ACTIONS_PAT` | (Опционально) PAT с правом **записи пакетов**. Если не задан, для `docker login` используется **`gitea.token`** (нужны права у workflow на пакеты). |
|
|
||||||
|
|
||||||
Вход в реестр в workflow:
|
`GITEA_URL`: `https://git.shts.su` (задано в workflow).
|
||||||
|
|
||||||
- **registry:** `git.shts.su`;
|
## Publish (образы)
|
||||||
- **username:** `${{ gitea.actor }}`;
|
|
||||||
- **password:** `${{ secrets.ACTIONS_PAT || gitea.token }}`.
|
|
||||||
|
|
||||||
### Почему шаг «Log in to Gitea Registry» мог быть skipped
|
Запускается только при **push git-тега** `vX.Y.Z` (не при каждом push в main).
|
||||||
|
|
||||||
Раньше в `if:` стояло **`secrets.ACTIONS_PAT != ''`**. В **Gitea act_runner** такая проверка секрета в условии шага часто **всегда ложна**, из‑за этого логин **всегда skipped**. Сейчас условие только `docker_push == 'true'` (всегда при нормальном шаге **Prepare**), пароль: **`secrets.ACTIONS_PAT || gitea.token`**.
|
Собираются все образы из `deploy/docker/*` (`docker buildx bake default --push`). Semver из тега передаётся в Go-бинарники (`VERSION`, `GIT_SHA`, `BUILD_TIME`).
|
||||||
|
|
||||||
Если после правки `docker login` падает с 401/403 — выдайте job-токену или PAT право **записи в Container Registry** (в настройках Gitea / организации / репозитория, в зависимости от версии).
|
Registry: **`git.shts.su`**. Login: `${{ gitea.actor }}` / `${{ secrets.ACTIONS_PAT || gitea.token }}` (тот же **`ACTIONS_PAT`**, что и для release).
|
||||||
|
|
||||||
Если **`gitea.token`** в выражениях недоступен, в [ci.yaml](workflows/ci.yaml) замените пароль на `${{ secrets.ACTIONS_PAT || github.token }}`.
|
### Теги образов
|
||||||
|
|
||||||
### Имена образов и теги
|
|
||||||
|
|
||||||
Формат:
|
|
||||||
|
|
||||||
```text
|
```text
|
||||||
git.shts.su/<owner_lowercase>/<имя>:<тег>
|
git.shts.su/<owner_lowercase>/<имя>:<тег>
|
||||||
```
|
```
|
||||||
|
|
||||||
где `<имя>` — одно из: `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>`.
|
Теги при релизе `v1.2.3`: `latest`, `v1.2.3`, короткий SHA, `sha-<full_commit>`.
|
||||||
|
|
||||||
Пример pull:
|
Пример:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker pull git.shts.su/myuser/evobgp-api:latest
|
docker pull git.shts.su/myuser/evobgp-api:v1.2.3
|
||||||
```
|
```
|
||||||
|
|
||||||
Полная таблица образов, тегов, ссылок на страницы пакетов в Gitea и минимальный `docker run` для сервера без сборки — в [docs/quickstart.md](../docs/quickstart.md) (раздел «Готовые образы без сборки»).
|
Подробности — [docs/quickstart.md](../docs/quickstart.md), [deploy/docker/README.md](../deploy/docker/README.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), чтобы не публиковать образы из чужих форков без явной политики.
|
|
||||||
+33
-129
@@ -121,6 +121,26 @@ jobs:
|
|||||||
- name: Lint OpenAPI (Redocly)
|
- name: Lint OpenAPI (Redocly)
|
||||||
run: npx --yes @redocly/cli@1 lint docs/openapi.yaml
|
run: npx --yes @redocly/cli@1 lint docs/openapi.yaml
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
web:
|
||||||
|
needs: [changes]
|
||||||
|
if: needs.changes.outputs.web == 'true'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "20"
|
||||||
|
cache: npm
|
||||||
|
cache-dependency-path: web/package-lock.json
|
||||||
|
- name: npm ci, check, lint
|
||||||
|
run: |
|
||||||
|
set -euxo pipefail
|
||||||
|
cd web
|
||||||
|
npm ci
|
||||||
|
npm run check
|
||||||
|
npm run lint
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
go:
|
go:
|
||||||
needs: [changes]
|
needs: [changes]
|
||||||
@@ -135,6 +155,8 @@ jobs:
|
|||||||
cache-dependency-path: go.sum
|
cache-dependency-path: go.sum
|
||||||
- name: Vet
|
- name: Vet
|
||||||
run: go vet ./...
|
run: go vet ./...
|
||||||
|
- name: Lint httpapi (ERR-01 / ARCH-01)
|
||||||
|
run: sh scripts/lint-httpapi.sh
|
||||||
- name: Test
|
- name: Test
|
||||||
run: go test ./... -race -count=1
|
run: go test ./... -race -count=1
|
||||||
- name: Build all commands
|
- name: Build all commands
|
||||||
@@ -186,138 +208,20 @@ jobs:
|
|||||||
done
|
done
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Docker: все Go-образы одним buildx bake (один go mod download, одна компиляция cmd/*,
|
commitlint:
|
||||||
# birdc собирается один раз для api/all). Registry cache: evobgp-buildcache:go-buildcache.
|
if: github.event_name == 'pull_request'
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
docker-go:
|
|
||||||
needs: [changes, go]
|
|
||||||
if: >-
|
|
||||||
always() &&
|
|
||||||
needs.changes.result == 'success' &&
|
|
||||||
needs.go.result != 'failure' &&
|
|
||||||
github.event_name == 'push' &&
|
|
||||||
(github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master') &&
|
|
||||||
(needs.changes.outputs.go == 'true' || needs.changes.outputs.docker_go == 'true')
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Set up Docker Buildx
|
|
||||||
uses: docker/setup-buildx-action@v3
|
|
||||||
- name: Prepare image metadata
|
|
||||||
id: meta
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
owner_lc="$(echo '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]')"
|
|
||||||
echo "owner_lc=$owner_lc" >> "$GITHUB_OUTPUT"
|
|
||||||
short_sha="$(echo '${{ github.sha }}' | cut -c1-7)"
|
|
||||||
echo "short_sha=$short_sha" >> "$GITHUB_OUTPUT"
|
|
||||||
- name: Log in to Gitea Registry
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
with:
|
||||||
registry: git.shts.su
|
fetch-depth: 0
|
||||||
username: ${{ gitea.actor }}
|
- uses: actions/setup-node@v4
|
||||||
password: ${{ secrets.ACTIONS_PAT || gitea.token }}
|
with:
|
||||||
- name: Build and push Go images (bake)
|
node-version: "22"
|
||||||
env:
|
cache: npm
|
||||||
REGISTRY: git.shts.su/${{ steps.meta.outputs.owner_lc }}
|
cache-dependency-path: package-lock.json
|
||||||
IMAGE_TAG: latest
|
- name: Lint commit messages
|
||||||
SHORT_SHA: ${{ steps.meta.outputs.short_sha }}
|
|
||||||
SHA_FULL: ${{ github.sha }}
|
|
||||||
CACHE_REF_GO: git.shts.su/${{ steps.meta.outputs.owner_lc }}/evobgp-buildcache:go-buildcache
|
|
||||||
CACHE_REF_WEB: ""
|
|
||||||
BUILDX_BAKE_ENTITLEMENTS_FS: "0"
|
|
||||||
run: |
|
run: |
|
||||||
set -euxo pipefail
|
set -euxo pipefail
|
||||||
cd "${{ github.workspace }}/deploy/docker"
|
npm ci
|
||||||
sh write-bake-override.sh
|
npx commitlint --from "${{ github.event.pull_request.base.sha }}" --to "${{ github.event.pull_request.head.sha }}"
|
||||||
docker buildx bake --allow=fs.read="${{ github.workspace }}" \
|
|
||||||
-f docker-bake.hcl -f docker-bake.override.hcl go-images --push
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Docker: Web (evobgp-web, evobgp-web-all) — один npm ci (кэш) + два nginx-тега.
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
docker-web:
|
|
||||||
needs: [changes]
|
|
||||||
if: >-
|
|
||||||
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')
|
|
||||||
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 Web 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: ""
|
|
||||||
CACHE_REF_WEB: git.shts.su/${{ steps.meta.outputs.owner_lc }}/evobgp-buildcache:web-buildcache
|
|
||||||
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 web-images --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
|
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
name: Publish
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags: ["v*"]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
publish-images:
|
||||||
|
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
|
||||||
|
ref="${GITHUB_REF_NAME:-}"
|
||||||
|
version="${ref#v}"
|
||||||
|
echo "version=$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
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: git.shts.su
|
||||||
|
username: ${{ gitea.actor }}
|
||||||
|
password: ${{ secrets.ACTIONS_PAT || gitea.token }}
|
||||||
|
- name: Build and push images (bake)
|
||||||
|
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 }}
|
||||||
|
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: |
|
||||||
|
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 default --push
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
name: Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_run:
|
||||||
|
workflows: [CI]
|
||||||
|
types: [completed]
|
||||||
|
branches: [main, master]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
release:
|
||||||
|
if: >-
|
||||||
|
github.event.workflow_run.conclusion == 'success' &&
|
||||||
|
github.event.workflow_run.event == 'push'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
ref: ${{ github.event.workflow_run.head_sha }}
|
||||||
|
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 }}
|
||||||
+14
@@ -0,0 +1,14 @@
|
|||||||
|
# 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
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Env / secrets (local only)
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
run:
|
||||||
|
timeout: 5m
|
||||||
|
|
||||||
|
linters:
|
||||||
|
enable:
|
||||||
|
- gofmt
|
||||||
|
- govet
|
||||||
|
- errcheck
|
||||||
|
|
||||||
|
issues:
|
||||||
|
exclude-use-default: false
|
||||||
|
max-issues-per-linter: 50
|
||||||
|
max-same-issues: 20
|
||||||
|
|
||||||
|
linters-settings:
|
||||||
|
gofmt:
|
||||||
|
simplify: true
|
||||||
@@ -0,0 +1,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" },
|
||||||
|
{ "breaking": true, "release": "major" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"@semantic-release/release-notes-generator",
|
||||||
|
{
|
||||||
|
"preset": "conventionalcommits"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"@semantic-release/changelog",
|
||||||
|
{
|
||||||
|
"changelogFile": "CHANGELOG.md"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"@semantic-release/git",
|
||||||
|
{
|
||||||
|
"assets": ["CHANGELOG.md"],
|
||||||
|
"message": "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"@markwylde/semantic-release-gitea",
|
||||||
|
{
|
||||||
|
"giteaUrl": "https://git.shts.su",
|
||||||
|
"assets": [
|
||||||
|
{
|
||||||
|
"path": "CHANGELOG.md",
|
||||||
|
"label": "Changelog"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -36,6 +36,15 @@
|
|||||||
- **Повторное использование:** если [docs/architecture.md](docs/architecture.md) уже описывает поток — не пересказывайте его длинно; укажите документ и конкретный подпункт задачи.
|
- **Повторное использование:** если [docs/architecture.md](docs/architecture.md) уже описывает поток — не пересказывайте его длинно; укажите документ и конкретный подпункт задачи.
|
||||||
- **Длинные планы:** `.cursor/plans/*.plan.md` — для истории решений; для навигации пользователю достаточно `docs/`; не читайте план целиком без причины.
|
- **Длинные планы:** `.cursor/plans/*.plan.md` — для истории решений; для навигации пользователю достаточно `docs/`; не читайте план целиком без причины.
|
||||||
|
|
||||||
|
## Коммиты (Conventional Commits)
|
||||||
|
|
||||||
|
Если пользователь просит **коммит**, **commit message**, **закоммить**, **git commit** или это следует из плана — **сразу**:
|
||||||
|
|
||||||
|
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 (скилл).
|
||||||
|
|
||||||
## Команды и среда
|
## Команды и среда
|
||||||
|
|
||||||
- Консоль пользователя: **PowerShell**; пути в стиле `deploy\compose`.
|
- Консоль пользователя: **PowerShell**; пути в стиле `deploy\compose`.
|
||||||
|
|||||||
+150
@@ -0,0 +1,150 @@
|
|||||||
|
## 1.0.0 (2026-05-19)
|
||||||
|
|
||||||
|
* feat: add @tanstack/table-core dependency and integrate module name mapping in operations page. Upda ([b140dcb](http://192.168.100.56:3000/denozord/EvoBGP/commit/b140dcb))
|
||||||
|
* feat: add @types/node and undici-types to devDependencies in package.json and package-lock.json for ([d92a06c](http://192.168.100.56:3000/denozord/EvoBGP/commit/d92a06c))
|
||||||
|
* feat: add aggregated router-lists catalog endpoint and update module listing filters ([c811c43](http://192.168.100.56:3000/denozord/EvoBGP/commit/c811c43))
|
||||||
|
* feat: add auto-updater service for Docker stack with configurable settings ([ae9fbe3](http://192.168.100.56:3000/denozord/EvoBGP/commit/ae9fbe3))
|
||||||
|
* feat: add bird configuration preview functionality with tabbed interface for fragments and prefixes. ([1969a00](http://192.168.100.56:3000/denozord/EvoBGP/commit/1969a00))
|
||||||
|
* feat: add BIRD status endpoint and integrate birdc checks into job processing. Enhance UI to display ([94a4c6a](http://192.168.100.56:3000/denozord/EvoBGP/commit/94a4c6a))
|
||||||
|
* feat: add CSV import and export functionality for module entries. Implement endpoints for exporting ([bae2d68](http://192.168.100.56:3000/denozord/EvoBGP/commit/bae2d68))
|
||||||
|
* feat: add debug logging and optimize database queries in Postgres repository ([6d0e214](http://192.168.100.56:3000/denozord/EvoBGP/commit/6d0e214))
|
||||||
|
* feat: add diagnostic log endpoint for revisions and implement download functionality ([2260ecf](http://192.168.100.56:3000/denozord/EvoBGP/commit/2260ecf))
|
||||||
|
* feat: add enabled state to BGP peer types and UI. Implement peer enabling toggle in the network page ([80965ad](http://192.168.100.56:3000/denozord/EvoBGP/commit/80965ad))
|
||||||
|
* feat: add last refreshed timestamp to modules and CDN sources ([1e13ef9](http://192.168.100.56:3000/denozord/EvoBGP/commit/1e13ef9))
|
||||||
|
* feat: add live BGP protocol state tracking to peer listing ([8a50ba4](http://192.168.100.56:3000/denozord/EvoBGP/commit/8a50ba4))
|
||||||
|
* feat: add optional name field to BGP peer creation and update UI ([1a61ae2](http://192.168.100.56:3000/denozord/EvoBGP/commit/1a61ae2))
|
||||||
|
* feat: add optional name field to DoH profile schema and types ([6bee5ad](http://192.168.100.56:3000/denozord/EvoBGP/commit/6bee5ad))
|
||||||
|
* feat: add smart aggregation for IPv4 CIDR prefixes in refresh pipeline. Implement functionality to a ([0428b83](http://192.168.100.56:3000/denozord/EvoBGP/commit/0428b83))
|
||||||
|
* feat: add strict bind configuration to BGP templates. Enhance BGP template rendering by including 's ([07f2ce5](http://192.168.100.56:3000/denozord/EvoBGP/commit/07f2ce5))
|
||||||
|
* feat: add tenant refresh functionality to HTTP API and job processing ([7a2b015](http://192.168.100.56:3000/denozord/EvoBGP/commit/7a2b015))
|
||||||
|
* feat: auto-refresh non-IP module updates and add full project manual ([1e77e1a](http://192.168.100.56:3000/denozord/EvoBGP/commit/1e77e1a))
|
||||||
|
* feat: enhance API client with pagination and new data fetching functions. Implement `apiPageAll` for ([9f0b128](http://192.168.100.56:3000/denozord/EvoBGP/commit/9f0b128))
|
||||||
|
* feat: enhance BGP template and peer configuration handling. Introduce policies_json parameter for lo ([be8e7b5](http://192.168.100.56:3000/denozord/EvoBGP/commit/be8e7b5))
|
||||||
|
* feat: enhance CDN source management with last refreshed timestamp ([4776434](http://192.168.100.56:3000/denozord/EvoBGP/commit/4776434))
|
||||||
|
* feat: enhance community attribute handling in BIRD format. Add validation for community route bodies ([b541794](http://192.168.100.56:3000/denozord/EvoBGP/commit/b541794))
|
||||||
|
* feat: enhance community label handling and improve UI localization across operations pages. Introduc ([53fd527](http://192.168.100.56:3000/denozord/EvoBGP/commit/53fd527))
|
||||||
|
* feat: enhance deployment configuration for secure Web UI with Traefik integration ([4acd685](http://192.168.100.56:3000/denozord/EvoBGP/commit/4acd685))
|
||||||
|
* feat: enhance DoH profile management and resolver policy in modules ([34ecc5c](http://192.168.100.56:3000/denozord/EvoBGP/commit/34ecc5c))
|
||||||
|
* feat: enhance EvoBGP with new command-line options for the evobgp-agent, including a watch command f ([6a55f72](http://192.168.100.56:3000/denozord/EvoBGP/commit/6a55f72))
|
||||||
|
* feat: enhance evobgp with new command-line tools for bundle management, including pull, verify, and ([bf52b21](http://192.168.100.56:3000/denozord/EvoBGP/commit/bf52b21))
|
||||||
|
* feat: enhance job detail handling in UI with caching and loading states. Introduce SvelteMap for job ([05bc5c4](http://192.168.100.56:3000/denozord/EvoBGP/commit/05bc5c4))
|
||||||
|
* feat: enhance job detail UI with improved overflow handling and layout adjustments. Update card comp ([67ce135](http://192.168.100.56:3000/denozord/EvoBGP/commit/67ce135))
|
||||||
|
* feat: enhance job filtering and display functionality in operations page. Introduce job search and f ([e1c4cbc](http://192.168.100.56:3000/denozord/EvoBGP/commit/e1c4cbc))
|
||||||
|
* feat: enhance job processing with detailed logging and UI improvements. Introduce revision log entri ([c48cc4e](http://192.168.100.56:3000/denozord/EvoBGP/commit/c48cc4e))
|
||||||
|
* feat: enhance monitoring page with detailed status tracking and error handling ([458a885](http://192.168.100.56:3000/denozord/EvoBGP/commit/458a885))
|
||||||
|
* feat: enhance observability with prefix aggregation metrics ([07b97bd](http://192.168.100.56:3000/denozord/EvoBGP/commit/07b97bd))
|
||||||
|
* feat: enhance peer neighbor handling by introducing NormalizePeerNeighborString function. Update Cre ([80f3a8d](http://192.168.100.56:3000/denozord/EvoBGP/commit/80f3a8d))
|
||||||
|
* feat: enhance RefreshModule to aggregate prefixes from all enabled tenant modules. Update documentat ([431c6a9](http://192.168.100.56:3000/denozord/EvoBGP/commit/431c6a9))
|
||||||
|
* feat: enhance RefreshModule to support legacy ASN resolution and improve prefix handling. Introduce ([e1aaa18](http://192.168.100.56:3000/denozord/EvoBGP/commit/e1aaa18))
|
||||||
|
* feat: enhance settings page layout and section management ([d91667f](http://192.168.100.56:3000/denozord/EvoBGP/commit/d91667f))
|
||||||
|
* feat: enhance UI components with improved styling and layout adjustments. Update button sizes, text ([6a72842](http://192.168.100.56:3000/denozord/EvoBGP/commit/6a72842))
|
||||||
|
* feat: enhance worker functionality by adding job queuing for deploy_apply. Introduce a Registry fiel ([42d956d](http://192.168.100.56:3000/denozord/EvoBGP/commit/42d956d))
|
||||||
|
* feat: implement AS holder name resolution and enhance ASEntry structure. Add ASHolderName function t ([c958d5a](http://192.168.100.56:3000/denozord/EvoBGP/commit/c958d5a))
|
||||||
|
* feat: implement bulk selection and deletion for modules and entries. Add functionality for selecting ([e816d17](http://192.168.100.56:3000/denozord/EvoBGP/commit/e816d17))
|
||||||
|
* feat: implement CDN source preview functionality and enhance data handling. Add a new endpoint for p ([e20c9f3](http://192.168.100.56:3000/denozord/EvoBGP/commit/e20c9f3))
|
||||||
|
* feat: implement countAll function for API data retrieval ([40c8a8d](http://192.168.100.56:3000/denozord/EvoBGP/commit/40c8a8d))
|
||||||
|
* feat: implement domain resolution via DoH in refresh pipeline. Enhance the collectModulePrefixRows f ([678bd27](http://192.168.100.56:3000/denozord/EvoBGP/commit/678bd27))
|
||||||
|
* feat: implement effective peer enabling logic during peer creation. Update CreatePeer methods in Pos ([3e85431](http://192.168.100.56:3000/denozord/EvoBGP/commit/3e85431))
|
||||||
|
* feat: implement module refresh job management with concurrency control. Add CountOtherActiveModuleRe ([149fb0a](http://192.168.100.56:3000/denozord/EvoBGP/commit/149fb0a))
|
||||||
|
* feat: implement peer reconciliation job for BGP peers ([3b17228](http://192.168.100.56:3000/denozord/EvoBGP/commit/3b17228))
|
||||||
|
* feat: implement revision retention settings and pruning logic ([4f50350](http://192.168.100.56:3000/denozord/EvoBGP/commit/4f50350))
|
||||||
|
* feat: implement theme selection and automatic theme application based on user preference. Update App ([b70a643](http://192.168.100.56:3000/denozord/EvoBGP/commit/b70a643))
|
||||||
|
* feat: implement versioning and release management in the project ([bb334b1](http://192.168.100.56:3000/denozord/EvoBGP/commit/bb334b1))
|
||||||
|
* feat: improve BGP protocol summary handling by introducing isBGPProtocolSummaryRow function. Update ([399622c](http://192.168.100.56:3000/denozord/EvoBGP/commit/399622c))
|
||||||
|
* feat: improve job detail UI with enhanced spacing and layout adjustments. Update table cell classes ([97908b5](http://192.168.100.56:3000/denozord/EvoBGP/commit/97908b5))
|
||||||
|
* feat: improve peer speaker assignment UX in network settings ([f877ec8](http://192.168.100.56:3000/denozord/EvoBGP/commit/f877ec8))
|
||||||
|
* feat: improve UI components with enhanced overflow handling and layout adjustments. Update card and ([0ca5542](http://192.168.100.56:3000/denozord/EvoBGP/commit/0ca5542))
|
||||||
|
* feat: improve UI layout and responsiveness in module and operations pages. Refactor component struct ([233f29b](http://192.168.100.56:3000/denozord/EvoBGP/commit/233f29b))
|
||||||
|
* feat: redesign settings page with structured controls ([2da6d49](http://192.168.100.56:3000/denozord/EvoBGP/commit/2da6d49))
|
||||||
|
* feat: refactor materialization logic to group static routes by community in the refresh pipeline. In ([1c5a9c9](http://192.168.100.56:3000/denozord/EvoBGP/commit/1c5a9c9))
|
||||||
|
* feat: refactor operations page to enhance UI and component structure. Remove unused card components ([88226f6](http://192.168.100.56:3000/denozord/EvoBGP/commit/88226f6))
|
||||||
|
* feat: refine BGP template handling and documentation updates. Remove local IP requirements in BGP te ([6266a39](http://192.168.100.56:3000/denozord/EvoBGP/commit/6266a39))
|
||||||
|
* feat: refine job detail UI with improved layout and scrolling capabilities. Enhance spacing and over ([57c09ac](http://192.168.100.56:3000/denozord/EvoBGP/commit/57c09ac))
|
||||||
|
* feat: update BGP community structure and related documentation. Refactor BGP community handling by r ([a2cbfac](http://192.168.100.56:3000/denozord/EvoBGP/commit/a2cbfac))
|
||||||
|
* feat: update BGP template configuration and OpenAPI documentation. Change import settings from 'all' ([133ea93](http://192.168.100.56:3000/denozord/EvoBGP/commit/133ea93))
|
||||||
|
* feat: update BIRD installation process to build from source. Modify CI workflow to ensure BIRD 2.14 ([e2002ba](http://192.168.100.56:3000/denozord/EvoBGP/commit/e2002ba))
|
||||||
|
* feat: update CI configuration and Dockerfiles for improved build efficiency ([80229b3](http://192.168.100.56:3000/denozord/EvoBGP/commit/80229b3))
|
||||||
|
* feat: update collectModulePrefixRows to support prior snapshots and enhance CDN/domain prefix collec ([ee364c8](http://192.168.100.56:3000/denozord/EvoBGP/commit/ee364c8))
|
||||||
|
* feat: update Go module dependencies and enhance DNS resolution in refresh pipeline. Upgrade Go versi ([655d750](http://192.168.100.56:3000/denozord/EvoBGP/commit/655d750))
|
||||||
|
* feat: update OpenAPI documentation and internal logic for BIRD settings. Enhance the description of ([62bf987](http://192.168.100.56:3000/denozord/EvoBGP/commit/62bf987))
|
||||||
|
* feat: update RevisionDiff type and enhance OperationsDiffTab component for better diff handling. Int ([4aaaa30](http://192.168.100.56:3000/denozord/EvoBGP/commit/4aaaa30))
|
||||||
|
* feat: update Traefik deployment configuration for Let's Encrypt and logging ([39ba976](http://192.168.100.56:3000/denozord/EvoBGP/commit/39ba976))
|
||||||
|
* docs: add router-lists-ui integration documentation to README ([b7a8aab](http://192.168.100.56:3000/denozord/EvoBGP/commit/b7a8aab))
|
||||||
|
* docs: add Web UI section to EvoBGP architecture plan, detailing Svelte and shadcn-svelte integration ([272542b](http://192.168.100.56:3000/denozord/EvoBGP/commit/272542b))
|
||||||
|
* docs: enhance EvoBGP architecture plan with new risk mitigation strategies, testing matrix for BIRD2 ([3992d01](http://192.168.100.56:3000/denozord/EvoBGP/commit/3992d01))
|
||||||
|
* docs: enhance README and CI workflow documentation for Docker image building and publishing process, ([9eb3e33](http://192.168.100.56:3000/denozord/EvoBGP/commit/9eb3e33))
|
||||||
|
* docs: enhance README and CI workflow to clarify Docker image build conditions and Gitea Container Re ([c6687b2](http://192.168.100.56:3000/denozord/EvoBGP/commit/c6687b2))
|
||||||
|
* docs: openapi.html со встроенным Redoc; инструкция для Gitea и скрипт сборки ([a754e1d](http://192.168.100.56:3000/denozord/EvoBGP/commit/a754e1d))
|
||||||
|
* docs: update AGENTS and README to include guidelines for Conventional Commits ([4c23232](http://192.168.100.56:3000/denozord/EvoBGP/commit/4c23232))
|
||||||
|
* docs: update AGENTS and README with engineering rules and guidelines ([aff27e8](http://192.168.100.56:3000/denozord/EvoBGP/commit/aff27e8))
|
||||||
|
* docs: update architecture and API documentation to clarify job processing, in-memory registry usage, ([acc0608](http://192.168.100.56:3000/denozord/EvoBGP/commit/acc0608))
|
||||||
|
* docs: update EvoBGP architecture plan with detailed REST API section, including general agreements, ([1a86d6c](http://192.168.100.56:3000/denozord/EvoBGP/commit/1a86d6c))
|
||||||
|
* docs: update EvoBGP architecture plan with new repository structure, refined sections, and detailed ([4480d64](http://192.168.100.56:3000/denozord/EvoBGP/commit/4480d64))
|
||||||
|
* docs: update README and CI workflow to clarify Docker workspace mounting for bird2 job ([6be46db](http://192.168.100.56:3000/denozord/EvoBGP/commit/6be46db))
|
||||||
|
* docs: update README and CI workflow to clarify Gitea Registry login process and improve Docker image ([f1b33b9](http://192.168.100.56:3000/denozord/EvoBGP/commit/f1b33b9))
|
||||||
|
* docs: update README and Docker Compose configurations to clarify usage of pre-built images from the ([7fac79c](http://192.168.100.56:3000/denozord/EvoBGP/commit/7fac79c))
|
||||||
|
* docs: update README to include information about the EvoBGP web interface, linking to quickstart and ([5d21f01](http://192.168.100.56:3000/denozord/EvoBGP/commit/5d21f01))
|
||||||
|
* docs: статическая Redoc-страница OpenAPI (для просмотра в Gitea без встроенного рендера) ([5c9d2e2](http://192.168.100.56:3000/denozord/EvoBGP/commit/5c9d2e2))
|
||||||
|
* docs: убрать типографское тире и невидимый Unicode из текстов API ([fbe9cd3](http://192.168.100.56:3000/denozord/EvoBGP/commit/fbe9cd3))
|
||||||
|
* refactor: adjust BootstrapWorkers function call in server.go to change the order of returned values, ([032fa99](http://192.168.100.56:3000/denozord/EvoBGP/commit/032fa99))
|
||||||
|
* refactor: consolidate module interval display logic and improve edit form handling ([1f85654](http://192.168.100.56:3000/denozord/EvoBGP/commit/1f85654))
|
||||||
|
* refactor: enhance bird metrics polling with context support ([f6b94a4](http://192.168.100.56:3000/denozord/EvoBGP/commit/f6b94a4))
|
||||||
|
* refactor: enhance CI workflow for granular change detection by module and improve idempotency key ge ([54eaad3](http://192.168.100.56:3000/denozord/EvoBGP/commit/54eaad3))
|
||||||
|
* refactor: enhance job status checks in worker tests ([6107c3e](http://192.168.100.56:3000/denozord/EvoBGP/commit/6107c3e))
|
||||||
|
* refactor: enhance JSON struct tags for various data models in backend and memory store. Update field ([4a860a0](http://192.168.100.56:3000/denozord/EvoBGP/commit/4a860a0))
|
||||||
|
* refactor: enhance module display and input handling in module pages ([25374a6](http://192.168.100.56:3000/denozord/EvoBGP/commit/25374a6))
|
||||||
|
* refactor: enhance module interval label logic and add reset buttons in edit forms ([b99fc17](http://192.168.100.56:3000/denozord/EvoBGP/commit/b99fc17))
|
||||||
|
* refactor: improve code consistency and formatting across components ([c263fd5](http://192.168.100.56:3000/denozord/EvoBGP/commit/c263fd5))
|
||||||
|
* refactor: improve handling of deployable fragments in ApplyRevision and buildPreviewFragments functi ([6932271](http://192.168.100.56:3000/denozord/EvoBGP/commit/6932271))
|
||||||
|
* refactor: improve module save functionality and nullable value handling ([05ec176](http://192.168.100.56:3000/denozord/EvoBGP/commit/05ec176))
|
||||||
|
* refactor: improve PATCH handling for module updates with nullable field support ([e700f90](http://192.168.100.56:3000/denozord/EvoBGP/commit/e700f90))
|
||||||
|
* refactor: optimize job listing and counting logic for concurrency safety. Separate tenant filtering ([d83efe2](http://192.168.100.56:3000/denozord/EvoBGP/commit/d83efe2))
|
||||||
|
* refactor: phase 2 structural alignment — reports, importer, nodecli, pagination ([250cdea](http://192.168.100.56:3000/denozord/EvoBGP/commit/250cdea))
|
||||||
|
* refactor: remove strict bind configuration from BGP templates and update related logic. Eliminate 's ([482eeb1](http://192.168.100.56:3000/denozord/EvoBGP/commit/482eeb1))
|
||||||
|
* refactor: streamline Docker build process in CI configuration and documentation ([6025f38](http://192.168.100.56:3000/denozord/EvoBGP/commit/6025f38))
|
||||||
|
* refactor: streamline edit form handling for community and DoH profile selections ([1765ab8](http://192.168.100.56:3000/denozord/EvoBGP/commit/1765ab8))
|
||||||
|
* refactor: streamline module refresh process and enhance revision handling ([10e2415](http://192.168.100.56:3000/denozord/EvoBGP/commit/10e2415))
|
||||||
|
* refactor: switch BIRD installation from source to Ubuntu package. Update CI workflow and Dockerfiles ([0b3ceaa](http://192.168.100.56:3000/denozord/EvoBGP/commit/0b3ceaa))
|
||||||
|
* refactor: update AS entry handling in API and database to support ASN-only entries. Remove prefix fi ([1db6b5b](http://192.168.100.56:3000/denozord/EvoBGP/commit/1db6b5b))
|
||||||
|
* refactor: update Docker build context and paths in CI configuration and documentation ([7d52a4a](http://192.168.100.56:3000/denozord/EvoBGP/commit/7d52a4a))
|
||||||
|
* refactor: update Docker build variable syntax in CI configuration and documentation ([27e01d0](http://192.168.100.56:3000/denozord/EvoBGP/commit/27e01d0))
|
||||||
|
* refactor: update job kind display logic in schedule page ([dc1f4a5](http://192.168.100.56:3000/denozord/EvoBGP/commit/dc1f4a5))
|
||||||
|
* refactor: update ListModules and GetModule methods in Postgres repository to include cron expression ([73d0003](http://192.168.100.56:3000/denozord/EvoBGP/commit/73d0003))
|
||||||
|
* refactor: update Switch component styles and structure for improved accessibility and responsiveness ([d4282c2](http://192.168.100.56:3000/denozord/EvoBGP/commit/d4282c2))
|
||||||
|
* refactor: update worker processes in EvoBGP to accept dependencies for shared store and job registry ([b7968db](http://192.168.100.56:3000/denozord/EvoBGP/commit/b7968db))
|
||||||
|
* ci: add conditional job for OpenAPI linting based on file changes; update README with workflow detai ([3adfc49](http://192.168.100.56:3000/denozord/EvoBGP/commit/3adfc49))
|
||||||
|
* ci: phase 4 automation — web job, lint-httpapi, golangci ([64516c3](http://192.168.100.56:3000/denozord/EvoBGP/commit/64516c3))
|
||||||
|
* ci: update README and CI workflow to improve bird2 job installation process and clarify Docker usage ([eddc6b8](http://192.168.100.56:3000/denozord/EvoBGP/commit/eddc6b8))
|
||||||
|
* chore: phase 3 code quality — pipeline prefixes, godoc baseline ([ea65451](http://192.168.100.56:3000/denozord/EvoBGP/commit/ea65451))
|
||||||
|
* chore: update CI configuration to disable filesystem entitlement for Docker builds ([9ab7de6](http://192.168.100.56:3000/denozord/EvoBGP/commit/9ab7de6))
|
||||||
|
* chore: update package dependencies and enhance AppShell component with new navigation items and tool ([7d85fef](http://192.168.100.56:3000/denozord/EvoBGP/commit/7d85fef))
|
||||||
|
* chore: upgrade Go version to 1.24 in Dockerfiles for evobgp-agent and gobinary. This change ensures ([4ba5b03](http://192.168.100.56:3000/denozord/EvoBGP/commit/4ba5b03))
|
||||||
|
* fix: adjust Postgres resource limits and healthcheck parameters. Increase memory limit to 256M and m ([20e674c](http://192.168.100.56:3000/denozord/EvoBGP/commit/20e674c))
|
||||||
|
* fix: improve input handling and validation for settings fields ([1386b63](http://192.168.100.56:3000/denozord/EvoBGP/commit/1386b63))
|
||||||
|
* fix: improve sidebar and navigation layout in AppShell component ([9f02128](http://192.168.100.56:3000/denozord/EvoBGP/commit/9f02128))
|
||||||
|
* fix: update BGP configuration in test scenarios to use new IPv6 addresses ([9fd9745](http://192.168.100.56:3000/denozord/EvoBGP/commit/9fd9745))
|
||||||
|
* fix: update bird build script and Dockerfile to include libncurses dependency ([0cb5a7d](http://192.168.100.56:3000/denozord/EvoBGP/commit/0cb5a7d))
|
||||||
|
* fix: update bird build script and Dockerfile to include readline dependency ([35ed79b](http://192.168.100.56:3000/denozord/EvoBGP/commit/35ed79b))
|
||||||
|
* fix: update bird build script and Dockerfile to remove readline dependency ([fefadac](http://192.168.100.56:3000/denozord/EvoBGP/commit/fefadac))
|
||||||
|
* fix: update deployment configuration for Traefik and secure Web UI ([00bd037](http://192.168.100.56:3000/denozord/EvoBGP/commit/00bd037))
|
||||||
|
* fix: update Docker build commands in CI configuration and documentation ([b5061a5](http://192.168.100.56:3000/denozord/EvoBGP/commit/b5061a5))
|
||||||
|
* fix(httpapi): phase 1 compliance — ERR-01, SEC-04, CDN client, scheduler docs ([1dd7135](http://192.168.100.56:3000/denozord/EvoBGP/commit/1dd7135))
|
||||||
|
* test: add CDN prefetch tests for 304 responses and snapshot handling ([a536a2d](http://192.168.100.56:3000/denozord/EvoBGP/commit/a536a2d))
|
||||||
|
* test: add validation for 'strict bind on' in BGP rendering tests. Enhance BGP test cases to ensure ' ([dc5e20e](http://192.168.100.56:3000/denozord/EvoBGP/commit/dc5e20e))
|
||||||
|
* docker: refactor cache handling in Docker bake configuration ([5e59a1c](http://192.168.100.56:3000/denozord/EvoBGP/commit/5e59a1c))
|
||||||
|
* docker: simplify web image targets in Docker bake configuration ([765a805](http://192.168.100.56:3000/denozord/EvoBGP/commit/765a805))
|
||||||
|
* docker: update web image build process and README ([92f6de8](http://192.168.100.56:3000/denozord/EvoBGP/commit/92f6de8))
|
||||||
|
* web(ui): app layout — sidebar, mobile sheet, topbar, PageHeader ([2d78c81](http://192.168.100.56:3000/denozord/EvoBGP/commit/2d78c81))
|
||||||
|
* web(ui): AppDataTable pattern and modules pilot ([b992301](http://192.168.100.56:3000/denozord/EvoBGP/commit/b992301))
|
||||||
|
* web(ui): design tokens — spacing and component scale ([ac635e9](http://192.168.100.56:3000/denozord/EvoBGP/commit/ac635e9))
|
||||||
|
* web(ui): feedback — confirm dialog, skeletons, README ([87309cf](http://192.168.100.56:3000/denozord/EvoBGP/commit/87309cf))
|
||||||
|
* web(ui): form patterns — FormField, formsnap, directories pilot ([77a2cfd](http://192.168.100.56:3000/denozord/EvoBGP/commit/77a2cfd))
|
||||||
|
* web(ui): foundation — ui/core layout, shadcn primitives, notify ([00a2a44](http://192.168.100.56:3000/denozord/EvoBGP/commit/00a2a44))
|
||||||
|
* web(ui): migrate dashboard, schedule, monitoring to ui patterns ([57dcf30](http://192.168.100.56:3000/denozord/EvoBGP/commit/57dcf30))
|
||||||
|
* web(ui): migrate network to ui patterns ([3a3056b](http://192.168.100.56:3000/denozord/EvoBGP/commit/3a3056b))
|
||||||
|
* web(ui): migrate operations and module detail to ui patterns ([1f9930d](http://192.168.100.56:3000/denozord/EvoBGP/commit/1f9930d))
|
||||||
|
* web(ui): migrate settings to ui patterns ([9808aca](http://192.168.100.56:3000/denozord/EvoBGP/commit/9808aca))
|
||||||
|
* Enhance EvoBGP architecture plan by refining the overview, updating todos with detailed descriptions ([30efdbf](http://192.168.100.56:3000/denozord/EvoBGP/commit/30efdbf))
|
||||||
|
* init ([cbf3aaa](http://192.168.100.56:3000/denozord/EvoBGP/commit/cbf3aaa))
|
||||||
|
* Refine EvoBGP architecture plan with updated database schema details, improved descriptions for comp ([431ef3e](http://192.168.100.56:3000/denozord/EvoBGP/commit/431ef3e))
|
||||||
|
* Update EvoBGP architecture plan with new overview and todos. Refined descriptions for control-plane ([31ff5bb](http://192.168.100.56:3000/denozord/EvoBGP/commit/31ff5bb))
|
||||||
@@ -44,4 +44,12 @@ docker compose --profile reference up -d
|
|||||||
- **Go:** модуль `evobgp`, точки входа в `cmd/`.
|
- **Go:** модуль `evobgp`, точки входа в `cmd/`.
|
||||||
- **Веб:** SvelteKit в каталоге `web/` (см. [web/README.md](web/README.md)).
|
- **Веб:** SvelteKit в каталоге `web/` (см. [web/README.md](web/README.md)).
|
||||||
|
|
||||||
|
## Версионирование и релизы
|
||||||
|
|
||||||
|
Версии определяются автоматически из Conventional Commits при merge в `main` (semantic-release на Gitea Actions). Первая версия — **1.0.0**; ручной bump не нужен.
|
||||||
|
|
||||||
|
- [docs/releasing.md](docs/releasing.md) — пайплайн, commit conventions, секреты CI
|
||||||
|
- API: `GET /version` и `GET /v1/version` (поле `version`)
|
||||||
|
- Docker-образы публикуются по git-тегу `vX.Y.Z` (workflow **Publish**)
|
||||||
|
|
||||||
Лицензия и условия использования — по политике владельца репозитория.
|
Лицензия и условия использования — по политике владельца репозитория.
|
||||||
@@ -19,6 +19,7 @@ import (
|
|||||||
"evobgp/internal/platform"
|
"evobgp/internal/platform"
|
||||||
"evobgp/internal/render"
|
"evobgp/internal/render"
|
||||||
"evobgp/internal/scheduler"
|
"evobgp/internal/scheduler"
|
||||||
|
"evobgp/internal/version"
|
||||||
)
|
)
|
||||||
|
|
||||||
// microVPS entrypoint: один процесс — HTTP API и фоновые воркеры scheduler, ingest, render, deploy (общий store и jobs.Registry).
|
// microVPS entrypoint: один процесс — HTTP API и фоновые воркеры scheduler, ingest, render, deploy (общий store и jobs.Registry).
|
||||||
@@ -37,7 +38,7 @@ func main() {
|
|||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
observability.SetBuildInfo("0.1.0", strings.TrimSpace(os.Getenv("EVOBGP_GIT_SHA")))
|
observability.SetBuildInfo(version.Version, version.GitSHA)
|
||||||
|
|
||||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||||
defer stop()
|
defer stop()
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
"evobgp/internal/httpapi"
|
"evobgp/internal/httpapi"
|
||||||
"evobgp/internal/observability"
|
"evobgp/internal/observability"
|
||||||
"evobgp/internal/platform"
|
"evobgp/internal/platform"
|
||||||
|
"evobgp/internal/version"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -34,7 +35,7 @@ func main() {
|
|||||||
}
|
}
|
||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
|
|
||||||
observability.SetBuildInfo("0.1.0", strings.TrimSpace(os.Getenv("EVOBGP_GIT_SHA")))
|
observability.SetBuildInfo(version.Version, version.GitSHA)
|
||||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||||
defer stop()
|
defer stop()
|
||||||
|
|
||||||
|
|||||||
+3
-228
@@ -1,240 +1,15 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"crypto/ed25519"
|
|
||||||
"encoding/base64"
|
|
||||||
"encoding/json"
|
|
||||||
"flag"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"evobgp/internal/birdfmt"
|
"evobgp/internal/nodecli"
|
||||||
"evobgp/internal/bundle"
|
|
||||||
"evobgp/internal/signing"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
if len(os.Args) < 2 {
|
if len(os.Args) < 2 {
|
||||||
usage()
|
nodecli.Usage(os.Stderr)
|
||||||
os.Exit(2)
|
|
||||||
}
|
|
||||||
switch os.Args[1] {
|
|
||||||
case "pull-bundle":
|
|
||||||
os.Exit(cmdPullBundle(os.Args[2:]))
|
|
||||||
case "verify-bundle":
|
|
||||||
os.Exit(cmdVerifyBundle(os.Args[2:]))
|
|
||||||
case "apply-bundle":
|
|
||||||
os.Exit(cmdApplyBundle(os.Args[2:]))
|
|
||||||
default:
|
|
||||||
usage()
|
|
||||||
os.Exit(2)
|
os.Exit(2)
|
||||||
}
|
}
|
||||||
}
|
os.Exit(nodecli.Run(os.Args[1:]))
|
||||||
|
|
||||||
func usage() {
|
|
||||||
fmt.Fprintf(os.Stderr, `Usage:
|
|
||||||
%s pull-bundle -base-url URL -token TOKEN -speaker-id ID [-revision-id ID] [-o path]
|
|
||||||
%s verify-bundle -f bundle.tar.gz (-pubkey-base64 B64 | -pubkey-hex HEX)
|
|
||||||
%s apply-bundle -f bundle.tar.gz -extract-dir DIR (-pubkey-base64 B64 | -pubkey-hex HEX)
|
|
||||||
[-bird PATH] [-birdc PATH] [-socket PATH] [-timeout DURATION]
|
|
||||||
|
|
||||||
apply-bundle verifies, extracts, runs bird -p on main bird.conf, then birdc configure.
|
|
||||||
`, os.Args[0], os.Args[0], os.Args[0])
|
|
||||||
}
|
|
||||||
|
|
||||||
func cmdPullBundle(args []string) int {
|
|
||||||
fs := flag.NewFlagSet("pull-bundle", flag.ExitOnError)
|
|
||||||
base := fs.String("base-url", "", "control plane base URL, e.g. http://localhost:8080")
|
|
||||||
token := fs.String("token", "", "Bearer token (node role)")
|
|
||||||
speaker := fs.String("speaker-id", "", "bgp_speaker id")
|
|
||||||
revision := fs.String("revision-id", "", "revision to fetch (empty = latest pointer)")
|
|
||||||
out := fs.String("o", "bundle.tar.gz", "output file")
|
|
||||||
_ = fs.Parse(args)
|
|
||||||
|
|
||||||
if strings.TrimSpace(*base) == "" || *token == "" || *speaker == "" {
|
|
||||||
fmt.Fprintln(os.Stderr, "pull-bundle: -base-url, -token, -speaker-id are required")
|
|
||||||
return 2
|
|
||||||
}
|
|
||||||
rev := strings.TrimSpace(*revision)
|
|
||||||
if rev == "" {
|
|
||||||
var err error
|
|
||||||
rev, err = fetchLatestRevision(*base, *token, *speaker)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintln(os.Stderr, err)
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
body, err := fetchBundle(*base, *token, *speaker, rev)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintln(os.Stderr, err)
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
if err := os.WriteFile(*out, body, 0o644); err != nil {
|
|
||||||
fmt.Fprintln(os.Stderr, err)
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
fmt.Fprintf(os.Stderr, "wrote %s (revision %s)\n", *out, rev)
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
func fetchLatestRevision(base, token, speaker string) (string, error) {
|
|
||||||
u := strings.TrimRight(base, "/") + "/v1/speakers/" + speaker + "/revisions/latest"
|
|
||||||
req, err := http.NewRequest(http.MethodGet, u, nil)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
req.Header.Set("Authorization", "Bearer "+token)
|
|
||||||
resp, err := http.DefaultClient.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
b, _ := io.ReadAll(resp.Body)
|
|
||||||
return "", fmt.Errorf("latest revision: %s: %s", resp.Status, strings.TrimSpace(string(b)))
|
|
||||||
}
|
|
||||||
var out struct {
|
|
||||||
RevisionID string `json:"revision_id"`
|
|
||||||
}
|
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
if out.RevisionID == "" {
|
|
||||||
return "", fmt.Errorf("empty revision_id in response")
|
|
||||||
}
|
|
||||||
return out.RevisionID, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func fetchBundle(base, token, speaker, revision string) ([]byte, error) {
|
|
||||||
u := strings.TrimRight(base, "/") + "/v1/speakers/" + speaker + "/bundle/" + revision
|
|
||||||
req, err := http.NewRequest(http.MethodGet, u, nil)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
req.Header.Set("Authorization", "Bearer "+token)
|
|
||||||
resp, err := http.DefaultClient.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
b, _ := io.ReadAll(resp.Body)
|
|
||||||
return nil, fmt.Errorf("bundle: %s: %s", resp.Status, strings.TrimSpace(string(b)))
|
|
||||||
}
|
|
||||||
return io.ReadAll(resp.Body)
|
|
||||||
}
|
|
||||||
|
|
||||||
func loadPubKey(pubB64, pubHex string) (ed25519.PublicKey, error) {
|
|
||||||
switch {
|
|
||||||
case strings.TrimSpace(pubB64) != "":
|
|
||||||
raw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(pubB64))
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if len(raw) != ed25519.PublicKeySize {
|
|
||||||
return nil, fmt.Errorf("pubkey-base64 must decode to %d bytes", ed25519.PublicKeySize)
|
|
||||||
}
|
|
||||||
return ed25519.PublicKey(raw), nil
|
|
||||||
case strings.TrimSpace(pubHex) != "":
|
|
||||||
return bundle.ParsePublicKeyHex(pubHex)
|
|
||||||
default:
|
|
||||||
return nil, fmt.Errorf("public key required")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func cmdVerifyBundle(args []string) int {
|
|
||||||
fs := flag.NewFlagSet("verify-bundle", flag.ExitOnError)
|
|
||||||
path := fs.String("f", "", "path to bundle.tar.gz")
|
|
||||||
pubB64 := fs.String("pubkey-base64", "", "Ed25519 public key (base64)")
|
|
||||||
pubHex := fs.String("pubkey-hex", "", "Ed25519 public key (64 hex chars)")
|
|
||||||
_ = fs.Parse(args)
|
|
||||||
|
|
||||||
if *path == "" {
|
|
||||||
fmt.Fprintln(os.Stderr, "verify-bundle: -f required")
|
|
||||||
return 2
|
|
||||||
}
|
|
||||||
pub, err := loadPubKey(*pubB64, *pubHex)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintln(os.Stderr, err)
|
|
||||||
return 2
|
|
||||||
}
|
|
||||||
raw, err := os.ReadFile(*path)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintln(os.Stderr, err)
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
v, err := signing.VerifyGzippedTar(raw, pub)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintln(os.Stderr, err)
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
fmt.Fprintf(os.Stderr, "ok: revision %s, %d payload files\n", v.Manifest.RevisionID, len(v.Files))
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
func cmdApplyBundle(args []string) int {
|
|
||||||
fs := flag.NewFlagSet("apply-bundle", flag.ExitOnError)
|
|
||||||
path := fs.String("f", "", "path to bundle.tar.gz")
|
|
||||||
dir := fs.String("extract-dir", "", "directory to extract into")
|
|
||||||
pubB64 := fs.String("pubkey-base64", "", "Ed25519 public key (base64)")
|
|
||||||
pubHex := fs.String("pubkey-hex", "", "Ed25519 public key (64 hex chars)")
|
|
||||||
bird := fs.String("bird", "", "bird binary (default PATH)")
|
|
||||||
birdc := fs.String("birdc", "", "birdc binary (default PATH)")
|
|
||||||
socket := fs.String("socket", "", "birdc -s socket path")
|
|
||||||
timeout := fs.Duration("timeout", 30*time.Second, "timeout for bird/birdc")
|
|
||||||
_ = fs.Parse(args)
|
|
||||||
|
|
||||||
if *path == "" || *dir == "" {
|
|
||||||
fmt.Fprintln(os.Stderr, "apply-bundle: -f and -extract-dir required")
|
|
||||||
return 2
|
|
||||||
}
|
|
||||||
pub, err := loadPubKey(*pubB64, *pubHex)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintln(os.Stderr, err)
|
|
||||||
return 2
|
|
||||||
}
|
|
||||||
raw, err := os.ReadFile(*path)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintln(os.Stderr, err)
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
v, err := signing.VerifyGzippedTar(raw, pub)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintln(os.Stderr, err)
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
root := filepath.Clean(*dir)
|
|
||||||
if err := os.MkdirAll(root, 0o755); err != nil {
|
|
||||||
fmt.Fprintln(os.Stderr, err)
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
if err := bundle.WriteExtractedFiles(root, v); err != nil {
|
|
||||||
fmt.Fprintln(os.Stderr, err)
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
mainRel := v.FindMainBirdConf()
|
|
||||||
if mainRel == "" {
|
|
||||||
fmt.Fprintln(os.Stderr, "bundle has no bird.conf path in manifest")
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
mainPath := filepath.Join(root, filepath.FromSlash(strings.TrimPrefix(mainRel, "/")))
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), *timeout)
|
|
||||||
defer cancel()
|
|
||||||
ctl := &birdfmt.BirdCtl{Bird: *bird, Birdc: *birdc, Socket: *socket}
|
|
||||||
if err := ctl.ParseCheck(ctx, mainPath); err != nil {
|
|
||||||
fmt.Fprintln(os.Stderr, err)
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
if err := ctl.Configure(ctx); err != nil {
|
|
||||||
fmt.Fprintln(os.Stderr, err)
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
fmt.Fprintf(os.Stderr, "applied revision %s (main config %s)\n", v.Manifest.RevisionID, mainPath)
|
|
||||||
return 0
|
|
||||||
}
|
}
|
||||||
@@ -41,6 +41,9 @@ func main() {
|
|||||||
|
|
||||||
apiBase := strings.TrimSpace(os.Getenv("EVOBGP_CONTROL_PLANE_URL"))
|
apiBase := strings.TrimSpace(os.Getenv("EVOBGP_CONTROL_PLANE_URL"))
|
||||||
apiTok := strings.TrimSpace(os.Getenv("EVOBGP_SCHEDULER_BEARER"))
|
apiTok := strings.TrimSpace(os.Getenv("EVOBGP_SCHEDULER_BEARER"))
|
||||||
|
if os.Getenv("EVOBGP_SCHEDULER_STANDALONE") == "1" && apiBase == "" {
|
||||||
|
log.Fatal("evobgp-scheduler: EVOBGP_SCHEDULER_STANDALONE=1 requires EVOBGP_CONTROL_PLANE_URL and EVOBGP_SCHEDULER_BEARER (split deploy must not use in-process jobs.Registry)")
|
||||||
|
}
|
||||||
deps := &scheduler.Deps{
|
deps := &scheduler.Deps{
|
||||||
Store: st,
|
Store: st,
|
||||||
HTTP: &http.Client{Timeout: 45 * time.Second},
|
HTTP: &http.Client{Timeout: 45 * time.Second},
|
||||||
|
|||||||
@@ -126,6 +126,8 @@ services:
|
|||||||
condition: service_started
|
condition: service_started
|
||||||
environment:
|
environment:
|
||||||
<<: *env-ref
|
<<: *env-ref
|
||||||
|
# Split deploy: scheduler must enqueue jobs via API, not in-process Registry (ARCH-04).
|
||||||
|
EVOBGP_SCHEDULER_STANDALONE: "1"
|
||||||
EVOBGP_CONTROL_PLANE_URL: http://evobgp-api:8080
|
EVOBGP_CONTROL_PLANE_URL: http://evobgp-api:8080
|
||||||
EVOBGP_SCHEDULER_BEARER: dev
|
EVOBGP_SCHEDULER_BEARER: dev
|
||||||
logging: *default-logging
|
logging: *default-logging
|
||||||
|
|||||||
@@ -2,7 +2,11 @@
|
|||||||
|
|
||||||
## CI (Gitea Actions)
|
## CI (Gitea Actions)
|
||||||
|
|
||||||
Сборка образов — **`docker buildx bake`** (`deploy/docker/docker-bake.hcl`), а не матрица из восьми отдельных `docker build`.
|
Сборка образов — **`docker buildx bake`** (`deploy/docker/docker-bake.hcl`), не отдельные `docker build`.
|
||||||
|
|
||||||
|
**Publish:** push git-тега `vX.Y.Z` запускает [.gitea/workflows/publish.yaml](../.gitea/workflows/publish.yaml) — образы с semver-тегом, `latest`, `sha-*`. Переменная `VERSION` из тега попадает в Go-бинарники через `-ldflags`.
|
||||||
|
|
||||||
|
Локально 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 REGISTRY=git.shts.su/<owner>
|
||||||
export IMAGE_TAG=latest
|
export IMAGE_TAG=latest
|
||||||
export SHORT_SHA=$(git rev-parse --short HEAD)
|
export SHORT_SHA=$(git rev-parse --short HEAD)
|
||||||
|
export VERSION=dev
|
||||||
|
export BUILD_TIME=
|
||||||
sh write-bake-override.sh
|
sh write-bake-override.sh
|
||||||
docker buildx bake --allow=fs.read=../.. -f docker-bake.hcl -f docker-bake.override.hcl go-images
|
docker buildx bake --allow=fs.read=../.. -f docker-bake.hcl -f docker-bake.override.hcl go-images
|
||||||
docker buildx bake --allow=fs.read=../.. -f docker-bake.hcl -f docker-bake.override.hcl web-images
|
docker buildx bake --allow=fs.read=../.. -f docker-bake.hcl -f docker-bake.override.hcl web-images
|
||||||
|
|||||||
@@ -19,6 +19,14 @@ variable "SHA_FULL" {
|
|||||||
default = ""
|
default = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
variable "VERSION" {
|
||||||
|
default = "dev"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "BUILD_TIME" {
|
||||||
|
default = ""
|
||||||
|
}
|
||||||
|
|
||||||
variable "CACHE_REF_GO" {
|
variable "CACHE_REF_GO" {
|
||||||
default = ""
|
default = ""
|
||||||
}
|
}
|
||||||
@@ -87,6 +95,11 @@ target "go-build-all" {
|
|||||||
dockerfile = "deploy/docker/gobinary/Dockerfile"
|
dockerfile = "deploy/docker/gobinary/Dockerfile"
|
||||||
target = "build-all"
|
target = "build-all"
|
||||||
platforms = ["linux/amd64"]
|
platforms = ["linux/amd64"]
|
||||||
|
args = {
|
||||||
|
VERSION = VERSION
|
||||||
|
GIT_SHA = notequal("", SHA_FULL) ? SHA_FULL : SHORT_SHA
|
||||||
|
BUILD_TIME = BUILD_TIME
|
||||||
|
}
|
||||||
contexts = {
|
contexts = {
|
||||||
deps = "target:go-deps"
|
deps = "target:go-deps"
|
||||||
}
|
}
|
||||||
@@ -127,14 +140,17 @@ target "_go-runtime-birdc" {
|
|||||||
|
|
||||||
function "image-tags" {
|
function "image-tags" {
|
||||||
params = [name]
|
params = [name]
|
||||||
result = notequal("", SHA_FULL) ? [
|
result = concat(
|
||||||
"${REGISTRY}/${name}:${IMAGE_TAG}",
|
notequal("", SHA_FULL) ? [
|
||||||
"${REGISTRY}/${name}:${SHORT_SHA}",
|
"${REGISTRY}/${name}:${IMAGE_TAG}",
|
||||||
"${REGISTRY}/${name}:sha-${SHA_FULL}",
|
"${REGISTRY}/${name}:${SHORT_SHA}",
|
||||||
] : [
|
"${REGISTRY}/${name}:sha-${SHA_FULL}",
|
||||||
"${REGISTRY}/${name}:${IMAGE_TAG}",
|
] : [
|
||||||
"${REGISTRY}/${name}:${SHORT_SHA}",
|
"${REGISTRY}/${name}:${IMAGE_TAG}",
|
||||||
]
|
"${REGISTRY}/${name}:${SHORT_SHA}",
|
||||||
|
],
|
||||||
|
notequal(VERSION, "dev") && notequal(VERSION, "") ? ["${REGISTRY}/${name}:v${VERSION}"] : []
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
target "evobgp-api" {
|
target "evobgp-api" {
|
||||||
|
|||||||
@@ -10,22 +10,38 @@ RUN --mount=type=cache,target=/go/pkg/mod,sharing=locked \
|
|||||||
|
|
||||||
FROM deps AS build-all
|
FROM deps AS build-all
|
||||||
COPY . .
|
COPY . .
|
||||||
|
ARG VERSION=dev
|
||||||
|
ARG GIT_SHA=unknown
|
||||||
|
ARG BUILD_TIME=
|
||||||
RUN --mount=type=cache,target=/go/pkg/mod,sharing=locked \
|
RUN --mount=type=cache,target=/go/pkg/mod,sharing=locked \
|
||||||
--mount=type=cache,target=/root/.cache/go-build,sharing=locked \
|
--mount=type=cache,target=/root/.cache/go-build,sharing=locked \
|
||||||
set -eux; \
|
set -eux; \
|
||||||
mkdir -p /out; \
|
mkdir -p /out; \
|
||||||
for d in cmd/*/; do \
|
for d in cmd/*/; do \
|
||||||
name="$(basename "$d")"; \
|
name="$(basename "$d")"; \
|
||||||
CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o "/out/${name}" "./cmd/${name}"; \
|
CGO_ENABLED=0 go build -trimpath \
|
||||||
|
-ldflags="-s -w \
|
||||||
|
-X evobgp/internal/version.Version=${VERSION} \
|
||||||
|
-X evobgp/internal/version.GitSHA=${GIT_SHA} \
|
||||||
|
-X evobgp/internal/version.BuildTime=${BUILD_TIME}" \
|
||||||
|
-o "/out/${name}" "./cmd/${name}"; \
|
||||||
done
|
done
|
||||||
|
|
||||||
# Один бинарь (локальная сборка); в CI — build-all + runtime.
|
# Один бинарь (локальная сборка); в CI — build-all + runtime.
|
||||||
FROM deps AS build
|
FROM deps AS build
|
||||||
COPY . .
|
COPY . .
|
||||||
ARG BIN=evobgp-api
|
ARG BIN=evobgp-api
|
||||||
|
ARG VERSION=dev
|
||||||
|
ARG GIT_SHA=unknown
|
||||||
|
ARG BUILD_TIME=
|
||||||
RUN --mount=type=cache,target=/go/pkg/mod,sharing=locked \
|
RUN --mount=type=cache,target=/go/pkg/mod,sharing=locked \
|
||||||
--mount=type=cache,target=/root/.cache/go-build,sharing=locked \
|
--mount=type=cache,target=/root/.cache/go-build,sharing=locked \
|
||||||
CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/evobgp "./cmd/${BIN}"
|
CGO_ENABLED=0 go build -trimpath \
|
||||||
|
-ldflags="-s -w \
|
||||||
|
-X evobgp/internal/version.Version=${VERSION} \
|
||||||
|
-X evobgp/internal/version.GitSHA=${GIT_SHA} \
|
||||||
|
-X evobgp/internal/version.BuildTime=${BUILD_TIME}" \
|
||||||
|
-o /out/evobgp "./cmd/${BIN}"
|
||||||
|
|
||||||
FROM public.ecr.aws/docker/library/debian:bookworm-slim AS birdc
|
FROM public.ecr.aws/docker/library/debian:bookworm-slim AS birdc
|
||||||
ARG BIRD_VERSION=2.14
|
ARG BIRD_VERSION=2.14
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ REGISTRY="${REGISTRY:?REGISTRY required}"
|
|||||||
IMAGE_TAG="${IMAGE_TAG:-latest}"
|
IMAGE_TAG="${IMAGE_TAG:-latest}"
|
||||||
SHORT_SHA="${SHORT_SHA:-dev}"
|
SHORT_SHA="${SHORT_SHA:-dev}"
|
||||||
SHA_FULL="${SHA_FULL:-}"
|
SHA_FULL="${SHA_FULL:-}"
|
||||||
|
VERSION="${VERSION:-dev}"
|
||||||
|
BUILD_TIME="${BUILD_TIME:-}"
|
||||||
CACHE_REF_GO="${CACHE_REF_GO:-}"
|
CACHE_REF_GO="${CACHE_REF_GO:-}"
|
||||||
CACHE_REF_WEB="${CACHE_REF_WEB:-}"
|
CACHE_REF_WEB="${CACHE_REF_WEB:-}"
|
||||||
cat >"$OUT" <<EOF
|
cat >"$OUT" <<EOF
|
||||||
@@ -22,6 +24,12 @@ variable "SHORT_SHA" {
|
|||||||
variable "SHA_FULL" {
|
variable "SHA_FULL" {
|
||||||
default = "${SHA_FULL}"
|
default = "${SHA_FULL}"
|
||||||
}
|
}
|
||||||
|
variable "VERSION" {
|
||||||
|
default = "${VERSION}"
|
||||||
|
}
|
||||||
|
variable "BUILD_TIME" {
|
||||||
|
default = "${BUILD_TIME}"
|
||||||
|
}
|
||||||
variable "CACHE_REF_GO" {
|
variable "CACHE_REF_GO" {
|
||||||
default = "${CACHE_REF_GO}"
|
default = "${CACHE_REF_GO}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
| [api.md](api.md) | REST: префикс `/v1`, публичные маршруты, ссылки на OpenAPI |
|
| [api.md](api.md) | REST: префикс `/v1`, публичные маршруты, ссылки на OpenAPI |
|
||||||
| [router-lists-ui-integration.md](router-lists-ui-integration.md) | Интеграция `router-lists-ui` с EvoBGP API (`DOMAINS/IP_RANGES/AS_PREFIXES/communities`) |
|
| [router-lists-ui-integration.md](router-lists-ui-integration.md) | Интеграция `router-lists-ui` с EvoBGP API (`DOMAINS/IP_RANGES/AS_PREFIXES/communities`) |
|
||||||
| [access.md](access.md) | Выдача доступа: API-ключи, роли, нода, CORS |
|
| [access.md](access.md) | Выдача доступа: API-ключи, роли, нода, CORS |
|
||||||
|
| [releasing.md](releasing.md) | Автоматические релизы, Conventional Commits, CI |
|
||||||
| [openapi.yaml](openapi.yaml) | Источник правды по контракту API |
|
| [openapi.yaml](openapi.yaml) | Источник правды по контракту API |
|
||||||
| [OPENAPI-GITEA.md](OPENAPI-GITEA.md) | Как открыть HTML-документацию API (в т.ч. из Gitea) |
|
| [OPENAPI-GITEA.md](OPENAPI-GITEA.md) | Как открыть HTML-документацию API (в т.ч. из Gitea) |
|
||||||
| [evobgp-api-sketches.md](evobgp-api-sketches.md) | Ранний черновик идей API (контекст, не замена OpenAPI) |
|
| [evobgp-api-sketches.md](evobgp-api-sketches.md) | Ранний черновик идей API (контекст, не замена OpenAPI) |
|
||||||
@@ -31,7 +32,13 @@
|
|||||||
| [../.cursor/rules/engineering.mdc](../.cursor/rules/engineering.mdc) | Go, API, migrations, security, enforcement |
|
| [../.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/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/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` попросите агента: **«сгенерируй коммит по staged»**, **«закоммить»**, **«commit message»** — агент **обязан первым делом** запустить `scripts/commit/staged-context.ps1`, затем скилл [commit-message](../.cursor/skills/commit-message/SKILL.md) (заголовок EN, тело RU, auto-split). Просмотр групп вручную: `powershell -NoProfile -File scripts/commit/staged-context.ps1 | ConvertFrom-Json`.
|
||||||
|
|
||||||
## Репозиторий и CI
|
## Репозиторий и CI
|
||||||
|
|
||||||
- [../.gitea/README.md](../.gitea/README.md) — Gitea Actions, runner, сборка образов и push в Container Registry.
|
- [../.gitea/README.md](../.gitea/README.md) — Gitea Actions, runner, сборка образов и push в Container Registry.
|
||||||
|
- [releasing.md](releasing.md) — semantic-release, commit conventions, теги образов.
|
||||||
+38
-2
@@ -1,10 +1,12 @@
|
|||||||
openapi: 3.1.0
|
openapi: 3.1.0
|
||||||
info:
|
info:
|
||||||
title: EvoBGP Control Plane API
|
title: EvoBGP Control Plane API
|
||||||
version: 0.1.0
|
version: 1.0.0
|
||||||
description: |
|
description: |
|
||||||
REST API управления префиксами, модулями ingest, ревизиями конфигурации BIRD и задачами (async jobs).
|
REST API управления префиксами, модулями ingest, ревизиями конфигурации BIRD и задачами (async jobs).
|
||||||
|
|
||||||
|
**Актуальная semver-сборка:** `GET /version` или `GET /v1/version` (поле `version`; совпадает с git-тегом `vX.Y.Z`).
|
||||||
|
|
||||||
**Соглашения:** префикс `/v1`; идентификаторы - UUID v7 или ULID (строки); время - ISO 8601 UTC.
|
**Соглашения:** префикс `/v1`; идентификаторы - UUID v7 или ULID (строки); время - ISO 8601 UTC.
|
||||||
Ошибки - `application/problem+json` ([RFC 9457](https://www.rfc-editor.org/rfc/rfc9457)).
|
Ошибки - `application/problem+json` ([RFC 9457](https://www.rfc-editor.org/rfc/rfc9457)).
|
||||||
Пагинация списков - `cursor` + `limit`; ответ содержит `items`, `next_cursor`, `has_more`.
|
Пагинация списков - `cursor` + `limit`; ответ содержит `items`, `next_cursor`, `has_more`.
|
||||||
@@ -606,6 +608,9 @@ components:
|
|||||||
properties:
|
properties:
|
||||||
id:
|
id:
|
||||||
$ref: "#/components/schemas/ResourceId"
|
$ref: "#/components/schemas/ResourceId"
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
description: Отображаемое имя профиля (опционально).
|
||||||
url:
|
url:
|
||||||
type: string
|
type: string
|
||||||
format: uri
|
format: uri
|
||||||
@@ -621,6 +626,9 @@ components:
|
|||||||
required:
|
required:
|
||||||
- url
|
- url
|
||||||
properties:
|
properties:
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
description: Отображаемое имя профиля (опционально).
|
||||||
url:
|
url:
|
||||||
type: string
|
type: string
|
||||||
format: uri
|
format: uri
|
||||||
@@ -752,8 +760,14 @@ components:
|
|||||||
VersionInfo:
|
VersionInfo:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
|
version:
|
||||||
|
type: string
|
||||||
|
description: Semver сборки (git tag без префикса v).
|
||||||
|
example: "1.2.3"
|
||||||
api_version:
|
api_version:
|
||||||
type: string
|
type: string
|
||||||
|
deprecated: true
|
||||||
|
description: Alias поля `version` (сохранён для обратной совместимости).
|
||||||
git_sha:
|
git_sha:
|
||||||
type: string
|
type: string
|
||||||
build_time:
|
build_time:
|
||||||
@@ -840,6 +854,8 @@ components:
|
|||||||
DohProfilePatch:
|
DohProfilePatch:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
url:
|
url:
|
||||||
type: string
|
type: string
|
||||||
format: uri
|
format: uri
|
||||||
@@ -959,11 +975,31 @@ paths:
|
|||||||
default:
|
default:
|
||||||
$ref: "#/components/responses/DefaultProblem"
|
$ref: "#/components/responses/DefaultProblem"
|
||||||
|
|
||||||
|
/version:
|
||||||
|
get:
|
||||||
|
tags: [System]
|
||||||
|
summary: Версия сборки (корневой путь)
|
||||||
|
description: |
|
||||||
|
Аналог `GET /v1/version`. Публичный маршрут без аутентификации.
|
||||||
|
Semver в поле `version` задаётся при сборке Docker-образов из git-тега.
|
||||||
|
operationId: getVersionRoot
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Метаданные сборки.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/VersionInfo"
|
||||||
|
default:
|
||||||
|
$ref: "#/components/responses/DefaultProblem"
|
||||||
|
|
||||||
/v1/version:
|
/v1/version:
|
||||||
get:
|
get:
|
||||||
tags: [System]
|
tags: [System]
|
||||||
summary: Версия сборки
|
summary: Версия сборки
|
||||||
description: Версия API и control-plane (`git_sha`, `build_time` и др.).
|
description: |
|
||||||
|
Semver control-plane и метаданные сборки (`git_sha`, `build_time`).
|
||||||
|
Дублирует `GET /version`.
|
||||||
operationId: getVersion
|
operationId: getVersion
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
# Релизы и версионирование EvoBGP
|
||||||
|
|
||||||
|
EvoBGP использует [Conventional Commits](https://www.conventionalcommits.org/) и [semantic-release](https://semantic-release.gitbook.io/) для полностью автоматических релизов на Gitea (`git.shts.su`). Ручное повышение версии в коде не требуется.
|
||||||
|
|
||||||
|
## Как определяется версия
|
||||||
|
|
||||||
|
| Тип коммита | Bump |
|
||||||
|
|-------------|------|
|
||||||
|
| `feat` | minor (1.0.0 → 1.1.0) |
|
||||||
|
| `fix`, `perf` | patch (1.0.0 → 1.0.1) |
|
||||||
|
| `feat!`, `fix!` или `BREAKING CHANGE:` в теле | major (1.0.0 → 2.0.0) |
|
||||||
|
| `docs`, `chore`, `ci`, `test`, `refactor` | без релиза |
|
||||||
|
|
||||||
|
Первый релиз при отсутствии git-тегов — **1.0.0**, если есть releasable-коммиты.
|
||||||
|
|
||||||
|
Подробные правила сообщений коммитов: [.cursor/rules/conventional-commits.mdc](../.cursor/rules/conventional-commits.mdc).
|
||||||
|
|
||||||
|
## CI-пайплайн
|
||||||
|
|
||||||
|
```text
|
||||||
|
push/merge в main
|
||||||
|
→ CI (openapi, web, go, bird2, commitlint на PR)
|
||||||
|
→ Release (semantic-release после успешного CI)
|
||||||
|
→ git tag vX.Y.Z
|
||||||
|
→ CHANGELOG.md + commit [skip ci]
|
||||||
|
→ Gitea Release с notes
|
||||||
|
→ Publish (push тега v*)
|
||||||
|
→ docker buildx bake с VERSION из тега
|
||||||
|
→ образы: latest, vX.Y.Z, sha-*, короткий SHA
|
||||||
|
```
|
||||||
|
|
||||||
|
Workflow-файлы:
|
||||||
|
|
||||||
|
- [.gitea/workflows/ci.yaml](../.gitea/workflows/ci.yaml) — quality gates
|
||||||
|
- [.gitea/workflows/release.yaml](../.gitea/workflows/release.yaml) — semantic-release
|
||||||
|
- [.gitea/workflows/publish.yaml](../.gitea/workflows/publish.yaml) — публикация образов
|
||||||
|
|
||||||
|
Конфиг semantic-release: [.releaserc.json](../.releaserc.json).
|
||||||
|
|
||||||
|
## Секреты Gitea
|
||||||
|
|
||||||
|
Один PAT в репозитории — **`ACTIONS_PAT`** (Settings → Actions → Secrets). Используется для semantic-release, push тегов/CHANGELOG и docker login в registry.
|
||||||
|
|
||||||
|
| Право PAT | Зачем |
|
||||||
|
|-----------|--------|
|
||||||
|
| push / write repository | commit `CHANGELOG.md`, push тегов |
|
||||||
|
| releases | Gitea Release через semantic-release |
|
||||||
|
| packages (Container Registry) | workflow **Publish** |
|
||||||
|
|
||||||
|
Если `ACTIONS_PAT` не задан, workflow пробует **`gitea.token`** job-токен (нужны права на releases и packages в настройках Gitea).
|
||||||
|
|
||||||
|
## Источник правды для версии в 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`; поле `api_version` — deprecated alias.
|
||||||
|
|
||||||
|
Web UI показывает версию из API (footer sidebar, страница «Мониторинг»).
|
||||||
|
|
||||||
|
## CHANGELOG
|
||||||
|
|
||||||
|
Файл [`CHANGELOG.md`](../CHANGELOG.md) создаётся и обновляется semantic-release. Копия прикрепляется к Gitea Release.
|
||||||
|
|
||||||
|
## Проверка после релиза
|
||||||
|
|
||||||
|
1. В Gitea: тег `vX.Y.Z` и Release с notes.
|
||||||
|
2. Container Registry: образы с тегом `vX.Y.Z`.
|
||||||
|
3. `curl http://localhost:8080/version` → `"version":"X.Y.Z"`.
|
||||||
|
4. Footer Web UI → `vX.Y.Z`.
|
||||||
|
|
||||||
|
## Первый релиз (bootstrap)
|
||||||
|
|
||||||
|
Merge PR в `main` с conventional commit типа `feat(release): ...` (не `chore` — иначе релиз не создастся). Ожидаемый результат: **v1.0.0**.
|
||||||
|
|
||||||
|
После merge убедитесь, что workflow **Release** завершился успешно и workflow **Publish** собрал образы по тегу.
|
||||||
@@ -15,6 +15,11 @@ import (
|
|||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// NewCDNHTTPClient returns the shared HTTP client for CDN and preview fetches (PERF-02 / ERR-03).
|
||||||
|
func NewCDNHTTPClient() *http.Client {
|
||||||
|
return &http.Client{Timeout: 45 * time.Second}
|
||||||
|
}
|
||||||
|
|
||||||
// BootstrapWorkers opens the same store.Backend and jobs.Registry as New (without HTTP or bundle keys).
|
// BootstrapWorkers opens the same store.Backend and jobs.Registry as New (without HTTP or bundle keys).
|
||||||
// Used by standalone worker binaries (scheduler, ingest, …) that share PostgreSQL with the API.
|
// Used by standalone worker binaries (scheduler, ingest, …) that share PostgreSQL with the API.
|
||||||
func BootstrapWorkers(ctx context.Context, opts Options) (store.Backend, *jobs.Registry, *pgxpool.Pool, error) {
|
func BootstrapWorkers(ctx context.Context, opts Options) (store.Backend, *jobs.Registry, *pgxpool.Pool, error) {
|
||||||
@@ -44,7 +49,7 @@ func BootstrapWorkers(ctx context.Context, opts Options) (store.Backend, *jobs.R
|
|||||||
backend = mem
|
backend = mem
|
||||||
}
|
}
|
||||||
|
|
||||||
cdnHTTP := &http.Client{Timeout: 45 * time.Second}
|
cdnHTTP := NewCDNHTTPClient()
|
||||||
wk := &jobs.Worker{Store: backend, HTTPClient: cdnHTTP}
|
wk := &jobs.Worker{Store: backend, HTTPClient: cdnHTTP}
|
||||||
reg := jobs.NewRegistry(wk.Process)
|
reg := jobs.NewRegistry(wk.Process)
|
||||||
wk.Registry = reg
|
wk.Registry = reg
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
)
|
||||||
|
|
||||||
|
func parseListLimit(r *http.Request) int {
|
||||||
|
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
||||||
|
if limit <= 0 {
|
||||||
|
return 50
|
||||||
|
}
|
||||||
|
if limit > 500 {
|
||||||
|
return 500
|
||||||
|
}
|
||||||
|
return limit
|
||||||
|
}
|
||||||
|
|
||||||
|
func strPtrOrNull(s string) any {
|
||||||
|
if s == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
@@ -2,9 +2,15 @@ package httpapi
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
internalErrorDetail = "an internal error occurred"
|
||||||
|
badGatewayDetail = "upstream request failed"
|
||||||
|
)
|
||||||
|
|
||||||
// Problem is RFC 9457 application/problem+json.
|
// Problem is RFC 9457 application/problem+json.
|
||||||
type Problem struct {
|
type Problem struct {
|
||||||
Type string `json:"type,omitempty"`
|
Type string `json:"type,omitempty"`
|
||||||
@@ -34,3 +40,19 @@ func writeJSON(w http.ResponseWriter, status int, v any) {
|
|||||||
func writeNoContent(w http.ResponseWriter) {
|
func writeNoContent(w http.ResponseWriter) {
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// writeInternalError logs err server-side and returns a generic 500 problem (ERR-01).
|
||||||
|
func writeInternalError(w http.ResponseWriter, operation string, err error) {
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("httpapi: %s: %v", operation, err)
|
||||||
|
}
|
||||||
|
writeProblem(w, http.StatusInternalServerError, "Internal Error", internalErrorDetail)
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeBadGateway logs err server-side and returns a generic 502 problem (ERR-01).
|
||||||
|
func writeBadGateway(w http.ResponseWriter, operation string, err error) {
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("httpapi: %s: %v", operation, err)
|
||||||
|
}
|
||||||
|
writeProblem(w, http.StatusBadGateway, "Bad Gateway", badGatewayDetail)
|
||||||
|
}
|
||||||
+49
-327
@@ -19,7 +19,9 @@ import (
|
|||||||
"evobgp/internal/jobs"
|
"evobgp/internal/jobs"
|
||||||
"evobgp/internal/observability"
|
"evobgp/internal/observability"
|
||||||
"evobgp/internal/pipeline"
|
"evobgp/internal/pipeline"
|
||||||
|
"evobgp/internal/reports"
|
||||||
"evobgp/internal/store"
|
"evobgp/internal/store"
|
||||||
|
"evobgp/internal/version"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Handler returns the root HTTP handler (system routes public; rest under /v1/ authenticated).
|
// Handler returns the root HTTP handler (system routes public; rest under /v1/ authenticated).
|
||||||
@@ -29,6 +31,7 @@ func (s *Server) Handler() http.Handler {
|
|||||||
wrappedV1 := http.StripPrefix("/v1", v1)
|
wrappedV1 := http.StripPrefix("/v1", v1)
|
||||||
|
|
||||||
s.mux.Handle("GET /metrics", observability.MetricsHandler())
|
s.mux.Handle("GET /metrics", observability.MetricsHandler())
|
||||||
|
s.mux.HandleFunc("GET /version", s.handleVersion)
|
||||||
s.mux.HandleFunc("GET /v1/health", s.handleHealth)
|
s.mux.HandleFunc("GET /v1/health", s.handleHealth)
|
||||||
s.mux.HandleFunc("GET /v1/ready", s.handleReady)
|
s.mux.HandleFunc("GET /v1/ready", s.handleReady)
|
||||||
s.mux.HandleFunc("GET /v1/version", s.handleVersion)
|
s.mux.HandleFunc("GET /v1/version", s.handleVersion)
|
||||||
@@ -83,7 +86,7 @@ func (s *Server) handleReady(w http.ResponseWriter, r *http.Request) {
|
|||||||
defer cancel()
|
defer cancel()
|
||||||
if s.pgPool != nil {
|
if s.pgPool != nil {
|
||||||
if err := s.pgPool.Ping(ctx); err != nil {
|
if err := s.pgPool.Ping(ctx); err != nil {
|
||||||
checks["postgres"] = err.Error()
|
checks["postgres"] = "unavailable"
|
||||||
writeJSON(w, http.StatusServiceUnavailable, map[string]any{"status": "not_ready", "checks": checks})
|
writeJSON(w, http.StatusServiceUnavailable, map[string]any{"status": "not_ready", "checks": checks})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -95,11 +98,16 @@ func (s *Server) handleReady(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleVersion(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleVersion(w http.ResponseWriter, r *http.Request) {
|
||||||
sha := strings.TrimSpace(os.Getenv("EVOBGP_GIT_SHA"))
|
ver, sha, buildTime := version.Info()
|
||||||
if sha == "" {
|
resp := map[string]string{
|
||||||
sha = "unknown"
|
"version": ver,
|
||||||
|
"api_version": ver,
|
||||||
|
"git_sha": sha,
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]string{"api_version": "0.1.0", "git_sha": sha})
|
if buildTime != "" {
|
||||||
|
resp["build_time"] = buildTime
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, resp)
|
||||||
}
|
}
|
||||||
|
|
||||||
func moduleJSON(mod *store.Module) map[string]any {
|
func moduleJSON(mod *store.Module) map[string]any {
|
||||||
@@ -189,19 +197,23 @@ func (s *Server) handleListModules(w http.ResponseWriter, r *http.Request) {
|
|||||||
enabledFilter = &v
|
enabledFilter = &v
|
||||||
}
|
}
|
||||||
|
|
||||||
mods := s.store.ListModules(a.TenantID)
|
filtered := make([]*store.Module, 0)
|
||||||
items := make([]map[string]any, 0, len(mods))
|
for _, mod := range s.store.ListModules(a.TenantID) {
|
||||||
for _, mod := range mods {
|
|
||||||
if typeFilter != "" && mod.Type != typeFilter {
|
if typeFilter != "" && mod.Type != typeFilter {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if enabledFilter != nil && mod.Enabled != *enabledFilter {
|
if enabledFilter != nil && mod.Enabled != *enabledFilter {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
filtered = append(filtered, mod)
|
||||||
|
}
|
||||||
|
page, next, more := store.PaginateOffset(filtered, r.URL.Query().Get("cursor"), parseListLimit(r))
|
||||||
|
items := make([]map[string]any, 0, len(page))
|
||||||
|
for _, mod := range page {
|
||||||
items = append(items, moduleJSON(mod))
|
items = append(items, moduleJSON(mod))
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]any{
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
"items": items, "next_cursor": nil, "has_more": false,
|
"items": items, "next_cursor": strPtrOrNull(next), "has_more": more,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -214,91 +226,17 @@ func (s *Server) handleRouterListsCatalog(w http.ResponseWriter, r *http.Request
|
|||||||
if !s.requireAtLeast(w, a, "viewer") {
|
if !s.requireAtLeast(w, a, "viewer") {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
cat, err := reports.BuildRouterListsCatalog(s.store, a.TenantID)
|
||||||
mods := s.store.ListModules(a.TenantID)
|
|
||||||
moduleItems := make([]map[string]any, 0, len(mods))
|
|
||||||
domains := make([]map[string]any, 0)
|
|
||||||
asns := make([]map[string]any, 0)
|
|
||||||
ipRanges := make([]map[string]any, 0)
|
|
||||||
|
|
||||||
for _, mod := range mods {
|
|
||||||
switch mod.Type {
|
|
||||||
case "DOMAINS", "AS_PREFIXES", "IP_RANGES":
|
|
||||||
moduleItems = append(moduleItems, moduleJSON(mod))
|
|
||||||
default:
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
switch mod.Type {
|
|
||||||
case "DOMAINS":
|
|
||||||
list, err := s.store.ListDomainEntries(a.TenantID, mod.ID)
|
|
||||||
if err != nil {
|
|
||||||
writeStoreErr(w, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
for _, x := range list {
|
|
||||||
domains = append(domains, map[string]any{
|
|
||||||
"module_id": mod.ID,
|
|
||||||
"entry": domainEntryJSON(x),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
case "AS_PREFIXES":
|
|
||||||
list, err := s.store.ListASEntries(a.TenantID, mod.ID)
|
|
||||||
if err != nil {
|
|
||||||
writeStoreErr(w, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
for _, x := range list {
|
|
||||||
asns = append(asns, map[string]any{
|
|
||||||
"module_id": mod.ID,
|
|
||||||
"entry": asEntryJSON(x),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
case "IP_RANGES":
|
|
||||||
list, err := s.store.ListIPRangeEntries(a.TenantID, mod.ID)
|
|
||||||
if err != nil {
|
|
||||||
writeStoreErr(w, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
for _, x := range list {
|
|
||||||
ipRanges = append(ipRanges, map[string]any{
|
|
||||||
"module_id": mod.ID,
|
|
||||||
"entry": ipRangeJSON(x),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
comms, err := s.store.ListCommunities(a.TenantID)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeStoreErr(w, err)
|
writeStoreErr(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
communityItems := make([]map[string]any, 0, len(comms))
|
|
||||||
for _, c := range comms {
|
|
||||||
communityItems = append(communityItems, map[string]any{
|
|
||||||
"id": c.ID,
|
|
||||||
"community": c.Community,
|
|
||||||
"title": c.Title,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
writeJSON(w, http.StatusOK, map[string]any{
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
"modules": map[string]any{
|
"modules": map[string]any{"items": cat.Modules},
|
||||||
"items": moduleItems,
|
"domains": map[string]any{"items": cat.Domains},
|
||||||
},
|
"asns": map[string]any{"items": cat.ASNs},
|
||||||
"domains": map[string]any{
|
"ip_ranges": map[string]any{"items": cat.IPRanges},
|
||||||
"items": domains,
|
"communities": map[string]any{"items": cat.Communities},
|
||||||
},
|
|
||||||
"asns": map[string]any{
|
|
||||||
"items": asns,
|
|
||||||
},
|
|
||||||
"ip_ranges": map[string]any{
|
|
||||||
"items": ipRanges,
|
|
||||||
},
|
|
||||||
"communities": map[string]any{
|
|
||||||
"items": communityItems,
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -317,7 +255,7 @@ func (s *Server) handleGetModule(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeProblem(w, http.StatusNotFound, "Not Found", "module not found")
|
writeProblem(w, http.StatusNotFound, "Not Found", "module not found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeProblem(w, http.StatusInternalServerError, "Internal Error", err.Error())
|
writeInternalError(w, "internal", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, moduleJSON(mod))
|
writeJSON(w, http.StatusOK, moduleJSON(mod))
|
||||||
@@ -332,10 +270,11 @@ func (s *Server) handleListPeers(w http.ResponseWriter, r *http.Request) {
|
|||||||
if !s.requireAtLeast(w, a, "viewer") {
|
if !s.requireAtLeast(w, a, "viewer") {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
peers := s.store.ListPeers(a.TenantID)
|
allPeers := s.store.ListPeers(a.TenantID)
|
||||||
|
page, next, more := store.PaginateOffset(allPeers, r.URL.Query().Get("cursor"), parseListLimit(r))
|
||||||
liveStates := s.liveBGPProtocolStates(r.Context())
|
liveStates := s.liveBGPProtocolStates(r.Context())
|
||||||
items := make([]map[string]any, 0, len(peers))
|
items := make([]map[string]any, 0, len(page))
|
||||||
for _, p := range peers {
|
for _, p := range page {
|
||||||
row := peerJSON(p)
|
row := peerJSON(p)
|
||||||
if st, ok := liveStates[peerProtocolNameForID(p.ID)]; ok && strings.TrimSpace(st) != "" {
|
if st, ok := liveStates[peerProtocolNameForID(p.ID)]; ok && strings.TrimSpace(st) != "" {
|
||||||
row["session_state"] = strings.TrimSpace(st)
|
row["session_state"] = strings.TrimSpace(st)
|
||||||
@@ -343,7 +282,7 @@ func (s *Server) handleListPeers(w http.ResponseWriter, r *http.Request) {
|
|||||||
items = append(items, row)
|
items = append(items, row)
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]any{
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
"items": items, "next_cursor": nil, "has_more": false,
|
"items": items, "next_cursor": strPtrOrNull(next), "has_more": more,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -451,7 +390,7 @@ func (s *Server) handleModuleRefresh(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeProblem(w, http.StatusNotFound, "Not Found", "module not found")
|
writeProblem(w, http.StatusNotFound, "Not Found", "module not found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeProblem(w, http.StatusInternalServerError, "Internal Error", err.Error())
|
writeInternalError(w, "internal", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
idem := r.Header.Get("Idempotency-Key")
|
idem := r.Header.Get("Idempotency-Key")
|
||||||
@@ -463,7 +402,7 @@ func (s *Server) handleModuleRefresh(w http.ResponseWriter, r *http.Request) {
|
|||||||
mid := mod.ID
|
mid := mod.ID
|
||||||
j, _, err := s.jobs.Enqueue(a.TenantID, jobs.KindModuleRefresh, idemPtr, &mid, map[string]any{"module_id": moduleID})
|
j, _, err := s.jobs.Enqueue(a.TenantID, jobs.KindModuleRefresh, idemPtr, &mid, map[string]any{"module_id": moduleID})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeProblem(w, http.StatusInternalServerError, "Internal Error", err.Error())
|
writeInternalError(w, "internal", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.Header().Set("Location", "/v1/jobs/"+j.ID)
|
w.Header().Set("Location", "/v1/jobs/"+j.ID)
|
||||||
@@ -512,7 +451,7 @@ func (s *Server) handleTenantRefresh(w http.ResponseWriter, r *http.Request) {
|
|||||||
"trigger": "api",
|
"trigger": "api",
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeProblem(w, http.StatusInternalServerError, "Internal Error", err.Error())
|
writeInternalError(w, "internal", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.Header().Set("Location", "/v1/jobs/"+j.ID)
|
w.Header().Set("Location", "/v1/jobs/"+j.ID)
|
||||||
@@ -570,13 +509,6 @@ func revisionJSON(rev *store.Revision) map[string]any {
|
|||||||
return m
|
return m
|
||||||
}
|
}
|
||||||
|
|
||||||
func strPtrOrNull(s string) any {
|
|
||||||
if s == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
|
|
||||||
// enqueueModuleRefreshIfEnabled queues module_refresh when the module exists and is enabled (best-effort, no HTTP error).
|
// enqueueModuleRefreshIfEnabled queues module_refresh when the module exists and is enabled (best-effort, no HTTP error).
|
||||||
func (s *Server) enqueueModuleRefreshIfEnabled(tenantID, moduleID, trigger string) {
|
func (s *Server) enqueueModuleRefreshIfEnabled(tenantID, moduleID, trigger string) {
|
||||||
if s.jobs == nil {
|
if s.jobs == nil {
|
||||||
@@ -672,225 +604,15 @@ func (s *Server) handleRevisionDiagnosticLog(w http.ResponseWriter, r *http.Requ
|
|||||||
writeProblem(w, http.StatusNotFound, "Not Found", "revision not found")
|
writeProblem(w, http.StatusNotFound, "Not Found", "revision not found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
logOut, err := reports.BuildRevisionDiagnosticLog(s.store, a.TenantID, rev)
|
||||||
moduleType := ""
|
if err != nil {
|
||||||
moduleName := ""
|
writeInternalError(w, "revision diagnostic log", err)
|
||||||
if strings.TrimSpace(rev.ModuleID) != "" {
|
return
|
||||||
if mod, modErr := s.store.GetModule(a.TenantID, rev.ModuleID); modErr == nil && mod != nil {
|
|
||||||
moduleType = strings.TrimSpace(mod.Type)
|
|
||||||
moduleName = strings.TrimSpace(mod.Name)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
communityLabels := map[string]string{}
|
|
||||||
if communities, listErr := s.store.ListCommunities(a.TenantID); listErr == nil {
|
|
||||||
for _, c := range communities {
|
|
||||||
if c == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
label := strings.TrimSpace(c.Title)
|
|
||||||
if label == "" {
|
|
||||||
label = strings.TrimSpace(c.Community)
|
|
||||||
}
|
|
||||||
if label == "" {
|
|
||||||
label = c.ID
|
|
||||||
}
|
|
||||||
communityLabels[c.ID] = label
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type rawRow struct {
|
|
||||||
Prefix string
|
|
||||||
Source string
|
|
||||||
Kind string
|
|
||||||
SourceName string
|
|
||||||
SourceDetail string
|
|
||||||
CommunityID string
|
|
||||||
CommunityLabel string
|
|
||||||
}
|
|
||||||
type summary struct {
|
|
||||||
Kind string
|
|
||||||
Source string
|
|
||||||
SourceDetail string
|
|
||||||
CommunityID string
|
|
||||||
CommunityLabel string
|
|
||||||
Count int
|
|
||||||
Sample []string
|
|
||||||
}
|
|
||||||
|
|
||||||
cdnSourceURLByID := map[string]string{}
|
|
||||||
if moduleType == "CDN_CIDRS" && strings.TrimSpace(rev.ModuleID) != "" {
|
|
||||||
if sources, srcErr := s.store.ListCDNSources(a.TenantID, rev.ModuleID); srcErr == nil {
|
|
||||||
for _, src := range sources {
|
|
||||||
if src == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
id := strings.TrimSpace(src.ID)
|
|
||||||
url := strings.TrimSpace(src.URL)
|
|
||||||
if id != "" && url != "" {
|
|
||||||
cdnSourceURLByID[id] = url
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
rows := make([]rawRow, 0, rev.MaterializedPrefixCount)
|
|
||||||
byGroup := map[string]*summary{}
|
|
||||||
cursor := ""
|
|
||||||
for {
|
|
||||||
page, next, more := s.store.ListRevisionPrefixes(a.TenantID, revID, cursor, 2000)
|
|
||||||
for _, p := range page {
|
|
||||||
kind, sourceName := classifyRevisionSource(p.Source)
|
|
||||||
sourceDetail := sourceName
|
|
||||||
if kind == "cdn" {
|
|
||||||
if url, ok := cdnSourceURLByID[sourceName]; ok && strings.TrimSpace(url) != "" {
|
|
||||||
sourceDetail = url
|
|
||||||
}
|
|
||||||
}
|
|
||||||
communityID := "none"
|
|
||||||
if p.CommunityID != nil && strings.TrimSpace(*p.CommunityID) != "" {
|
|
||||||
communityID = strings.TrimSpace(*p.CommunityID)
|
|
||||||
}
|
|
||||||
communityLabel := "без community"
|
|
||||||
if communityID != "none" {
|
|
||||||
if lbl, ok := communityLabels[communityID]; ok && strings.TrimSpace(lbl) != "" {
|
|
||||||
communityLabel = lbl
|
|
||||||
} else {
|
|
||||||
communityLabel = communityID
|
|
||||||
}
|
|
||||||
}
|
|
||||||
rows = append(rows, rawRow{
|
|
||||||
Prefix: p.Prefix,
|
|
||||||
Source: p.Source,
|
|
||||||
Kind: kind,
|
|
||||||
SourceName: sourceName,
|
|
||||||
SourceDetail: sourceDetail,
|
|
||||||
CommunityID: communityID,
|
|
||||||
CommunityLabel: communityLabel,
|
|
||||||
})
|
|
||||||
groupKey := kind + "|" + sourceName + "|" + communityID
|
|
||||||
g, ok := byGroup[groupKey]
|
|
||||||
if !ok {
|
|
||||||
g = &summary{
|
|
||||||
Kind: kind,
|
|
||||||
Source: sourceName,
|
|
||||||
SourceDetail: sourceDetail,
|
|
||||||
CommunityID: communityID,
|
|
||||||
CommunityLabel: communityLabel,
|
|
||||||
Sample: make([]string, 0, 5),
|
|
||||||
}
|
|
||||||
byGroup[groupKey] = g
|
|
||||||
}
|
|
||||||
g.Count++
|
|
||||||
if len(g.Sample) < 5 {
|
|
||||||
g.Sample = append(g.Sample, p.Prefix)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !more || strings.TrimSpace(next) == "" {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
cursor = next
|
|
||||||
}
|
|
||||||
|
|
||||||
summaryKeys := make([]string, 0, len(byGroup))
|
|
||||||
for k := range byGroup {
|
|
||||||
summaryKeys = append(summaryKeys, k)
|
|
||||||
}
|
|
||||||
sort.Strings(summaryKeys)
|
|
||||||
|
|
||||||
sort.Slice(rows, func(i, j int) bool {
|
|
||||||
if rows[i].Kind != rows[j].Kind {
|
|
||||||
return rows[i].Kind < rows[j].Kind
|
|
||||||
}
|
|
||||||
if rows[i].SourceName != rows[j].SourceName {
|
|
||||||
return rows[i].SourceName < rows[j].SourceName
|
|
||||||
}
|
|
||||||
if rows[i].CommunityID != rows[j].CommunityID {
|
|
||||||
return rows[i].CommunityID < rows[j].CommunityID
|
|
||||||
}
|
|
||||||
return rows[i].Prefix < rows[j].Prefix
|
|
||||||
})
|
|
||||||
|
|
||||||
var b strings.Builder
|
|
||||||
b.WriteString("EvoBGP revision diagnostic log\n")
|
|
||||||
b.WriteString("generated_at=" + time.Now().UTC().Format(time.RFC3339Nano) + "\n")
|
|
||||||
b.WriteString("tenant_id=" + a.TenantID + "\n")
|
|
||||||
b.WriteString("revision_id=" + rev.ID + "\n")
|
|
||||||
b.WriteString("revision_created_at=" + rev.CreatedAt.UTC().Format(time.RFC3339Nano) + "\n")
|
|
||||||
b.WriteString("content_hash=" + rev.ContentHash + "\n")
|
|
||||||
b.WriteString("materialized_prefix_count=" + strconv.Itoa(rev.MaterializedPrefixCount) + "\n")
|
|
||||||
if rev.ModuleID != "" {
|
|
||||||
b.WriteString("module_id=" + rev.ModuleID + "\n")
|
|
||||||
} else {
|
|
||||||
b.WriteString("module_id=\n")
|
|
||||||
}
|
|
||||||
b.WriteString("module_type=" + moduleType + "\n")
|
|
||||||
b.WriteString("module_name=" + moduleName + "\n")
|
|
||||||
b.WriteString("fetched_rows=" + strconv.Itoa(len(rows)) + "\n\n")
|
|
||||||
|
|
||||||
b.WriteString("## Aggregation by source and community\n")
|
|
||||||
for _, k := range summaryKeys {
|
|
||||||
g := byGroup[k]
|
|
||||||
b.WriteString("- kind=" + g.Kind +
|
|
||||||
" source=" + g.Source +
|
|
||||||
" source_detail=" + g.SourceDetail +
|
|
||||||
" community_id=" + g.CommunityID +
|
|
||||||
" community_label=" + g.CommunityLabel +
|
|
||||||
" count=" + strconv.Itoa(g.Count))
|
|
||||||
if len(g.Sample) > 0 {
|
|
||||||
b.WriteString(" sample=" + strings.Join(g.Sample, ","))
|
|
||||||
}
|
|
||||||
b.WriteByte('\n')
|
|
||||||
}
|
|
||||||
|
|
||||||
b.WriteString("\n## Raw rows\n")
|
|
||||||
b.WriteString("prefix\tkind\tsource\tsource_detail\tcommunity_id\tcommunity_label\n")
|
|
||||||
for _, row := range rows {
|
|
||||||
b.WriteString(row.Prefix)
|
|
||||||
b.WriteByte('\t')
|
|
||||||
b.WriteString(row.Kind)
|
|
||||||
b.WriteByte('\t')
|
|
||||||
b.WriteString(row.Source)
|
|
||||||
b.WriteByte('\t')
|
|
||||||
b.WriteString(strings.ReplaceAll(row.SourceDetail, "\t", " "))
|
|
||||||
b.WriteByte('\t')
|
|
||||||
b.WriteString(row.CommunityID)
|
|
||||||
b.WriteByte('\t')
|
|
||||||
b.WriteString(strings.ReplaceAll(row.CommunityLabel, "\t", " "))
|
|
||||||
b.WriteByte('\n')
|
|
||||||
}
|
|
||||||
|
|
||||||
filename := "revision-" + shortRevisionID(rev.ID) + "-diagnostic.log"
|
|
||||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||||
w.Header().Set("Content-Disposition", `attachment; filename="`+filename+`"`)
|
w.Header().Set("Content-Disposition", `attachment; filename="`+logOut.Filename+`"`)
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
_, _ = w.Write([]byte(b.String()))
|
_, _ = w.Write(logOut.Body)
|
||||||
}
|
|
||||||
|
|
||||||
func classifyRevisionSource(src string) (kind, sourceName string) {
|
|
||||||
switch {
|
|
||||||
case strings.HasPrefix(src, "as:"):
|
|
||||||
return "asn", strings.TrimPrefix(src, "as:")
|
|
||||||
case strings.HasPrefix(src, "domain:"):
|
|
||||||
return "domain", strings.TrimPrefix(src, "domain:")
|
|
||||||
case strings.HasPrefix(src, "cdn:"):
|
|
||||||
return "cdn", strings.TrimPrefix(src, "cdn:")
|
|
||||||
case src == "ip_range":
|
|
||||||
return "ip_range", "manual_ranges"
|
|
||||||
default:
|
|
||||||
if strings.TrimSpace(src) == "" {
|
|
||||||
return "unknown", "unknown"
|
|
||||||
}
|
|
||||||
return "source", strings.TrimSpace(src)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func shortRevisionID(id string) string {
|
|
||||||
s := strings.TrimSpace(id)
|
|
||||||
if len(s) <= 8 {
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
return s[:8]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleRevisionDiff(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleRevisionDiff(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -916,7 +638,7 @@ func (s *Server) handleRevisionRollback(w http.ResponseWriter, r *http.Request)
|
|||||||
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
|
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !s.requireAtLeast(w, a, "editor") {
|
if !s.requireAtLeast(w, a, "operator") {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
revID := r.PathValue("revision_id")
|
revID := r.PathValue("revision_id")
|
||||||
@@ -933,7 +655,7 @@ func (s *Server) handleRevisionRollback(w http.ResponseWriter, r *http.Request)
|
|||||||
"source_revision_id": revID,
|
"source_revision_id": revID,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeProblem(w, http.StatusInternalServerError, "Internal Error", err.Error())
|
writeInternalError(w, "internal", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.Header().Set("Location", "/v1/jobs/"+j.ID)
|
w.Header().Set("Location", "/v1/jobs/"+j.ID)
|
||||||
@@ -980,7 +702,7 @@ func (s *Server) handleApply(w http.ResponseWriter, r *http.Request) {
|
|||||||
"strategy": body.Strategy,
|
"strategy": body.Strategy,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeProblem(w, http.StatusInternalServerError, "Internal Error", err.Error())
|
writeInternalError(w, "internal", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.Header().Set("Location", "/v1/jobs/"+j.ID)
|
w.Header().Set("Location", "/v1/jobs/"+j.ID)
|
||||||
@@ -1031,7 +753,7 @@ func (s *Server) handleSpeakerApply(w http.ResponseWriter, r *http.Request) {
|
|||||||
"speaker_id": spkID,
|
"speaker_id": spkID,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeProblem(w, http.StatusInternalServerError, "Internal Error", err.Error())
|
writeInternalError(w, "internal", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.Header().Set("Location", "/v1/jobs/"+j.ID)
|
w.Header().Set("Location", "/v1/jobs/"+j.ID)
|
||||||
@@ -1056,7 +778,7 @@ func (s *Server) handleBirdReload(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
j, _, err := s.jobs.Enqueue(a.TenantID, jobs.KindBirdReload, idemPtr, nil, nil)
|
j, _, err := s.jobs.Enqueue(a.TenantID, jobs.KindBirdReload, idemPtr, nil, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeProblem(w, http.StatusInternalServerError, "Internal Error", err.Error())
|
writeInternalError(w, "internal", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.Header().Set("Location", "/v1/jobs/"+j.ID)
|
w.Header().Set("Location", "/v1/jobs/"+j.ID)
|
||||||
@@ -1191,7 +913,7 @@ func (s *Server) handleNodeBundle(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
tgz, err := bundle.BuildGzippedTar(rid, sid, rev.PreviewFragments, s.bundlePriv)
|
tgz, err := bundle.BuildGzippedTar(rid, sid, rev.PreviewFragments, s.bundlePriv)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeProblem(w, http.StatusInternalServerError, "Internal Error", err.Error())
|
writeInternalError(w, "internal", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.Header().Set("Content-Type", "application/gzip")
|
w.Header().Set("Content-Type", "application/gzip")
|
||||||
@@ -1241,7 +963,7 @@ func (s *Server) handleNodeEnroll(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
patch := &store.SpeakerPatch{MetaJSON: &meta}
|
patch := &store.SpeakerPatch{MetaJSON: &meta}
|
||||||
if _, err := s.store.UpdateSpeaker(a.TenantID, sid, patch); err != nil {
|
if _, err := s.store.UpdateSpeaker(a.TenantID, sid, patch); err != nil {
|
||||||
writeProblem(w, http.StatusInternalServerError, "Internal Error", err.Error())
|
writeInternalError(w, "internal", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]any{
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
|||||||
+32
-152
@@ -3,6 +3,7 @@ package httpapi
|
|||||||
import (
|
import (
|
||||||
"encoding/csv"
|
"encoding/csv"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -10,6 +11,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"evobgp/internal/importer"
|
||||||
"evobgp/internal/pipeline"
|
"evobgp/internal/pipeline"
|
||||||
"evobgp/internal/store"
|
"evobgp/internal/store"
|
||||||
)
|
)
|
||||||
@@ -178,7 +180,7 @@ func writeStoreErr(w http.ResponseWriter, err error) {
|
|||||||
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", err.Error())
|
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeProblem(w, http.StatusInternalServerError, "Internal Error", err.Error())
|
writeInternalError(w, "store", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleListCDNSources(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleListCDNSources(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -251,20 +253,20 @@ func (s *Server) handlePreviewCDNSource(w http.ResponseWriter, r *http.Request)
|
|||||||
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", "invalid url")
|
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", "invalid url")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
resp, err := http.DefaultClient.Do(req)
|
resp, err := s.cdnHTTP.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeProblem(w, http.StatusBadGateway, "Bad Gateway", err.Error())
|
writeBadGateway(w, "cdn preview fetch", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
_, _ = io.Copy(io.Discard, resp.Body)
|
_, _ = io.Copy(io.Discard, resp.Body)
|
||||||
writeProblem(w, http.StatusBadGateway, "Bad Gateway", fmt.Sprintf("upstream status: %s", resp.Status))
|
writeBadGateway(w, "cdn preview fetch", fmt.Errorf("upstream status: %s", resp.Status))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
raw, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
|
raw, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeProblem(w, http.StatusBadGateway, "Bad Gateway", err.Error())
|
writeBadGateway(w, "cdn preview read body", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
pfxs, err := pipeline.ExtractCIDRs(string(raw), body.SourceKind, body.PrefixPath)
|
pfxs, err := pipeline.ExtractCIDRs(string(raw), body.SourceKind, body.PrefixPath)
|
||||||
@@ -689,163 +691,41 @@ func (s *Server) handleImportModuleEntriesCSV(w http.ResponseWriter, r *http.Req
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
moduleID := r.PathValue("module_id")
|
moduleID := r.PathValue("module_id")
|
||||||
mod, err := s.store.GetModule(a.TenantID, moduleID)
|
res, err := importer.ImportModuleEntriesCSV(s.store, a.TenantID, moduleID, r.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if errors.Is(err, store.ErrInvalidInput) {
|
||||||
|
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", "csv is empty")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.Contains(err.Error(), "importer: invalid csv") {
|
||||||
|
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid csv")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.Contains(err.Error(), "importer: line") {
|
||||||
|
detail := strings.TrimPrefix(err.Error(), "importer: ")
|
||||||
|
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", detail)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.Contains(err.Error(), "importer: csv import/export") {
|
||||||
|
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", "csv import/export is supported only for AS_PREFIXES, DOMAINS, IP_RANGES")
|
||||||
|
return
|
||||||
|
}
|
||||||
writeStoreErr(w, err)
|
writeStoreErr(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if res.Imported > 0 {
|
||||||
cr := csv.NewReader(io.LimitReader(r.Body, 8<<20))
|
switch res.ModuleType {
|
||||||
cr.TrimLeadingSpace = true
|
case "AS_PREFIXES":
|
||||||
cr.FieldsPerRecord = -1
|
|
||||||
rows, err := cr.ReadAll()
|
|
||||||
if err != nil {
|
|
||||||
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid csv")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if len(rows) == 0 {
|
|
||||||
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", "csv is empty")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
communities, err := s.store.ListCommunities(a.TenantID)
|
|
||||||
if err != nil {
|
|
||||||
writeStoreErr(w, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
communityIDByID := make(map[string]string, len(communities))
|
|
||||||
communityIDByValue := make(map[string]string, len(communities))
|
|
||||||
for _, c := range communities {
|
|
||||||
communityIDByID[c.ID] = c.ID
|
|
||||||
communityIDByValue[strings.TrimSpace(c.Community)] = c.ID
|
|
||||||
}
|
|
||||||
|
|
||||||
resolveCommunity := func(raw string, required bool) (*string, error) {
|
|
||||||
v := strings.TrimSpace(raw)
|
|
||||||
if v == "" {
|
|
||||||
if required {
|
|
||||||
return nil, fmt.Errorf("community is required")
|
|
||||||
}
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
if id, ok := communityIDByID[v]; ok {
|
|
||||||
return &id, nil
|
|
||||||
}
|
|
||||||
if id, ok := communityIDByValue[v]; ok {
|
|
||||||
return &id, nil
|
|
||||||
}
|
|
||||||
return nil, fmt.Errorf("unknown community %q", v)
|
|
||||||
}
|
|
||||||
|
|
||||||
start := 0
|
|
||||||
if len(rows[0]) >= 2 {
|
|
||||||
key := strings.ToLower(strings.TrimSpace(rows[0][0]))
|
|
||||||
switch key {
|
|
||||||
case "asn", "domain", "iprange":
|
|
||||||
start = 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
imported := 0
|
|
||||||
switch mod.Type {
|
|
||||||
case "AS_PREFIXES":
|
|
||||||
for i := start; i < len(rows); i++ {
|
|
||||||
rec := rows[i]
|
|
||||||
if len(rec) == 0 || (strings.TrimSpace(rec[0]) == "" && (len(rec) < 2 || strings.TrimSpace(rec[1]) == "")) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if len(rec) < 2 {
|
|
||||||
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", fmt.Sprintf("line %d: expected 2 columns", i+1))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
asn, err := strconv.ParseInt(strings.TrimSpace(rec[0]), 10, 64)
|
|
||||||
if err != nil || asn <= 0 {
|
|
||||||
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", fmt.Sprintf("line %d: invalid asn", i+1))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
cid, err := resolveCommunity(rec[1], false)
|
|
||||||
if err != nil {
|
|
||||||
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", fmt.Sprintf("line %d: %v", i+1, err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
_, err = s.store.CreateASEntry(a.TenantID, moduleID, &store.ASEntry{ASN: asn, CommunityID: cid})
|
|
||||||
if err != nil {
|
|
||||||
writeStoreErr(w, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
imported++
|
|
||||||
}
|
|
||||||
if imported > 0 {
|
|
||||||
s.enqueueModuleRefreshIfEnabled(a.TenantID, moduleID, "as_entry_import_csv")
|
s.enqueueModuleRefreshIfEnabled(a.TenantID, moduleID, "as_entry_import_csv")
|
||||||
}
|
case "DOMAINS":
|
||||||
case "DOMAINS":
|
|
||||||
for i := start; i < len(rows); i++ {
|
|
||||||
rec := rows[i]
|
|
||||||
if len(rec) == 0 || (strings.TrimSpace(rec[0]) == "" && (len(rec) < 2 || strings.TrimSpace(rec[1]) == "")) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if len(rec) < 2 {
|
|
||||||
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", fmt.Sprintf("line %d: expected 2 columns", i+1))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
fqdn := strings.TrimSpace(rec[0])
|
|
||||||
if fqdn == "" {
|
|
||||||
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", fmt.Sprintf("line %d: domain is required", i+1))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
cid, err := resolveCommunity(rec[1], false)
|
|
||||||
if err != nil {
|
|
||||||
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", fmt.Sprintf("line %d: %v", i+1, err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
_, err = s.store.CreateDomainEntry(a.TenantID, moduleID, &store.DomainEntry{FQDN: fqdn, CommunityID: cid})
|
|
||||||
if err != nil {
|
|
||||||
writeStoreErr(w, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
imported++
|
|
||||||
}
|
|
||||||
if imported > 0 {
|
|
||||||
s.enqueueModuleRefreshIfEnabled(a.TenantID, moduleID, "domain_entry_import_csv")
|
s.enqueueModuleRefreshIfEnabled(a.TenantID, moduleID, "domain_entry_import_csv")
|
||||||
}
|
case "IP_RANGES":
|
||||||
case "IP_RANGES":
|
|
||||||
for i := start; i < len(rows); i++ {
|
|
||||||
rec := rows[i]
|
|
||||||
if len(rec) == 0 || (strings.TrimSpace(rec[0]) == "" && (len(rec) < 2 || strings.TrimSpace(rec[1]) == "")) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if len(rec) < 2 {
|
|
||||||
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", fmt.Sprintf("line %d: expected 2 columns", i+1))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
prefix := strings.TrimSpace(rec[0])
|
|
||||||
if prefix == "" {
|
|
||||||
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", fmt.Sprintf("line %d: ipRange is required", i+1))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
cid, err := resolveCommunity(rec[1], true)
|
|
||||||
if err != nil {
|
|
||||||
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", fmt.Sprintf("line %d: %v", i+1, err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
_, err = s.store.CreateIPRangeEntry(a.TenantID, moduleID, &store.IPRangeEntry{Prefix: prefix, CommunityID: cid})
|
|
||||||
if err != nil {
|
|
||||||
writeStoreErr(w, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
imported++
|
|
||||||
}
|
|
||||||
if imported > 0 {
|
|
||||||
s.enqueueModuleRefreshIfEnabled(a.TenantID, moduleID, "ip_range_import_csv")
|
s.enqueueModuleRefreshIfEnabled(a.TenantID, moduleID, "ip_range_import_csv")
|
||||||
}
|
}
|
||||||
default:
|
|
||||||
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", "csv import/export is supported only for AS_PREFIXES, DOMAINS, IP_RANGES")
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
writeJSON(w, http.StatusOK, map[string]any{
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
"imported": imported,
|
"imported": res.Imported,
|
||||||
"module_type": mod.Type,
|
"module_type": res.ModuleType,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ type Server struct {
|
|||||||
apiKeys []apiKeyRecord
|
apiKeys []apiKeyRecord
|
||||||
insecureDev bool
|
insecureDev bool
|
||||||
corsOrigins []string
|
corsOrigins []string
|
||||||
|
cdnHTTP *http.Client
|
||||||
mux *http.ServeMux
|
mux *http.ServeMux
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,6 +68,7 @@ func New(opts Options) (*Server, error) {
|
|||||||
apiKeys: parseAPIKeysSpec(opts.APIKeys),
|
apiKeys: parseAPIKeysSpec(opts.APIKeys),
|
||||||
insecureDev: opts.InsecureDev && opts.SeedDemo,
|
insecureDev: opts.InsecureDev && opts.SeedDemo,
|
||||||
corsOrigins: parseCORSOrigins(opts.CORSAllowedOrigins),
|
corsOrigins: parseCORSOrigins(opts.CORSAllowedOrigins),
|
||||||
|
cdnHTTP: NewCDNHTTPClient(),
|
||||||
}
|
}
|
||||||
s.mux = http.NewServeMux()
|
s.mux = http.NewServeMux()
|
||||||
s.registerRoutes()
|
s.registerRoutes()
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
|
|
||||||
"evobgp/internal/jobs"
|
"evobgp/internal/jobs"
|
||||||
"evobgp/internal/signing"
|
"evobgp/internal/signing"
|
||||||
|
"evobgp/internal/version"
|
||||||
)
|
)
|
||||||
|
|
||||||
const testBundleSeed = "0101010101010101010101010101010101010101010101010101010101010101"
|
const testBundleSeed = "0101010101010101010101010101010101010101010101010101010101010101"
|
||||||
@@ -28,7 +29,7 @@ func TestAPIRefreshApplyJobsBundle(t *testing.T) {
|
|||||||
}
|
}
|
||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
tenant, modCDN, modIP, rev, speaker := srv.Store().DemoIDs()
|
tenant, modCDN, modIP, rev, speaker := srv.Store().DemoIDs()
|
||||||
srv.apiKeys = parseAPIKeysSpec("nodekey|" + tenant + "|node,opkey|" + tenant + "|operator")
|
srv.apiKeys = parseAPIKeysSpec("nodekey|" + tenant + "|node,opkey|" + tenant + "|operator,edkey|" + tenant + "|editor")
|
||||||
|
|
||||||
ts := httptest.NewServer(srv.Handler())
|
ts := httptest.NewServer(srv.Handler())
|
||||||
defer ts.Close()
|
defer ts.Close()
|
||||||
@@ -247,6 +248,20 @@ func TestAPIRefreshApplyJobsBundle(t *testing.T) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
t.Run("rollback forbidden for editor", func(t *testing.T) {
|
||||||
|
req, _ := http.NewRequest(http.MethodPost, base+"/v1/revisions/"+rev+"/rollback", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer edkey")
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusForbidden {
|
||||||
|
b, _ := io.ReadAll(resp.Body)
|
||||||
|
t.Fatalf("status %d want 403: %s", resp.StatusCode, b)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
t.Run("rollback queues job", func(t *testing.T) {
|
t.Run("rollback queues job", func(t *testing.T) {
|
||||||
req, _ := http.NewRequest(http.MethodPost, base+"/v1/revisions/"+rev+"/rollback", nil)
|
req, _ := http.NewRequest(http.MethodPost, base+"/v1/revisions/"+rev+"/rollback", nil)
|
||||||
req.Header.Set("Authorization", "Bearer opkey")
|
req.Header.Set("Authorization", "Bearer opkey")
|
||||||
@@ -357,3 +372,60 @@ evobgp_p_01234567 BGP master start 2026-04-09 Connect
|
|||||||
t.Fatalf("expected protocol state Connect, got %q", got["evobgp_p_01234567"])
|
t.Fatalf("expected protocol state Connect, got %q", got["evobgp_p_01234567"])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestVersionEndpoints(t *testing.T) {
|
||||||
|
srv, err := New(Options{
|
||||||
|
InsecureDev: true,
|
||||||
|
SeedDemo: true,
|
||||||
|
BundleSeedHex: testBundleSeed,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
oldVer := version.Version
|
||||||
|
oldSHA := version.GitSHA
|
||||||
|
oldTime := version.BuildTime
|
||||||
|
version.Version = "1.2.3"
|
||||||
|
version.GitSHA = "abc123def456"
|
||||||
|
version.BuildTime = "2026-05-20T12:00:00Z"
|
||||||
|
t.Cleanup(func() {
|
||||||
|
version.Version = oldVer
|
||||||
|
version.GitSHA = oldSHA
|
||||||
|
version.BuildTime = oldTime
|
||||||
|
})
|
||||||
|
|
||||||
|
ts := httptest.NewServer(srv.Handler())
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
for _, path := range []string{"/version", "/v1/version"} {
|
||||||
|
t.Run(path, func(t *testing.T) {
|
||||||
|
resp, err := http.Get(ts.URL + path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
b, _ := io.ReadAll(resp.Body)
|
||||||
|
t.Fatalf("status %d: %s", resp.StatusCode, b)
|
||||||
|
}
|
||||||
|
var body map[string]string
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if body["version"] != "1.2.3" {
|
||||||
|
t.Fatalf("version=%q want 1.2.3", body["version"])
|
||||||
|
}
|
||||||
|
if body["api_version"] != "1.2.3" {
|
||||||
|
t.Fatalf("api_version=%q want 1.2.3", body["api_version"])
|
||||||
|
}
|
||||||
|
if body["git_sha"] != "abc123def456" {
|
||||||
|
t.Fatalf("git_sha=%q", body["git_sha"])
|
||||||
|
}
|
||||||
|
if body["build_time"] != "2026-05-20T12:00:00Z" {
|
||||||
|
t.Fatalf("build_time=%q", body["build_time"])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
package importer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/csv"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"evobgp/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Result of importing module entries from CSV.
|
||||||
|
type Result struct {
|
||||||
|
Imported int
|
||||||
|
ModuleType string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ImportModuleEntriesCSV parses and creates entries for AS_PREFIXES, DOMAINS, or IP_RANGES modules.
|
||||||
|
func ImportModuleEntriesCSV(st store.Backend, tenantID, moduleID string, body io.Reader) (*Result, error) {
|
||||||
|
mod, err := st.GetModule(tenantID, moduleID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
cr := csv.NewReader(io.LimitReader(body, 8<<20))
|
||||||
|
cr.TrimLeadingSpace = true
|
||||||
|
cr.FieldsPerRecord = -1
|
||||||
|
rows, err := cr.ReadAll()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("importer: invalid csv: %w", err)
|
||||||
|
}
|
||||||
|
if len(rows) == 0 {
|
||||||
|
return nil, fmt.Errorf("importer: %w", store.ErrInvalidInput)
|
||||||
|
}
|
||||||
|
|
||||||
|
communities, err := st.ListCommunities(tenantID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
communityIDByID := make(map[string]string, len(communities))
|
||||||
|
communityIDByValue := make(map[string]string, len(communities))
|
||||||
|
for _, c := range communities {
|
||||||
|
communityIDByID[c.ID] = c.ID
|
||||||
|
communityIDByValue[strings.TrimSpace(c.Community)] = c.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
resolveCommunity := func(raw string, required bool) (*string, error) {
|
||||||
|
v := strings.TrimSpace(raw)
|
||||||
|
if v == "" {
|
||||||
|
if required {
|
||||||
|
return nil, fmt.Errorf("community is required")
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if id, ok := communityIDByID[v]; ok {
|
||||||
|
return &id, nil
|
||||||
|
}
|
||||||
|
if id, ok := communityIDByValue[v]; ok {
|
||||||
|
return &id, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("unknown community %q", v)
|
||||||
|
}
|
||||||
|
|
||||||
|
start := 0
|
||||||
|
if len(rows[0]) >= 2 {
|
||||||
|
key := strings.ToLower(strings.TrimSpace(rows[0][0]))
|
||||||
|
switch key {
|
||||||
|
case "asn", "domain", "iprange":
|
||||||
|
start = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
imported := 0
|
||||||
|
switch mod.Type {
|
||||||
|
case "AS_PREFIXES":
|
||||||
|
for i := start; i < len(rows); i++ {
|
||||||
|
rec := rows[i]
|
||||||
|
if len(rec) == 0 || (strings.TrimSpace(rec[0]) == "" && (len(rec) < 2 || strings.TrimSpace(rec[1]) == "")) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(rec) < 2 {
|
||||||
|
return nil, fmt.Errorf("importer: line %d: expected 2 columns", i+1)
|
||||||
|
}
|
||||||
|
asn, err := strconv.ParseInt(strings.TrimSpace(rec[0]), 10, 64)
|
||||||
|
if err != nil || asn <= 0 {
|
||||||
|
return nil, fmt.Errorf("importer: line %d: invalid asn", i+1)
|
||||||
|
}
|
||||||
|
cid, err := resolveCommunity(rec[1], false)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("importer: line %d: %w", i+1, err)
|
||||||
|
}
|
||||||
|
if _, err := st.CreateASEntry(tenantID, moduleID, &store.ASEntry{ASN: asn, CommunityID: cid}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
imported++
|
||||||
|
}
|
||||||
|
case "DOMAINS":
|
||||||
|
for i := start; i < len(rows); i++ {
|
||||||
|
rec := rows[i]
|
||||||
|
if len(rec) == 0 || (strings.TrimSpace(rec[0]) == "" && (len(rec) < 2 || strings.TrimSpace(rec[1]) == "")) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(rec) < 2 {
|
||||||
|
return nil, fmt.Errorf("importer: line %d: expected 2 columns", i+1)
|
||||||
|
}
|
||||||
|
fqdn := strings.TrimSpace(rec[0])
|
||||||
|
if fqdn == "" {
|
||||||
|
return nil, fmt.Errorf("importer: line %d: domain is required", i+1)
|
||||||
|
}
|
||||||
|
cid, err := resolveCommunity(rec[1], false)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("importer: line %d: %w", i+1, err)
|
||||||
|
}
|
||||||
|
if _, err := st.CreateDomainEntry(tenantID, moduleID, &store.DomainEntry{FQDN: fqdn, CommunityID: cid}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
imported++
|
||||||
|
}
|
||||||
|
case "IP_RANGES":
|
||||||
|
for i := start; i < len(rows); i++ {
|
||||||
|
rec := rows[i]
|
||||||
|
if len(rec) == 0 || (strings.TrimSpace(rec[0]) == "" && (len(rec) < 2 || strings.TrimSpace(rec[1]) == "")) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(rec) < 2 {
|
||||||
|
return nil, fmt.Errorf("importer: line %d: expected 2 columns", i+1)
|
||||||
|
}
|
||||||
|
prefix := strings.TrimSpace(rec[0])
|
||||||
|
if prefix == "" {
|
||||||
|
return nil, fmt.Errorf("importer: line %d: ipRange is required", i+1)
|
||||||
|
}
|
||||||
|
cid, err := resolveCommunity(rec[1], true)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("importer: line %d: %w", i+1, err)
|
||||||
|
}
|
||||||
|
if _, err := st.CreateIPRangeEntry(tenantID, moduleID, &store.IPRangeEntry{Prefix: prefix, CommunityID: cid}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
imported++
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("importer: csv import/export is supported only for AS_PREFIXES, DOMAINS, IP_RANGES")
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Result{Imported: imported, ModuleType: mod.Type}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
package nodecli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/ed25519"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"evobgp/internal/birdfmt"
|
||||||
|
"evobgp/internal/bundle"
|
||||||
|
"evobgp/internal/signing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CmdPullBundle downloads a signed bundle from the control plane API.
|
||||||
|
func CmdPullBundle(args []string) int {
|
||||||
|
fs := flag.NewFlagSet("pull-bundle", flag.ExitOnError)
|
||||||
|
base := fs.String("base-url", "", "control plane base URL, e.g. http://localhost:8080")
|
||||||
|
token := fs.String("token", "", "Bearer token (node role)")
|
||||||
|
speaker := fs.String("speaker-id", "", "bgp_speaker id")
|
||||||
|
revision := fs.String("revision-id", "", "revision to fetch (empty = latest pointer)")
|
||||||
|
out := fs.String("o", "bundle.tar.gz", "output file")
|
||||||
|
_ = fs.Parse(args)
|
||||||
|
|
||||||
|
if strings.TrimSpace(*base) == "" || *token == "" || *speaker == "" {
|
||||||
|
fmt.Fprintln(os.Stderr, "pull-bundle: -base-url, -token, -speaker-id are required")
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
rev := strings.TrimSpace(*revision)
|
||||||
|
if rev == "" {
|
||||||
|
var err error
|
||||||
|
rev, err = fetchLatestRevision(*base, *token, *speaker)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
body, err := fetchBundle(*base, *token, *speaker, rev)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(*out, body, 0o644); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
fmt.Fprintf(os.Stderr, "wrote %s (revision %s)\n", *out, rev)
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchLatestRevision(base, token, speaker string) (string, error) {
|
||||||
|
u := strings.TrimRight(base, "/") + "/v1/speakers/" + speaker + "/revisions/latest"
|
||||||
|
req, err := http.NewRequest(http.MethodGet, u, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
b, _ := io.ReadAll(resp.Body)
|
||||||
|
return "", fmt.Errorf("latest revision: %s: %s", resp.Status, strings.TrimSpace(string(b)))
|
||||||
|
}
|
||||||
|
var out struct {
|
||||||
|
RevisionID string `json:"revision_id"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if out.RevisionID == "" {
|
||||||
|
return "", fmt.Errorf("empty revision_id in response")
|
||||||
|
}
|
||||||
|
return out.RevisionID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchBundle(base, token, speaker, revision string) ([]byte, error) {
|
||||||
|
u := strings.TrimRight(base, "/") + "/v1/speakers/" + speaker + "/bundle/" + revision
|
||||||
|
req, err := http.NewRequest(http.MethodGet, u, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
b, _ := io.ReadAll(resp.Body)
|
||||||
|
return nil, fmt.Errorf("bundle: %s: %s", resp.Status, strings.TrimSpace(string(b)))
|
||||||
|
}
|
||||||
|
return io.ReadAll(resp.Body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadPubKey(pubB64, pubHex string) (ed25519.PublicKey, error) {
|
||||||
|
switch {
|
||||||
|
case strings.TrimSpace(pubB64) != "":
|
||||||
|
raw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(pubB64))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(raw) != ed25519.PublicKeySize {
|
||||||
|
return nil, fmt.Errorf("pubkey-base64 must decode to %d bytes", ed25519.PublicKeySize)
|
||||||
|
}
|
||||||
|
return ed25519.PublicKey(raw), nil
|
||||||
|
case strings.TrimSpace(pubHex) != "":
|
||||||
|
return bundle.ParsePublicKeyHex(pubHex)
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("public key required")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CmdVerifyBundle checks bundle signature and manifest.
|
||||||
|
func CmdVerifyBundle(args []string) int {
|
||||||
|
fs := flag.NewFlagSet("verify-bundle", flag.ExitOnError)
|
||||||
|
path := fs.String("f", "", "path to bundle.tar.gz")
|
||||||
|
pubB64 := fs.String("pubkey-base64", "", "Ed25519 public key (base64)")
|
||||||
|
pubHex := fs.String("pubkey-hex", "", "Ed25519 public key (64 hex chars)")
|
||||||
|
_ = fs.Parse(args)
|
||||||
|
|
||||||
|
if *path == "" {
|
||||||
|
fmt.Fprintln(os.Stderr, "verify-bundle: -f required")
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
pub, err := loadPubKey(*pubB64, *pubHex)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
raw, err := os.ReadFile(*path)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
v, err := signing.VerifyGzippedTar(raw, pub)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
fmt.Fprintf(os.Stderr, "ok: revision %s, %d payload files\n", v.Manifest.RevisionID, len(v.Files))
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// CmdApplyBundle verifies, extracts, parse-checks, and configures BIRD from a bundle.
|
||||||
|
func CmdApplyBundle(args []string) int {
|
||||||
|
fs := flag.NewFlagSet("apply-bundle", flag.ExitOnError)
|
||||||
|
path := fs.String("f", "", "path to bundle.tar.gz")
|
||||||
|
dir := fs.String("extract-dir", "", "directory to extract into")
|
||||||
|
pubB64 := fs.String("pubkey-base64", "", "Ed25519 public key (base64)")
|
||||||
|
pubHex := fs.String("pubkey-hex", "", "Ed25519 public key (64 hex chars)")
|
||||||
|
bird := fs.String("bird", "", "bird binary (default PATH)")
|
||||||
|
birdc := fs.String("birdc", "", "birdc binary (default PATH)")
|
||||||
|
socket := fs.String("socket", "", "birdc -s socket path")
|
||||||
|
timeout := fs.Duration("timeout", 30*time.Second, "timeout for bird/birdc")
|
||||||
|
_ = fs.Parse(args)
|
||||||
|
|
||||||
|
if *path == "" || *dir == "" {
|
||||||
|
fmt.Fprintln(os.Stderr, "apply-bundle: -f and -extract-dir required")
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
pub, err := loadPubKey(*pubB64, *pubHex)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
raw, err := os.ReadFile(*path)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
v, err := signing.VerifyGzippedTar(raw, pub)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
root := filepath.Clean(*dir)
|
||||||
|
if err := os.MkdirAll(root, 0o755); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
if err := bundle.WriteExtractedFiles(root, v); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
mainRel := v.FindMainBirdConf()
|
||||||
|
if mainRel == "" {
|
||||||
|
fmt.Fprintln(os.Stderr, "bundle has no bird.conf path in manifest")
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
mainPath := filepath.Join(root, filepath.FromSlash(strings.TrimPrefix(mainRel, "/")))
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), *timeout)
|
||||||
|
defer cancel()
|
||||||
|
ctl := &birdfmt.BirdCtl{Bird: *bird, Birdc: *birdc, Socket: *socket}
|
||||||
|
if err := ctl.ParseCheck(ctx, mainPath); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
if err := ctl.Configure(ctx); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
fmt.Fprintf(os.Stderr, "applied revision %s (main config %s)\n", v.Manifest.RevisionID, mainPath)
|
||||||
|
return 0
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
// Package nodecli implements evobgp-node subcommands (pull/verify/apply bundle).
|
||||||
|
package nodecli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Run executes a subcommand from args (without program name). Returns exit code.
|
||||||
|
func Run(args []string) int {
|
||||||
|
if len(args) < 1 {
|
||||||
|
Usage(os.Stderr)
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
switch args[0] {
|
||||||
|
case "pull-bundle":
|
||||||
|
return CmdPullBundle(args[1:])
|
||||||
|
case "verify-bundle":
|
||||||
|
return CmdVerifyBundle(args[1:])
|
||||||
|
case "apply-bundle":
|
||||||
|
return CmdApplyBundle(args[1:])
|
||||||
|
default:
|
||||||
|
Usage(os.Stderr)
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usage prints CLI help to w.
|
||||||
|
func Usage(w interface{ Write([]byte) (int, error) }) {
|
||||||
|
fmt.Fprintf(w, `Usage:
|
||||||
|
evobgp-node pull-bundle -base-url URL -token TOKEN -speaker-id ID [-revision-id ID] [-o path]
|
||||||
|
evobgp-node verify-bundle -f bundle.tar.gz (-pubkey-base64 B64 | -pubkey-hex HEX)
|
||||||
|
evobgp-node apply-bundle -f bundle.tar.gz -extract-dir DIR (-pubkey-base64 B64 | -pubkey-hex HEX)
|
||||||
|
[-bird PATH] [-birdc PATH] [-socket PATH] [-timeout DURATION]
|
||||||
|
|
||||||
|
apply-bundle verifies, extracts, runs bird -p on main bird.conf, then birdc configure.
|
||||||
|
`)
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
// Package pipeline ingests module inputs (CDN, ASN, domains, IP ranges), materializes
|
||||||
|
// prefixes, and creates BIRD preview revisions via store.
|
||||||
|
package pipeline
|
||||||
@@ -13,7 +13,7 @@ import (
|
|||||||
// moduleIngestInputHash fingerprints module config and child entries so snapshots invalidate on CRUD.
|
// moduleIngestInputHash fingerprints module config and child entries so snapshots invalidate on CRUD.
|
||||||
func moduleIngestInputHash(st store.Backend, tenantID string, mod *store.Module) (string, error) {
|
func moduleIngestInputHash(st store.Backend, tenantID string, mod *store.Module) (string, error) {
|
||||||
if st == nil || mod == nil {
|
if st == nil || mod == nil {
|
||||||
return "", fmt.Errorf("module hash: missing store or module")
|
return "", fmt.Errorf("pipeline: module hash: missing store or module")
|
||||||
}
|
}
|
||||||
h := sha256.New()
|
h := sha256.New()
|
||||||
_, _ = fmt.Fprintf(h, "type=%s\n", strings.TrimSpace(mod.Type))
|
_, _ = fmt.Fprintf(h, "type=%s\n", strings.TrimSpace(mod.Type))
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ func RefreshModuleIngest(ctx context.Context, st store.Backend, hc *http.Client,
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if !mod.Enabled {
|
if !mod.Enabled {
|
||||||
return fmt.Errorf("module disabled")
|
return fmt.Errorf("pipeline: module disabled")
|
||||||
}
|
}
|
||||||
|
|
||||||
var prior []store.PrefixRow
|
var prior []store.PrefixRow
|
||||||
@@ -185,7 +185,7 @@ func collectModulePrefixRows(ctx context.Context, st store.Backend, hc *http.Cli
|
|||||||
}
|
}
|
||||||
return collectDomainPrefixRows(ctx, hc, mod, profiles, policy, entries)
|
return collectDomainPrefixRows(ctx, hc, mod, profiles, policy, entries)
|
||||||
default:
|
default:
|
||||||
return nil, fmt.Errorf("unknown module type %q", mod.Type)
|
return nil, fmt.Errorf("pipeline: unknown module type %q", mod.Type)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -76,5 +76,5 @@ func PickTenantRefreshTriggerModule(st store.Backend, tenantID string, moduleIDs
|
|||||||
return mod.ID, nil
|
return mod.ID, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return "", fmt.Errorf("no enabled module for tenant refresh")
|
return "", fmt.Errorf("pipeline: no enabled module for tenant refresh")
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
package reports
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"evobgp/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RouterListsCatalog is the aggregated router-lists payload for GET /router-lists/catalog.
|
||||||
|
type RouterListsCatalog struct {
|
||||||
|
Modules []map[string]any
|
||||||
|
Domains []map[string]any
|
||||||
|
ASNs []map[string]any
|
||||||
|
IPRanges []map[string]any
|
||||||
|
Communities []map[string]any
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildRouterListsCatalog loads modules and entries for the tenant catalog endpoint.
|
||||||
|
func BuildRouterListsCatalog(st store.Backend, tenantID string) (*RouterListsCatalog, error) {
|
||||||
|
mods := st.ListModules(tenantID)
|
||||||
|
out := &RouterListsCatalog{
|
||||||
|
Modules: make([]map[string]any, 0),
|
||||||
|
Domains: make([]map[string]any, 0),
|
||||||
|
ASNs: make([]map[string]any, 0),
|
||||||
|
IPRanges: make([]map[string]any, 0),
|
||||||
|
Communities: make([]map[string]any, 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, mod := range mods {
|
||||||
|
switch mod.Type {
|
||||||
|
case "DOMAINS", "AS_PREFIXES", "IP_RANGES":
|
||||||
|
out.Modules = append(out.Modules, moduleMap(mod))
|
||||||
|
default:
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
switch mod.Type {
|
||||||
|
case "DOMAINS":
|
||||||
|
list, err := st.ListDomainEntries(tenantID, mod.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, x := range list {
|
||||||
|
out.Domains = append(out.Domains, map[string]any{
|
||||||
|
"module_id": mod.ID,
|
||||||
|
"entry": domainEntryMap(x),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
case "AS_PREFIXES":
|
||||||
|
list, err := st.ListASEntries(tenantID, mod.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, x := range list {
|
||||||
|
out.ASNs = append(out.ASNs, map[string]any{
|
||||||
|
"module_id": mod.ID,
|
||||||
|
"entry": asEntryMap(x),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
case "IP_RANGES":
|
||||||
|
list, err := st.ListIPRangeEntries(tenantID, mod.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, x := range list {
|
||||||
|
out.IPRanges = append(out.IPRanges, map[string]any{
|
||||||
|
"module_id": mod.ID,
|
||||||
|
"entry": ipRangeMap(x),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
comms, err := st.ListCommunities(tenantID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, c := range comms {
|
||||||
|
out.Communities = append(out.Communities, map[string]any{
|
||||||
|
"id": c.ID,
|
||||||
|
"community": c.Community,
|
||||||
|
"title": c.Title,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func moduleMap(mod *store.Module) map[string]any {
|
||||||
|
m := map[string]any{
|
||||||
|
"id": mod.ID,
|
||||||
|
"type": mod.Type,
|
||||||
|
"name": mod.Name,
|
||||||
|
"enabled": mod.Enabled,
|
||||||
|
"priority": mod.Priority,
|
||||||
|
"refresh_interval_sec": mod.RefreshIntervalSec,
|
||||||
|
"cron_expr": mod.CronExpr,
|
||||||
|
}
|
||||||
|
if mod.LastRefreshedAt != nil {
|
||||||
|
m["last_refreshed_at"] = mod.LastRefreshedAt.UTC().Format(time.RFC3339Nano)
|
||||||
|
} else {
|
||||||
|
m["last_refreshed_at"] = nil
|
||||||
|
}
|
||||||
|
if mod.DefaultCommunityID != nil {
|
||||||
|
m["default_community_id"] = *mod.DefaultCommunityID
|
||||||
|
} else {
|
||||||
|
m["default_community_id"] = nil
|
||||||
|
}
|
||||||
|
ids := mod.EffectiveDohProfileIDs()
|
||||||
|
if len(ids) > 0 {
|
||||||
|
m["doh_profile_ids"] = ids
|
||||||
|
} else {
|
||||||
|
m["doh_profile_ids"] = []string{}
|
||||||
|
}
|
||||||
|
m["doh_resolver_policy"] = store.NormalizeDohResolverPolicy(mod.DohResolverPolicy)
|
||||||
|
if mod.DohProfileID != nil {
|
||||||
|
m["doh_profile_id"] = *mod.DohProfileID
|
||||||
|
} else {
|
||||||
|
m["doh_profile_id"] = nil
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func domainEntryMap(x *store.DomainEntry) map[string]any {
|
||||||
|
m := map[string]any{"id": x.ID, "fqdn": x.FQDN}
|
||||||
|
if x.CommunityID != nil {
|
||||||
|
m["community_id"] = *x.CommunityID
|
||||||
|
} else {
|
||||||
|
m["community_id"] = nil
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func asEntryMap(x *store.ASEntry) map[string]any {
|
||||||
|
m := map[string]any{"id": x.ID, "asn": x.ASN}
|
||||||
|
if x.CommunityID != nil {
|
||||||
|
m["community_id"] = *x.CommunityID
|
||||||
|
} else {
|
||||||
|
m["community_id"] = nil
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func ipRangeMap(x *store.IPRangeEntry) map[string]any {
|
||||||
|
m := map[string]any{"id": x.ID, "prefix": x.Prefix}
|
||||||
|
if x.CommunityID != nil {
|
||||||
|
m["community_id"] = *x.CommunityID
|
||||||
|
} else {
|
||||||
|
m["community_id"] = nil
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
package reports
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"evobgp/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RevisionDiagnosticLog is plain-text diagnostic output for a revision.
|
||||||
|
type RevisionDiagnosticLog struct {
|
||||||
|
Body []byte
|
||||||
|
Filename string
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildRevisionDiagnosticLog aggregates revision prefixes by source and community.
|
||||||
|
func BuildRevisionDiagnosticLog(st store.Backend, tenantID string, rev *store.Revision) (*RevisionDiagnosticLog, error) {
|
||||||
|
moduleType := ""
|
||||||
|
moduleName := ""
|
||||||
|
if strings.TrimSpace(rev.ModuleID) != "" {
|
||||||
|
if mod, modErr := st.GetModule(tenantID, rev.ModuleID); modErr == nil && mod != nil {
|
||||||
|
moduleType = strings.TrimSpace(mod.Type)
|
||||||
|
moduleName = strings.TrimSpace(mod.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
communityLabels := map[string]string{}
|
||||||
|
if communities, listErr := st.ListCommunities(tenantID); listErr == nil {
|
||||||
|
for _, c := range communities {
|
||||||
|
if c == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
label := strings.TrimSpace(c.Title)
|
||||||
|
if label == "" {
|
||||||
|
label = strings.TrimSpace(c.Community)
|
||||||
|
}
|
||||||
|
if label == "" {
|
||||||
|
label = c.ID
|
||||||
|
}
|
||||||
|
communityLabels[c.ID] = label
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type rawRow struct {
|
||||||
|
Prefix string
|
||||||
|
Source string
|
||||||
|
Kind string
|
||||||
|
SourceName string
|
||||||
|
SourceDetail string
|
||||||
|
CommunityID string
|
||||||
|
CommunityLabel string
|
||||||
|
}
|
||||||
|
type summary struct {
|
||||||
|
Kind string
|
||||||
|
Source string
|
||||||
|
SourceDetail string
|
||||||
|
CommunityID string
|
||||||
|
CommunityLabel string
|
||||||
|
Count int
|
||||||
|
Sample []string
|
||||||
|
}
|
||||||
|
|
||||||
|
cdnSourceURLByID := map[string]string{}
|
||||||
|
if moduleType == "CDN_CIDRS" && strings.TrimSpace(rev.ModuleID) != "" {
|
||||||
|
if sources, srcErr := st.ListCDNSources(tenantID, rev.ModuleID); srcErr == nil {
|
||||||
|
for _, src := range sources {
|
||||||
|
if src == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
id := strings.TrimSpace(src.ID)
|
||||||
|
url := strings.TrimSpace(src.URL)
|
||||||
|
if id != "" && url != "" {
|
||||||
|
cdnSourceURLByID[id] = url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rows := make([]rawRow, 0, rev.MaterializedPrefixCount)
|
||||||
|
byGroup := map[string]*summary{}
|
||||||
|
cursor := ""
|
||||||
|
for {
|
||||||
|
page, next, more := st.ListRevisionPrefixes(tenantID, rev.ID, cursor, 2000)
|
||||||
|
for _, p := range page {
|
||||||
|
kind, sourceName := classifyRevisionSource(p.Source)
|
||||||
|
sourceDetail := sourceName
|
||||||
|
if kind == "cdn" {
|
||||||
|
if url, ok := cdnSourceURLByID[sourceName]; ok && strings.TrimSpace(url) != "" {
|
||||||
|
sourceDetail = url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
communityID := "none"
|
||||||
|
if p.CommunityID != nil && strings.TrimSpace(*p.CommunityID) != "" {
|
||||||
|
communityID = strings.TrimSpace(*p.CommunityID)
|
||||||
|
}
|
||||||
|
communityLabel := "без community"
|
||||||
|
if communityID != "none" {
|
||||||
|
if lbl, ok := communityLabels[communityID]; ok && strings.TrimSpace(lbl) != "" {
|
||||||
|
communityLabel = lbl
|
||||||
|
} else {
|
||||||
|
communityLabel = communityID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rows = append(rows, rawRow{
|
||||||
|
Prefix: p.Prefix,
|
||||||
|
Source: p.Source,
|
||||||
|
Kind: kind,
|
||||||
|
SourceName: sourceName,
|
||||||
|
SourceDetail: sourceDetail,
|
||||||
|
CommunityID: communityID,
|
||||||
|
CommunityLabel: communityLabel,
|
||||||
|
})
|
||||||
|
groupKey := kind + "|" + sourceName + "|" + communityID
|
||||||
|
g, ok := byGroup[groupKey]
|
||||||
|
if !ok {
|
||||||
|
g = &summary{
|
||||||
|
Kind: kind,
|
||||||
|
Source: sourceName,
|
||||||
|
SourceDetail: sourceDetail,
|
||||||
|
CommunityID: communityID,
|
||||||
|
CommunityLabel: communityLabel,
|
||||||
|
Sample: make([]string, 0, 5),
|
||||||
|
}
|
||||||
|
byGroup[groupKey] = g
|
||||||
|
}
|
||||||
|
g.Count++
|
||||||
|
if len(g.Sample) < 5 {
|
||||||
|
g.Sample = append(g.Sample, p.Prefix)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !more || strings.TrimSpace(next) == "" {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
cursor = next
|
||||||
|
}
|
||||||
|
|
||||||
|
summaryKeys := make([]string, 0, len(byGroup))
|
||||||
|
for k := range byGroup {
|
||||||
|
summaryKeys = append(summaryKeys, k)
|
||||||
|
}
|
||||||
|
sort.Strings(summaryKeys)
|
||||||
|
|
||||||
|
sort.Slice(rows, func(i, j int) bool {
|
||||||
|
if rows[i].Kind != rows[j].Kind {
|
||||||
|
return rows[i].Kind < rows[j].Kind
|
||||||
|
}
|
||||||
|
if rows[i].SourceName != rows[j].SourceName {
|
||||||
|
return rows[i].SourceName < rows[j].SourceName
|
||||||
|
}
|
||||||
|
if rows[i].CommunityID != rows[j].CommunityID {
|
||||||
|
return rows[i].CommunityID < rows[j].CommunityID
|
||||||
|
}
|
||||||
|
return rows[i].Prefix < rows[j].Prefix
|
||||||
|
})
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString("EvoBGP revision diagnostic log\n")
|
||||||
|
b.WriteString("generated_at=" + time.Now().UTC().Format(time.RFC3339Nano) + "\n")
|
||||||
|
b.WriteString("tenant_id=" + tenantID + "\n")
|
||||||
|
b.WriteString("revision_id=" + rev.ID + "\n")
|
||||||
|
b.WriteString("revision_created_at=" + rev.CreatedAt.UTC().Format(time.RFC3339Nano) + "\n")
|
||||||
|
b.WriteString("content_hash=" + rev.ContentHash + "\n")
|
||||||
|
b.WriteString("materialized_prefix_count=" + strconv.Itoa(rev.MaterializedPrefixCount) + "\n")
|
||||||
|
if rev.ModuleID != "" {
|
||||||
|
b.WriteString("module_id=" + rev.ModuleID + "\n")
|
||||||
|
} else {
|
||||||
|
b.WriteString("module_id=\n")
|
||||||
|
}
|
||||||
|
b.WriteString("module_type=" + moduleType + "\n")
|
||||||
|
b.WriteString("module_name=" + moduleName + "\n")
|
||||||
|
b.WriteString("fetched_rows=" + strconv.Itoa(len(rows)) + "\n\n")
|
||||||
|
|
||||||
|
b.WriteString("## Aggregation by source and community\n")
|
||||||
|
for _, k := range summaryKeys {
|
||||||
|
g := byGroup[k]
|
||||||
|
b.WriteString("- kind=" + g.Kind +
|
||||||
|
" source=" + g.Source +
|
||||||
|
" source_detail=" + g.SourceDetail +
|
||||||
|
" community_id=" + g.CommunityID +
|
||||||
|
" community_label=" + g.CommunityLabel +
|
||||||
|
" count=" + strconv.Itoa(g.Count))
|
||||||
|
if len(g.Sample) > 0 {
|
||||||
|
b.WriteString(" sample=" + strings.Join(g.Sample, ","))
|
||||||
|
}
|
||||||
|
b.WriteByte('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
b.WriteString("\n## Raw rows\n")
|
||||||
|
b.WriteString("prefix\tkind\tsource\tsource_detail\tcommunity_id\tcommunity_label\n")
|
||||||
|
for _, row := range rows {
|
||||||
|
b.WriteString(row.Prefix)
|
||||||
|
b.WriteByte('\t')
|
||||||
|
b.WriteString(row.Kind)
|
||||||
|
b.WriteByte('\t')
|
||||||
|
b.WriteString(row.Source)
|
||||||
|
b.WriteByte('\t')
|
||||||
|
b.WriteString(strings.ReplaceAll(row.SourceDetail, "\t", " "))
|
||||||
|
b.WriteByte('\t')
|
||||||
|
b.WriteString(row.CommunityID)
|
||||||
|
b.WriteByte('\t')
|
||||||
|
b.WriteString(strings.ReplaceAll(row.CommunityLabel, "\t", " "))
|
||||||
|
b.WriteByte('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
filename := fmt.Sprintf("revision-%s-diagnostic.log", shortRevisionID(rev.ID))
|
||||||
|
return &RevisionDiagnosticLog{Body: []byte(b.String()), Filename: filename}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func classifyRevisionSource(src string) (kind, sourceName string) {
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(src, "as:"):
|
||||||
|
return "asn", strings.TrimPrefix(src, "as:")
|
||||||
|
case strings.HasPrefix(src, "domain:"):
|
||||||
|
return "domain", strings.TrimPrefix(src, "domain:")
|
||||||
|
case strings.HasPrefix(src, "cdn:"):
|
||||||
|
return "cdn", strings.TrimPrefix(src, "cdn:")
|
||||||
|
case src == "ip_range":
|
||||||
|
return "ip_range", "manual_ranges"
|
||||||
|
default:
|
||||||
|
if strings.TrimSpace(src) == "" {
|
||||||
|
return "unknown", "unknown"
|
||||||
|
}
|
||||||
|
return "source", strings.TrimSpace(src)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func shortRevisionID(id string) string {
|
||||||
|
s := strings.TrimSpace(id)
|
||||||
|
if len(s) <= 8 {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return s[:8]
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ type Backend interface {
|
|||||||
// ListTenantIDs returns distinct tenant identifiers (for background workers).
|
// ListTenantIDs returns distinct tenant identifiers (for background workers).
|
||||||
ListTenantIDs() ([]string, error)
|
ListTenantIDs() ([]string, error)
|
||||||
|
|
||||||
|
// ListModules returns all modules for a tenant (control plane may paginate in httpapi).
|
||||||
ListModules(tenantID string) []*Module
|
ListModules(tenantID string) []*Module
|
||||||
GetModule(tenantID, moduleID string) (*Module, error)
|
GetModule(tenantID, moduleID string) (*Module, error)
|
||||||
CreateModule(tenantID string, in *Module) (*Module, error)
|
CreateModule(tenantID string, in *Module) (*Module, error)
|
||||||
@@ -53,6 +54,7 @@ type Backend interface {
|
|||||||
UpdateCommunity(tenantID, id string, patch *CommunityPatch) (*Community, error)
|
UpdateCommunity(tenantID, id string, patch *CommunityPatch) (*Community, error)
|
||||||
DeleteCommunity(tenantID, id string) error
|
DeleteCommunity(tenantID, id string) error
|
||||||
|
|
||||||
|
// ListPeers returns all BGP peers for a tenant (control plane may paginate in httpapi).
|
||||||
ListPeers(tenantID string) []*BGPPeer
|
ListPeers(tenantID string) []*BGPPeer
|
||||||
GetPeer(tenantID, id string) (*BGPPeer, error)
|
GetPeer(tenantID, id string) (*BGPPeer, error)
|
||||||
CreatePeer(tenantID string, in *BGPPeer) (*BGPPeer, error)
|
CreatePeer(tenantID string, in *BGPPeer) (*BGPPeer, error)
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import "strconv"
|
||||||
|
|
||||||
|
// PaginateOffset returns a page from a pre-sorted slice using numeric string cursors (same scheme as ListRevisions).
|
||||||
|
func PaginateOffset[T any](all []T, cursor string, limit int) (page []T, nextCursor string, hasMore bool) {
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 50
|
||||||
|
}
|
||||||
|
off := 0
|
||||||
|
if cursor != "" {
|
||||||
|
if n, err := strconv.Atoi(cursor); err == nil && n >= 0 {
|
||||||
|
off = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if off > len(all) {
|
||||||
|
off = len(all)
|
||||||
|
}
|
||||||
|
end := off + limit
|
||||||
|
if end > len(all) {
|
||||||
|
end = len(all)
|
||||||
|
}
|
||||||
|
page = all[off:end]
|
||||||
|
if end < len(all) {
|
||||||
|
nextCursor = strconv.Itoa(end)
|
||||||
|
hasMore = true
|
||||||
|
}
|
||||||
|
return page, nextCursor, hasMore
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
// Package version holds build metadata injected at link time via -ldflags.
|
||||||
|
package version
|
||||||
|
|
||||||
|
// Link-time variables (defaults for local development).
|
||||||
|
var (
|
||||||
|
Version = "dev"
|
||||||
|
GitSHA = "unknown"
|
||||||
|
BuildTime = ""
|
||||||
|
)
|
||||||
|
|
||||||
|
// Info returns build metadata, substituting safe defaults for empty values.
|
||||||
|
func Info() (ver, gitSHA, buildTime string) {
|
||||||
|
ver = Version
|
||||||
|
if ver == "" {
|
||||||
|
ver = "dev"
|
||||||
|
}
|
||||||
|
gitSHA = GitSHA
|
||||||
|
if gitSHA == "" {
|
||||||
|
gitSHA = "unknown"
|
||||||
|
}
|
||||||
|
buildTime = BuildTime
|
||||||
|
return ver, gitSHA, buildTime
|
||||||
|
}
|
||||||
Generated
+7691
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/git": "^10.0.1",
|
||||||
|
"@semantic-release/release-notes-generator": "^14.0.3",
|
||||||
|
"semantic-release": "^25.0.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
#Requires -Version 5.1
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Staged git changes grouped by EvoBGP scope for Conventional Commit generation.
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
Outputs JSON to stdout: file groups, --stat, and truncated patch per group.
|
||||||
|
Exit 1 if nothing is staged. Does not write commit messages.
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
powershell -NoProfile -File scripts/commit/staged-context.ps1
|
||||||
|
powershell -NoProfile -File scripts/commit/staged-context.ps1 | ConvertFrom-Json
|
||||||
|
#>
|
||||||
|
Set-StrictMode -Version Latest
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
|
||||||
|
$MaxLinesPerGroup = 120
|
||||||
|
$MaxLinesTotal = 500
|
||||||
|
|
||||||
|
function Test-GitRepository {
|
||||||
|
$null = git rev-parse --git-dir 2>$null
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
Write-Error 'staged-context: not a git repository'
|
||||||
|
exit 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-ScopeForPath {
|
||||||
|
param([string]$Path)
|
||||||
|
$p = $Path -replace '\\', '/'
|
||||||
|
|
||||||
|
# Longest / most specific prefixes first
|
||||||
|
$rules = @(
|
||||||
|
@{ Prefix = 'docs/openapi.yaml'; Scope = 'openapi' }
|
||||||
|
@{ Prefix = 'redocly.yaml'; Scope = 'openapi' }
|
||||||
|
@{ Prefix = 'internal/httpapi/'; Scope = 'httpapi' }
|
||||||
|
@{ Prefix = 'internal/store/'; Scope = 'store' }
|
||||||
|
@{ Prefix = 'internal/repository/'; Scope = 'store' }
|
||||||
|
@{ Prefix = 'internal/db/'; Scope = 'store' }
|
||||||
|
@{ Prefix = 'internal/jobs/'; Scope = 'jobs' }
|
||||||
|
@{ Prefix = 'internal/pipeline/'; Scope = 'pipeline' }
|
||||||
|
@{ Prefix = 'internal/birdfmt/'; Scope = 'birdfmt' }
|
||||||
|
@{ Prefix = 'internal/birddeploy/'; Scope = 'birddeploy' }
|
||||||
|
@{ Prefix = 'internal/bundle/'; Scope = 'bundle' }
|
||||||
|
@{ Prefix = 'internal/signing/'; Scope = 'bundle' }
|
||||||
|
@{ Prefix = 'cmd/'; Scope = 'cmd' }
|
||||||
|
@{ Prefix = 'web/'; Scope = 'web' }
|
||||||
|
@{ Prefix = 'migrations/'; Scope = 'db' }
|
||||||
|
@{ Prefix = 'docs/'; Scope = 'docs' }
|
||||||
|
@{ Prefix = '.gitea/'; Scope = 'ci' }
|
||||||
|
@{ Prefix = 'deploy/'; Scope = 'deploy' }
|
||||||
|
@{ Prefix = '.cursor/'; Scope = 'chore' }
|
||||||
|
)
|
||||||
|
|
||||||
|
foreach ($rule in $rules) {
|
||||||
|
if ($p -eq $rule.Prefix.TrimEnd('/') -or $p.StartsWith($rule.Prefix)) {
|
||||||
|
return $rule.Scope
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 'chore'
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-ScopeSortOrder {
|
||||||
|
param([string]$Scope)
|
||||||
|
$order = @{
|
||||||
|
openapi = 10
|
||||||
|
httpapi = 20
|
||||||
|
store = 21
|
||||||
|
jobs = 22
|
||||||
|
pipeline = 30
|
||||||
|
birdfmt = 31
|
||||||
|
birddeploy = 32
|
||||||
|
bundle = 33
|
||||||
|
web = 40
|
||||||
|
ci = 50
|
||||||
|
deploy = 51
|
||||||
|
db = 52
|
||||||
|
docs = 60
|
||||||
|
cmd = 70
|
||||||
|
chore = 80
|
||||||
|
}
|
||||||
|
if ($order.ContainsKey($Scope)) { return $order[$Scope] }
|
||||||
|
return 99
|
||||||
|
}
|
||||||
|
|
||||||
|
function Invoke-Git {
|
||||||
|
param([string[]]$GitArgs)
|
||||||
|
$out = & git @GitArgs 2>&1
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
$msg = ($out | Out-String).Trim()
|
||||||
|
throw "git $($GitArgs -join ' ') failed: $msg"
|
||||||
|
}
|
||||||
|
return ($out | Out-String).TrimEnd()
|
||||||
|
}
|
||||||
|
|
||||||
|
function New-GitArgs {
|
||||||
|
param([string[]]$Base, [string[]]$Paths)
|
||||||
|
if ($Paths.Count -eq 0) { return $Base }
|
||||||
|
return $Base + '--' + $Paths
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-TruncatedDiff {
|
||||||
|
param(
|
||||||
|
[string[]]$Files,
|
||||||
|
[int]$MaxLines,
|
||||||
|
[ref]$TotalLinesUsed
|
||||||
|
)
|
||||||
|
if ($Files.Count -eq 0) { return '' }
|
||||||
|
|
||||||
|
$remaining = $MaxLinesTotal - $TotalLinesUsed.Value
|
||||||
|
if ($remaining -le 0) {
|
||||||
|
return '[diff truncated: global line budget exceeded]'
|
||||||
|
}
|
||||||
|
|
||||||
|
$cap = [Math]::Min($MaxLines, $remaining)
|
||||||
|
$diff = Invoke-Git -GitArgs (New-GitArgs -Base @(
|
||||||
|
'diff', '--cached', '--no-color', '--unified=3'
|
||||||
|
) -Paths $Files)
|
||||||
|
if ([string]::IsNullOrWhiteSpace($diff)) { return '' }
|
||||||
|
|
||||||
|
$lines = $diff -split "`n", -1
|
||||||
|
if ($lines.Count -le $cap) {
|
||||||
|
$TotalLinesUsed.Value += $lines.Count
|
||||||
|
return $diff
|
||||||
|
}
|
||||||
|
|
||||||
|
$truncated = ($lines[0..($cap - 1)] -join "`n") + "`n... [truncated: $($lines.Count - $cap) more lines]"
|
||||||
|
$TotalLinesUsed.Value += $cap
|
||||||
|
return $truncated
|
||||||
|
}
|
||||||
|
|
||||||
|
Test-GitRepository
|
||||||
|
|
||||||
|
$stagedRaw = Invoke-Git -GitArgs @('diff', '--cached', '--name-only')
|
||||||
|
if ([string]::IsNullOrWhiteSpace($stagedRaw)) {
|
||||||
|
[Console]::Error.WriteLine('staged-context: no staged changes (git index is empty)')
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
$stagedFiles = @(
|
||||||
|
$stagedRaw -split "`n" | ForEach-Object { $_.Trim() } | Where-Object { $_ -ne '' }
|
||||||
|
)
|
||||||
|
$stagedFiles = $stagedFiles | Sort-Object -Unique
|
||||||
|
|
||||||
|
$scopeBuckets = @{}
|
||||||
|
foreach ($f in $stagedFiles) {
|
||||||
|
$scope = Get-ScopeForPath -Path $f
|
||||||
|
if (-not $scopeBuckets.ContainsKey($scope)) {
|
||||||
|
$scopeBuckets[$scope] = [System.Collections.Generic.List[string]]::new()
|
||||||
|
}
|
||||||
|
$scopeBuckets[$scope].Add($f) | Out-Null
|
||||||
|
}
|
||||||
|
|
||||||
|
$totalLinesUsed = 0
|
||||||
|
$groups = New-Object System.Collections.Generic.List[object]
|
||||||
|
|
||||||
|
$scopeKeys = @($scopeBuckets.Keys | Sort-Object { Get-ScopeSortOrder $_ })
|
||||||
|
|
||||||
|
foreach ($scope in $scopeKeys) {
|
||||||
|
$files = @($scopeBuckets[$scope] | Sort-Object)
|
||||||
|
$stat = Invoke-Git -GitArgs (New-GitArgs -Base @('diff', '--cached', '--stat') -Paths $files)
|
||||||
|
$diffExcerpt = Get-TruncatedDiff -Files $files -MaxLines $MaxLinesPerGroup -TotalLinesUsed ([ref]$totalLinesUsed)
|
||||||
|
|
||||||
|
$groups.Add([ordered]@{
|
||||||
|
scope = $scope
|
||||||
|
files = $files
|
||||||
|
stat = $stat
|
||||||
|
diff_excerpt = $diffExcerpt
|
||||||
|
}) | Out-Null
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = [ordered]@{
|
||||||
|
staged_count = $stagedFiles.Count
|
||||||
|
groups = $groups.ToArray()
|
||||||
|
}
|
||||||
|
|
||||||
|
$json = $result | ConvertTo-Json -Depth 6 -Compress:$false
|
||||||
|
# UTF-8 stdout for agents / ConvertFrom-Json
|
||||||
|
[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false)
|
||||||
|
Write-Output $json
|
||||||
|
exit 0
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
#!/usr/bin/env sh
|
||||||
|
# Engineering gates for internal/httpapi (ERR-01, ARCH-01).
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
ROOT="$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)"
|
||||||
|
cd "$ROOT"
|
||||||
|
|
||||||
|
HTTPAPI="internal/httpapi"
|
||||||
|
FAIL=0
|
||||||
|
|
||||||
|
echo "==> ERR-01: no err.Error() in 5xx problem details"
|
||||||
|
if grep -rE 'writeProblem\(w, http\.StatusInternalServerError.*err\.Error\(\)' "$HTTPAPI" 2>/dev/null; then
|
||||||
|
FAIL=1
|
||||||
|
fi
|
||||||
|
if grep -rE 'writeProblem\(w, http\.StatusBadGateway.*err\.Error\(\)' "$HTTPAPI" 2>/dev/null; then
|
||||||
|
FAIL=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "==> ARCH-01: no SQL/pgx queries in httpapi"
|
||||||
|
if grep -rE 'pool\.(Query|Exec|QueryRow)|SELECT |INSERT INTO |UPDATE .* SET |DELETE FROM ' "$HTTPAPI" 2>/dev/null; then
|
||||||
|
FAIL=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$FAIL" -ne 0 ]; then
|
||||||
|
echo "lint-httpapi: failed" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "lint-httpapi: ok"
|
||||||
+1
-4
@@ -3,10 +3,7 @@
|
|||||||
"singleQuote": true,
|
"singleQuote": true,
|
||||||
"trailingComma": "none",
|
"trailingComma": "none",
|
||||||
"printWidth": 100,
|
"printWidth": 100,
|
||||||
"plugins": [
|
"plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"],
|
||||||
"prettier-plugin-svelte",
|
|
||||||
"prettier-plugin-tailwindcss"
|
|
||||||
],
|
|
||||||
"tailwindStylesheet": "./src/routes/layout.css",
|
"tailwindStylesheet": "./src/routes/layout.css",
|
||||||
"overrides": [
|
"overrides": [
|
||||||
{
|
{
|
||||||
|
|||||||
Vendored
+1
-5
@@ -1,7 +1,3 @@
|
|||||||
{
|
{
|
||||||
"recommendations": [
|
"recommendations": ["svelte.svelte-vscode", "bradlc.vscode-tailwindcss", "esbenp.prettier-vscode"]
|
||||||
"svelte.svelte-vscode",
|
|
||||||
"bradlc.vscode-tailwindcss",
|
|
||||||
"esbenp.prettier-vscode"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
+6
-6
@@ -6,12 +6,12 @@ SvelteKit-приложение панели управления EvoBGP. Зап
|
|||||||
|
|
||||||
## UI-архитектура
|
## UI-архитектура
|
||||||
|
|
||||||
| Путь | Назначение |
|
| Путь | Назначение |
|
||||||
|------|------------|
|
| ------------------------ | ------------------------------------------------------------------- |
|
||||||
| `src/lib/ui/core/` | shadcn-svelte (только через `npx shadcn-svelte@latest add … -y -o`) |
|
| `src/lib/ui/core/` | shadcn-svelte (только через `npx shadcn-svelte@latest add … -y -o`) |
|
||||||
| `src/lib/ui/app/` | Layout, PageHeader, toast (`notify`) |
|
| `src/lib/ui/app/` | Layout, PageHeader, toast (`notify`) |
|
||||||
| `src/lib/ui/patterns/` | FormField, AppDataTable, ConfirmDialog, EmptyState |
|
| `src/lib/ui/patterns/` | FormField, AppDataTable, ConfirmDialog, EmptyState |
|
||||||
| `src/lib/components/ui/` | Re-export на `ui/core` (совместимость) |
|
| `src/lib/components/ui/` | Re-export на `ui/core` (совместимость) |
|
||||||
|
|
||||||
Токены темы: `src/routes/layout.css`. Справочник: `src/lib/ui/app/tokens.md`.
|
Токены темы: `src/routes/layout.css`. Справочник: `src/lib/ui/app/tokens.md`.
|
||||||
|
|
||||||
|
|||||||
@@ -124,11 +124,13 @@ export type IpRangeEntriesResponse = Page<IpRangeEntry>;
|
|||||||
// ---- DoH Profiles ----
|
// ---- DoH Profiles ----
|
||||||
export type DohProfile = {
|
export type DohProfile = {
|
||||||
id: string;
|
id: string;
|
||||||
|
name?: string;
|
||||||
url: string;
|
url: string;
|
||||||
timeout_ms: number | null;
|
timeout_ms: number | null;
|
||||||
vault_secret_ref: string | null;
|
vault_secret_ref: string | null;
|
||||||
};
|
};
|
||||||
export type DohProfileCreate = {
|
export type DohProfileCreate = {
|
||||||
|
name?: string;
|
||||||
url: string;
|
url: string;
|
||||||
timeout_ms?: number | null;
|
timeout_ms?: number | null;
|
||||||
vault_secret_ref?: string | null;
|
vault_secret_ref?: string | null;
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { RevisionDiff, RevisionPrefix, RevisionRow } from '$lib/api/types.js';
|
import type { RevisionDiff, RevisionPrefix, RevisionRow } from '$lib/api/types.js';
|
||||||
import { Button } from '$lib/components/ui/button/index.js';
|
import { Button } from '$lib/components/ui/button/index.js';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '$lib/components/ui/card/index.js';
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle
|
||||||
|
} from '$lib/components/ui/card/index.js';
|
||||||
import { sortRevisionDiffItems } from '$lib/sort-prefixes.js';
|
import { sortRevisionDiffItems } from '$lib/sort-prefixes.js';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -79,7 +85,7 @@
|
|||||||
<select
|
<select
|
||||||
value={diffRevA}
|
value={diffRevA}
|
||||||
onchange={(e) => onDiffRevAChange((e.currentTarget as HTMLSelectElement).value)}
|
onchange={(e) => onDiffRevAChange((e.currentTarget as HTMLSelectElement).value)}
|
||||||
class="border-border bg-background h-8 min-w-0 flex-1 rounded-md border px-2 text-sm"
|
class="h-8 min-w-0 flex-1 rounded-md border border-border bg-background px-2 text-sm"
|
||||||
>
|
>
|
||||||
<option value="">Ревизия A</option>
|
<option value="">Ревизия A</option>
|
||||||
{#each revisions as rev (rev.id)}
|
{#each revisions as rev (rev.id)}
|
||||||
@@ -89,51 +95,58 @@
|
|||||||
<select
|
<select
|
||||||
value={diffRevB}
|
value={diffRevB}
|
||||||
onchange={(e) => onDiffRevBChange((e.currentTarget as HTMLSelectElement).value)}
|
onchange={(e) => onDiffRevBChange((e.currentTarget as HTMLSelectElement).value)}
|
||||||
class="border-border bg-background h-8 min-w-0 flex-1 rounded-md border px-2 text-sm"
|
class="h-8 min-w-0 flex-1 rounded-md border border-border bg-background px-2 text-sm"
|
||||||
>
|
>
|
||||||
<option value="">Ревизия B</option>
|
<option value="">Ревизия B</option>
|
||||||
{#each revisions as rev (rev.id)}
|
{#each revisions as rev (rev.id)}
|
||||||
<option value={rev.id}>{rev.id.slice(0, 8)}… ({formatDate(rev.created_at)})</option>
|
<option value={rev.id}>{rev.id.slice(0, 8)}… ({formatDate(rev.created_at)})</option>
|
||||||
{/each}
|
{/each}
|
||||||
</select>
|
</select>
|
||||||
<Button size="sm" class="shrink-0 self-start sm:self-auto" onclick={onLoadDiff} disabled={diffLoading}>
|
<Button
|
||||||
|
size="sm"
|
||||||
|
class="shrink-0 self-start sm:self-auto"
|
||||||
|
onclick={onLoadDiff}
|
||||||
|
disabled={diffLoading}
|
||||||
|
>
|
||||||
{diffLoading ? 'Загрузка…' : 'Сравнить'}
|
{diffLoading ? 'Загрузка…' : 'Сравнить'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if diffData}
|
{#if diffData}
|
||||||
<div
|
<div
|
||||||
class="border-border grid min-h-0 grid-cols-1 overflow-hidden rounded-md border sm:grid-cols-2"
|
class="grid min-h-0 grid-cols-1 overflow-hidden rounded-md border border-border sm:grid-cols-2"
|
||||||
style="scrollbar-gutter: stable;"
|
style="scrollbar-gutter: stable;"
|
||||||
>
|
>
|
||||||
<!-- + Добавлено -->
|
<!-- + Добавлено -->
|
||||||
<div class="border-border flex min-h-0 min-w-0 flex-col border-b sm:border-r sm:border-b-0">
|
<div class="flex min-h-0 min-w-0 flex-col border-b border-border sm:border-r sm:border-b-0">
|
||||||
<div
|
<div
|
||||||
class="border-border bg-muted/60 flex shrink-0 items-center border-b px-3 py-2 font-mono text-xs font-semibold text-green-600 dark:text-green-400"
|
class="flex shrink-0 items-center border-b border-border bg-muted/60 px-3 py-2 font-mono text-xs font-semibold text-green-600 dark:text-green-400"
|
||||||
>
|
>
|
||||||
<span class="text-muted-foreground mr-2 w-10 shrink-0 select-none text-right">+</span>
|
<span class="mr-2 w-10 shrink-0 text-right text-muted-foreground select-none">+</span>
|
||||||
<span>Добавлено ({addedSorted.length})</span>
|
<span>Добавлено ({addedSorted.length})</span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
bind:this={scrollAddedEl}
|
bind:this={scrollAddedEl}
|
||||||
class="bg-background/50 max-h-[min(28rem,70vh)] min-h-0 overflow-x-auto overflow-y-scroll overscroll-contain"
|
class="max-h-[min(28rem,70vh)] min-h-0 overflow-x-auto overflow-y-scroll overscroll-contain bg-background/50"
|
||||||
onscroll={syncFromAdded}
|
onscroll={syncFromAdded}
|
||||||
>
|
>
|
||||||
{#if addedSorted.length === 0}
|
{#if addedSorted.length === 0}
|
||||||
<p class="text-muted-foreground p-3 font-mono text-xs">Нет изменений</p>
|
<p class="p-3 font-mono text-xs text-muted-foreground">Нет изменений</p>
|
||||||
{:else}
|
{:else}
|
||||||
<table class="w-full border-collapse font-mono text-xs">
|
<table class="w-full border-collapse font-mono text-xs">
|
||||||
<tbody>
|
<tbody>
|
||||||
{#each addedSorted as line, i (`a-${i}-${line}`)}
|
{#each addedSorted as line, i (`a-${i}-${line}`)}
|
||||||
<tr
|
<tr
|
||||||
class="border-border/50 hover:bg-muted/30 border-b border-l-2 border-l-green-500/80 bg-green-500/[0.08] dark:bg-green-500/15"
|
class="border-b border-l-2 border-border/50 border-l-green-500/80 bg-green-500/[0.08] hover:bg-muted/30 dark:bg-green-500/15"
|
||||||
>
|
>
|
||||||
<td
|
<td
|
||||||
class="text-muted-foreground w-10 shrink-0 border-r border-transparent py-0.5 pr-1 pl-2 text-right align-top text-[11px] tabular-nums select-none"
|
class="w-10 shrink-0 border-r border-transparent py-0.5 pr-1 pl-2 text-right align-top text-[11px] text-muted-foreground tabular-nums select-none"
|
||||||
>
|
>
|
||||||
{i + 1}
|
{i + 1}
|
||||||
</td>
|
</td>
|
||||||
<td class="text-foreground max-w-0 break-all py-0.5 pr-3 pl-1 whitespace-pre-wrap">
|
<td
|
||||||
|
class="max-w-0 py-0.5 pr-3 pl-1 break-all whitespace-pre-wrap text-foreground"
|
||||||
|
>
|
||||||
{line}
|
{line}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -147,31 +160,33 @@
|
|||||||
<!-- − Удалено -->
|
<!-- − Удалено -->
|
||||||
<div class="flex min-h-0 min-w-0 flex-col">
|
<div class="flex min-h-0 min-w-0 flex-col">
|
||||||
<div
|
<div
|
||||||
class="border-border bg-muted/60 flex shrink-0 items-center border-b px-3 py-2 font-mono text-xs font-semibold text-red-600 dark:text-red-400"
|
class="flex shrink-0 items-center border-b border-border bg-muted/60 px-3 py-2 font-mono text-xs font-semibold text-red-600 dark:text-red-400"
|
||||||
>
|
>
|
||||||
<span class="text-muted-foreground mr-2 w-10 shrink-0 select-none text-right">−</span>
|
<span class="mr-2 w-10 shrink-0 text-right text-muted-foreground select-none">−</span>
|
||||||
<span>Удалено ({removedSorted.length})</span>
|
<span>Удалено ({removedSorted.length})</span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
bind:this={scrollRemovedEl}
|
bind:this={scrollRemovedEl}
|
||||||
class="bg-background/50 max-h-[min(28rem,70vh)] min-h-0 overflow-x-auto overflow-y-scroll overscroll-contain"
|
class="max-h-[min(28rem,70vh)] min-h-0 overflow-x-auto overflow-y-scroll overscroll-contain bg-background/50"
|
||||||
onscroll={syncFromRemoved}
|
onscroll={syncFromRemoved}
|
||||||
>
|
>
|
||||||
{#if removedSorted.length === 0}
|
{#if removedSorted.length === 0}
|
||||||
<p class="text-muted-foreground p-3 font-mono text-xs">Нет изменений</p>
|
<p class="p-3 font-mono text-xs text-muted-foreground">Нет изменений</p>
|
||||||
{:else}
|
{:else}
|
||||||
<table class="w-full border-collapse font-mono text-xs">
|
<table class="w-full border-collapse font-mono text-xs">
|
||||||
<tbody>
|
<tbody>
|
||||||
{#each removedSorted as line, i (`r-${i}-${line}`)}
|
{#each removedSorted as line, i (`r-${i}-${line}`)}
|
||||||
<tr
|
<tr
|
||||||
class="border-border/50 hover:bg-muted/30 border-b border-l-2 border-l-red-500/80 bg-red-500/[0.08] dark:bg-red-500/15"
|
class="border-b border-l-2 border-border/50 border-l-red-500/80 bg-red-500/[0.08] hover:bg-muted/30 dark:bg-red-500/15"
|
||||||
>
|
>
|
||||||
<td
|
<td
|
||||||
class="text-muted-foreground w-10 shrink-0 border-r border-transparent py-0.5 pr-1 pl-2 text-right align-top text-[11px] tabular-nums select-none"
|
class="w-10 shrink-0 border-r border-transparent py-0.5 pr-1 pl-2 text-right align-top text-[11px] text-muted-foreground tabular-nums select-none"
|
||||||
>
|
>
|
||||||
{i + 1}
|
{i + 1}
|
||||||
</td>
|
</td>
|
||||||
<td class="text-foreground max-w-0 break-all py-0.5 pr-3 pl-1 whitespace-pre-wrap">
|
<td
|
||||||
|
class="max-w-0 py-0.5 pr-3 pl-1 break-all whitespace-pre-wrap text-foreground"
|
||||||
|
>
|
||||||
{line}
|
{line}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -61,7 +61,7 @@
|
|||||||
|
|
||||||
<div class="space-y-3 rounded-lg border border-border/80 bg-muted/15 p-3">
|
<div class="space-y-3 rounded-lg border border-border/80 bg-muted/15 p-3">
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
<Filter class="text-muted-foreground size-4 shrink-0" aria-hidden="true" />
|
<Filter class="size-4 shrink-0 text-muted-foreground" aria-hidden="true" />
|
||||||
<span class="text-sm font-medium">Фильтры задач</span>
|
<span class="text-sm font-medium">Фильтры задач</span>
|
||||||
<div class="ml-auto flex flex-wrap gap-1.5">
|
<div class="ml-auto flex flex-wrap gap-1.5">
|
||||||
<Button
|
<Button
|
||||||
@@ -84,7 +84,14 @@
|
|||||||
>
|
>
|
||||||
В работе
|
В работе
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="button" variant="ghost" size="sm" class="h-7 text-xs" {disabled} onclick={onResetFilters}>
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
class="h-7 text-xs"
|
||||||
|
{disabled}
|
||||||
|
onclick={onResetFilters}
|
||||||
|
>
|
||||||
<X class="size-3.5" aria-hidden="true" />
|
<X class="size-3.5" aria-hidden="true" />
|
||||||
Сброс
|
Сброс
|
||||||
</Button>
|
</Button>
|
||||||
@@ -93,7 +100,7 @@
|
|||||||
|
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<Search
|
<Search
|
||||||
class="text-muted-foreground pointer-events-none absolute top-1/2 left-2.5 size-4 -translate-y-1/2"
|
class="pointer-events-none absolute top-1/2 left-2.5 size-4 -translate-y-1/2 text-muted-foreground"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
@@ -113,7 +120,7 @@
|
|||||||
<Label for="job-filter-status" class="text-xs">Статус</Label>
|
<Label for="job-filter-status" class="text-xs">Статус</Label>
|
||||||
<select
|
<select
|
||||||
id="job-filter-status"
|
id="job-filter-status"
|
||||||
class="border-border bg-background h-9 w-full rounded-md border px-2 text-sm"
|
class="h-9 w-full rounded-md border border-border bg-background px-2 text-sm"
|
||||||
value={filterStatus}
|
value={filterStatus}
|
||||||
onchange={(e) => onFilterStatusChange((e.currentTarget as HTMLSelectElement).value)}
|
onchange={(e) => onFilterStatusChange((e.currentTarget as HTMLSelectElement).value)}
|
||||||
{disabled}
|
{disabled}
|
||||||
@@ -127,7 +134,7 @@
|
|||||||
<Label for="job-filter-kind" class="text-xs">Тип задачи</Label>
|
<Label for="job-filter-kind" class="text-xs">Тип задачи</Label>
|
||||||
<select
|
<select
|
||||||
id="job-filter-kind"
|
id="job-filter-kind"
|
||||||
class="border-border bg-background h-9 w-full rounded-md border px-2 text-sm"
|
class="h-9 w-full rounded-md border border-border bg-background px-2 text-sm"
|
||||||
value={filterKind}
|
value={filterKind}
|
||||||
onchange={(e) => onFilterKindChange((e.currentTarget as HTMLSelectElement).value)}
|
onchange={(e) => onFilterKindChange((e.currentTarget as HTMLSelectElement).value)}
|
||||||
{disabled}
|
{disabled}
|
||||||
@@ -141,7 +148,7 @@
|
|||||||
<Label for="job-filter-module" class="text-xs">Модуль (локально)</Label>
|
<Label for="job-filter-module" class="text-xs">Модуль (локально)</Label>
|
||||||
<select
|
<select
|
||||||
id="job-filter-module"
|
id="job-filter-module"
|
||||||
class="border-border bg-background h-9 w-full rounded-md border px-2 text-sm"
|
class="h-9 w-full rounded-md border border-border bg-background px-2 text-sm"
|
||||||
value={filterModuleId}
|
value={filterModuleId}
|
||||||
onchange={(e) => onFilterModuleIdChange((e.currentTarget as HTMLSelectElement).value)}
|
onchange={(e) => onFilterModuleIdChange((e.currentTarget as HTMLSelectElement).value)}
|
||||||
{disabled}
|
{disabled}
|
||||||
|
|||||||
@@ -87,7 +87,7 @@
|
|||||||
<CardDescription class="flex flex-wrap items-center gap-x-2 gap-y-1">
|
<CardDescription class="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||||
<span>Фоновые задачи (ingest, применение, обновление)</span>
|
<span>Фоновые задачи (ingest, применение, обновление)</span>
|
||||||
{#if jobsFetchedTotal !== undefined}
|
{#if jobsFetchedTotal !== undefined}
|
||||||
<span class="text-muted-foreground font-normal tabular-nums">
|
<span class="font-normal text-muted-foreground tabular-nums">
|
||||||
· Показано {jobs.length} из {jobsFetchedTotal}
|
· Показано {jobs.length} из {jobsFetchedTotal}
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -163,20 +163,18 @@
|
|||||||
<div
|
<div
|
||||||
class="rounded-md border border-chart-1/25 bg-chart-1/5 px-2.5 py-2 dark:bg-chart-1/10"
|
class="rounded-md border border-chart-1/25 bg-chart-1/5 px-2.5 py-2 dark:bg-chart-1/10"
|
||||||
>
|
>
|
||||||
<p
|
<p class="flex items-center gap-1 text-[11px] font-medium text-chart-1 uppercase">
|
||||||
class="text-chart-1 flex items-center gap-1 text-[11px] font-medium uppercase"
|
|
||||||
>
|
|
||||||
<CircleDot class="size-3.5 shrink-0" aria-hidden="true" />
|
<CircleDot class="size-3.5 shrink-0" aria-hidden="true" />
|
||||||
Статус
|
Статус
|
||||||
</p>
|
</p>
|
||||||
<Badge class="mt-1" variant={jobStatusVariant(job.status)}>{jobStatusRu(job.status)}</Badge>
|
<Badge class="mt-1" variant={jobStatusVariant(job.status)}
|
||||||
|
>{jobStatusRu(job.status)}</Badge
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="rounded-md border border-chart-2/25 bg-chart-2/5 px-2.5 py-2 dark:bg-chart-2/10"
|
class="rounded-md border border-chart-2/25 bg-chart-2/5 px-2.5 py-2 dark:bg-chart-2/10"
|
||||||
>
|
>
|
||||||
<p
|
<p class="flex items-center gap-1 text-[11px] font-medium text-chart-2 uppercase">
|
||||||
class="text-chart-2 flex items-center gap-1 text-[11px] font-medium uppercase"
|
|
||||||
>
|
|
||||||
<CalendarClock class="size-3.5 shrink-0" aria-hidden="true" />
|
<CalendarClock class="size-3.5 shrink-0" aria-hidden="true" />
|
||||||
Создана
|
Создана
|
||||||
</p>
|
</p>
|
||||||
@@ -185,9 +183,7 @@
|
|||||||
<div
|
<div
|
||||||
class="rounded-md border border-chart-3/25 bg-chart-3/5 px-2.5 py-2 dark:bg-chart-3/10"
|
class="rounded-md border border-chart-3/25 bg-chart-3/5 px-2.5 py-2 dark:bg-chart-3/10"
|
||||||
>
|
>
|
||||||
<p
|
<p class="flex items-center gap-1 text-[11px] font-medium text-chart-3 uppercase">
|
||||||
class="text-chart-3 flex items-center gap-1 text-[11px] font-medium uppercase"
|
|
||||||
>
|
|
||||||
<PlayCircle class="size-3.5 shrink-0" aria-hidden="true" />
|
<PlayCircle class="size-3.5 shrink-0" aria-hidden="true" />
|
||||||
Запущена
|
Запущена
|
||||||
</p>
|
</p>
|
||||||
@@ -196,9 +192,7 @@
|
|||||||
<div
|
<div
|
||||||
class="rounded-md border border-chart-4/25 bg-chart-4/5 px-2.5 py-2 dark:bg-chart-4/10"
|
class="rounded-md border border-chart-4/25 bg-chart-4/5 px-2.5 py-2 dark:bg-chart-4/10"
|
||||||
>
|
>
|
||||||
<p
|
<p class="flex items-center gap-1 text-[11px] font-medium text-chart-4 uppercase">
|
||||||
class="text-chart-4 flex items-center gap-1 text-[11px] font-medium uppercase"
|
|
||||||
>
|
|
||||||
<Flag class="size-3.5 shrink-0" aria-hidden="true" />
|
<Flag class="size-3.5 shrink-0" aria-hidden="true" />
|
||||||
Завершена
|
Завершена
|
||||||
</p>
|
</p>
|
||||||
@@ -343,7 +337,7 @@
|
|||||||
class="min-w-0 overflow-hidden rounded-md border border-chart-1/30 bg-chart-1/5 p-2.5 dark:bg-chart-1/10"
|
class="min-w-0 overflow-hidden rounded-md border border-chart-1/30 bg-chart-1/5 p-2.5 dark:bg-chart-1/10"
|
||||||
>
|
>
|
||||||
<p
|
<p
|
||||||
class="text-chart-1 flex items-center gap-1 text-[11px] font-medium uppercase"
|
class="flex items-center gap-1 text-[11px] font-medium text-chart-1 uppercase"
|
||||||
>
|
>
|
||||||
<Layers class="size-3.5 shrink-0" aria-hidden="true" />
|
<Layers class="size-3.5 shrink-0" aria-hidden="true" />
|
||||||
Агрегация
|
Агрегация
|
||||||
@@ -354,7 +348,7 @@
|
|||||||
class="min-w-0 overflow-hidden rounded-md border border-chart-2/30 bg-chart-2/5 p-2.5 dark:bg-chart-2/10"
|
class="min-w-0 overflow-hidden rounded-md border border-chart-2/30 bg-chart-2/5 p-2.5 dark:bg-chart-2/10"
|
||||||
>
|
>
|
||||||
<p
|
<p
|
||||||
class="text-chart-2 flex items-center gap-1 text-[11px] font-medium uppercase"
|
class="flex items-center gap-1 text-[11px] font-medium text-chart-2 uppercase"
|
||||||
>
|
>
|
||||||
<Globe class="size-3.5 shrink-0" aria-hidden="true" />
|
<Globe class="size-3.5 shrink-0" aria-hidden="true" />
|
||||||
Домены
|
Домены
|
||||||
@@ -365,7 +359,7 @@
|
|||||||
class="min-w-0 overflow-hidden rounded-md border border-chart-3/30 bg-chart-3/5 p-2.5 dark:bg-chart-3/10"
|
class="min-w-0 overflow-hidden rounded-md border border-chart-3/30 bg-chart-3/5 p-2.5 dark:bg-chart-3/10"
|
||||||
>
|
>
|
||||||
<p
|
<p
|
||||||
class="text-chart-3 flex items-center gap-1 text-[11px] font-medium uppercase"
|
class="flex items-center gap-1 text-[11px] font-medium text-chart-3 uppercase"
|
||||||
>
|
>
|
||||||
<Binary class="size-3.5 shrink-0" aria-hidden="true" />
|
<Binary class="size-3.5 shrink-0" aria-hidden="true" />
|
||||||
ASN
|
ASN
|
||||||
@@ -376,7 +370,7 @@
|
|||||||
class="min-w-0 overflow-hidden rounded-md border border-chart-4/30 bg-chart-4/5 p-2.5 dark:bg-chart-4/10"
|
class="min-w-0 overflow-hidden rounded-md border border-chart-4/30 bg-chart-4/5 p-2.5 dark:bg-chart-4/10"
|
||||||
>
|
>
|
||||||
<p
|
<p
|
||||||
class="text-chart-4 flex items-center gap-1 text-[11px] font-medium uppercase"
|
class="flex items-center gap-1 text-[11px] font-medium text-chart-4 uppercase"
|
||||||
>
|
>
|
||||||
<Link2 class="size-3.5 shrink-0" aria-hidden="true" />
|
<Link2 class="size-3.5 shrink-0" aria-hidden="true" />
|
||||||
CDN / IP-диапазоны
|
CDN / IP-диапазоны
|
||||||
@@ -427,7 +421,9 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="min-w-0 space-y-1.5 rounded-lg border p-3">
|
<div class="min-w-0 space-y-1.5 rounded-lg border p-3">
|
||||||
<p class="text-sm font-medium">IP-диапазоны: итог по статическим диапазонам</p>
|
<p class="text-sm font-medium">
|
||||||
|
IP-диапазоны: итог по статическим диапазонам
|
||||||
|
</p>
|
||||||
<JobReportTableBlock
|
<JobReportTableBlock
|
||||||
rows={jobReport.ipRanges as RowData[]}
|
rows={jobReport.ipRanges as RowData[]}
|
||||||
columns={reportCols}
|
columns={reportCols}
|
||||||
|
|||||||
@@ -18,7 +18,9 @@
|
|||||||
onReload: () => void;
|
onReload: () => void;
|
||||||
onRefreshBirdStatus: () => void;
|
onRefreshBirdStatus: () => void;
|
||||||
onOpenBirdProtocols: () => void;
|
onOpenBirdProtocols: () => void;
|
||||||
birdHealthyBadgeVariant: (h: boolean | null | undefined) => 'default' | 'secondary' | 'outline' | 'destructive';
|
birdHealthyBadgeVariant: (
|
||||||
|
h: boolean | null | undefined
|
||||||
|
) => 'default' | 'secondary' | 'outline' | 'destructive';
|
||||||
birdHealthyShortLabel: (h: boolean | null | undefined) => string;
|
birdHealthyShortLabel: (h: boolean | null | undefined) => string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -38,24 +40,28 @@
|
|||||||
|
|
||||||
<div class="grid grid-cols-1 items-stretch gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
<div class="grid grid-cols-1 items-stretch gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||||
<Card
|
<Card
|
||||||
class={cn(
|
class={cn('min-w-0 overflow-hidden border-l-4 border-l-chart-1 bg-chart-1/5 p-4 shadow-sm')}
|
||||||
'min-w-0 overflow-hidden border-l-4 border-l-chart-1 bg-chart-1/5 p-4 shadow-sm'
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div class="flex min-w-0 flex-1 gap-3">
|
<div class="flex min-w-0 flex-1 gap-3">
|
||||||
<div
|
<div
|
||||||
class="bg-chart-1/20 flex size-11 shrink-0 items-center justify-center rounded-xl"
|
class="flex size-11 shrink-0 items-center justify-center rounded-xl bg-chart-1/20"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
<Play class="text-chart-1 size-5" />
|
<Play class="size-5 text-chart-1" />
|
||||||
</div>
|
</div>
|
||||||
<div class="min-w-0 flex-1 space-y-1">
|
<div class="min-w-0 flex-1 space-y-1">
|
||||||
<p class="font-semibold">Применить ко всем спикерам</p>
|
<p class="font-semibold">Применить ко всем спикерам</p>
|
||||||
<p class="text-muted-foreground max-w-[42ch] text-sm">Применить текущую конфигурацию на всех BIRD-спикерах</p>
|
<p class="max-w-[42ch] text-sm text-muted-foreground">
|
||||||
|
Применить текущую конфигурацию на всех BIRD-спикерах
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button class="w-full shrink-0 self-start sm:w-auto sm:self-auto" onclick={onApply} disabled={applying}>
|
<Button
|
||||||
|
class="w-full shrink-0 self-start sm:w-auto sm:self-auto"
|
||||||
|
onclick={onApply}
|
||||||
|
disabled={applying}
|
||||||
|
>
|
||||||
<Play class="size-4" aria-hidden="true" />
|
<Play class="size-4" aria-hidden="true" />
|
||||||
Применить
|
Применить
|
||||||
</Button>
|
</Button>
|
||||||
@@ -63,21 +69,21 @@
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card
|
<Card
|
||||||
class={cn(
|
class={cn('min-w-0 overflow-hidden border-l-4 border-l-chart-4 bg-chart-4/5 p-4 shadow-sm')}
|
||||||
'min-w-0 overflow-hidden border-l-4 border-l-chart-4 bg-chart-4/5 p-4 shadow-sm'
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div class="flex min-w-0 flex-1 gap-3">
|
<div class="flex min-w-0 flex-1 gap-3">
|
||||||
<div
|
<div
|
||||||
class="bg-chart-4/20 flex size-11 shrink-0 items-center justify-center rounded-xl"
|
class="flex size-11 shrink-0 items-center justify-center rounded-xl bg-chart-4/20"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
<RotateCcw class="text-chart-4 size-5" />
|
<RotateCcw class="size-5 text-chart-4" />
|
||||||
</div>
|
</div>
|
||||||
<div class="min-w-0 flex-1 space-y-1">
|
<div class="min-w-0 flex-1 space-y-1">
|
||||||
<p class="font-semibold">Перезагрузка BIRD</p>
|
<p class="font-semibold">Перезагрузка BIRD</p>
|
||||||
<p class="text-muted-foreground max-w-[42ch] text-sm">Перезагрузить конфигурацию BIRD на всех спикерах</p>
|
<p class="max-w-[42ch] text-sm text-muted-foreground">
|
||||||
|
Перезагрузить конфигурацию BIRD на всех спикерах
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
@@ -101,26 +107,32 @@
|
|||||||
<div class="min-w-0 flex-1 space-y-1">
|
<div class="min-w-0 flex-1 space-y-1">
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
<div
|
<div
|
||||||
class="bg-info/15 mr-0.5 flex size-9 items-center justify-center rounded-lg"
|
class="mr-0.5 flex size-9 items-center justify-center rounded-lg bg-info/15"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
<Bird class="text-info size-5 shrink-0" />
|
<Bird class="size-5 shrink-0 text-info" />
|
||||||
</div>
|
</div>
|
||||||
<p class="font-semibold">Состояние BIRD</p>
|
<p class="font-semibold">Состояние BIRD</p>
|
||||||
{#if birdStatus}
|
{#if birdStatus}
|
||||||
<Badge variant={birdHealthyBadgeVariant(birdStatus.healthy)}>{birdHealthyShortLabel(birdStatus.healthy)}</Badge>
|
<Badge variant={birdHealthyBadgeVariant(birdStatus.healthy)}
|
||||||
|
>{birdHealthyShortLabel(birdStatus.healthy)}</Badge
|
||||||
|
>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<p class="text-foreground/80 max-w-[56ch] text-sm">
|
<p class="max-w-[56ch] text-sm text-foreground/80">
|
||||||
Локально на хосте API: <code class="bg-muted rounded px-1 text-xs">birdc show protocols</code>. Не заменяет мониторинг спикеров.
|
Локально на хосте API: <code class="rounded bg-muted px-1 text-xs"
|
||||||
|
>birdc show protocols</code
|
||||||
|
>. Не заменяет мониторинг спикеров.
|
||||||
</p>
|
</p>
|
||||||
{#if birdLoading}
|
{#if birdLoading}
|
||||||
<p class="text-foreground/80 text-sm">Загрузка…</p>
|
<p class="text-sm text-foreground/80">Загрузка…</p>
|
||||||
{:else if birdStatus}
|
{:else if birdStatus}
|
||||||
{#if !birdStatus.birdc_configured}
|
{#if !birdStatus.birdc_configured}
|
||||||
<p class="text-foreground/80 text-sm">{birdStatus.message ?? 'birdc не настроен на API.'}</p>
|
<p class="text-sm text-foreground/80">
|
||||||
|
{birdStatus.message ?? 'birdc не настроен на API.'}
|
||||||
|
</p>
|
||||||
{:else if birdStatus.error}
|
{:else if birdStatus.error}
|
||||||
<p class="text-destructive text-sm">{birdStatus.error}</p>
|
<p class="text-sm text-destructive">{birdStatus.error}</p>
|
||||||
{:else}
|
{:else}
|
||||||
<p class="text-sm">
|
<p class="text-sm">
|
||||||
<span class="text-muted-foreground">BGP сессий:</span>
|
<span class="text-muted-foreground">BGP сессий:</span>
|
||||||
@@ -131,7 +143,7 @@
|
|||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
{:else}
|
{:else}
|
||||||
<p class="text-foreground/80 text-sm">Статус не загружен</p>
|
<p class="text-sm text-foreground/80">Статус не загружен</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { RevisionRow } from '$lib/api/types.js';
|
import type { RevisionRow } from '$lib/api/types.js';
|
||||||
import { Button } from '$lib/components/ui/button/index.js';
|
import { Button } from '$lib/components/ui/button/index.js';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '$lib/components/ui/card/index.js';
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle
|
||||||
|
} from '$lib/components/ui/card/index.js';
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -41,60 +47,69 @@
|
|||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<CardTitle class="text-base">История ревизий</CardTitle>
|
<CardTitle class="text-base">История ревизий</CardTitle>
|
||||||
<CardDescription class="max-w-[75ch]">
|
<CardDescription class="max-w-[75ch]">
|
||||||
Создаются задачей module_refresh (CDN / IP / AS и т.д.); в превью есть полный текст BIRD с инклюдами
|
Создаются задачей module_refresh (CDN / IP / AS и т.д.); в превью есть полный текст BIRD с
|
||||||
|
инклюдами
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="outline" size="sm" class="shrink-0 self-start sm:self-auto" onclick={onReload} disabled={revLoading}>
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
class="shrink-0 self-start sm:self-auto"
|
||||||
|
onclick={onReload}
|
||||||
|
disabled={revLoading}
|
||||||
|
>
|
||||||
<RefreshCw class={revLoading ? 'animate-spin' : ''} />
|
<RefreshCw class={revLoading ? 'animate-spin' : ''} />
|
||||||
</Button>
|
</Button>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent class="min-w-0 p-0">
|
<CardContent class="min-w-0 p-0">
|
||||||
<div class="max-w-full overflow-x-auto overscroll-x-contain [scrollbar-gutter:stable]">
|
<div class="max-w-full overflow-x-auto overscroll-x-contain [scrollbar-gutter:stable]">
|
||||||
<Table class="min-w-[44rem]">
|
<Table class="min-w-[44rem]">
|
||||||
<TableHeader>
|
<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>
|
<TableRow>
|
||||||
<TableCell class="font-mono text-xs">{rev.id.slice(0, 8)}…</TableCell>
|
<TableHead>ID</TableHead>
|
||||||
<TableCell class="text-sm">{formatDate(rev.created_at)}</TableCell>
|
<TableHead>Создана</TableHead>
|
||||||
<TableCell>{rev.materialized_prefix_count}</TableCell>
|
<TableHead>Префиксов</TableHead>
|
||||||
<TableCell class="font-mono text-xs text-muted-foreground">{rev.content_hash.slice(0, 12)}…</TableCell>
|
<TableHead>Хэш</TableHead>
|
||||||
<TableCell>
|
<TableHead class="w-32"></TableHead>
|
||||||
<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>
|
</TableRow>
|
||||||
{:else}
|
</TableHeader>
|
||||||
<TableRow>
|
<TableBody>
|
||||||
<TableCell colspan={5} class="text-muted-foreground py-8 text-center">
|
{#each revisions as rev (rev.id)}
|
||||||
{revLoading ? 'Загрузка…' : 'Нет ревизий'}
|
<TableRow>
|
||||||
</TableCell>
|
<TableCell class="font-mono text-xs">{rev.id.slice(0, 8)}…</TableCell>
|
||||||
</TableRow>
|
<TableCell class="text-sm">{formatDate(rev.created_at)}</TableCell>
|
||||||
{/each}
|
<TableCell>{rev.materialized_prefix_count}</TableCell>
|
||||||
</TableBody>
|
<TableCell class="font-mono text-xs text-muted-foreground"
|
||||||
</Table>
|
>{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>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -53,7 +53,10 @@ function parseIPv6(addrPart: string): { words: number[]; pl: number } | null {
|
|||||||
}
|
}
|
||||||
const missing = 8 - left.length - right.length;
|
const missing = 8 - left.length - right.length;
|
||||||
if (missing < 0) return null;
|
if (missing < 0) return null;
|
||||||
return { words: [...left.map(parseGroup), ...Array(missing).fill(0), ...right.map(parseGroup)], pl: 128 };
|
return {
|
||||||
|
words: [...left.map(parseGroup), ...Array(missing).fill(0), ...right.map(parseGroup)],
|
||||||
|
pl: 128
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
type SortKey =
|
type SortKey =
|
||||||
|
|||||||
@@ -18,10 +18,10 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<TooltipProvider delayDuration={0}>
|
<TooltipProvider delayDuration={0}>
|
||||||
<div class="bg-background flex min-h-screen">
|
<div class="flex min-h-screen bg-background">
|
||||||
<AppSidebar bind:collapsed bind:theme />
|
<AppSidebar bind:collapsed bind:theme />
|
||||||
<div class="flex min-w-0 flex-1 flex-col">
|
<div class="flex min-w-0 flex-1 flex-col">
|
||||||
<div class="border-border flex items-center gap-2 border-b px-4 py-2 md:hidden">
|
<div class="flex items-center gap-2 border-b border-border px-4 py-2 md:hidden">
|
||||||
<AppMobileNav bind:open={mobileNavOpen} bind:theme />
|
<AppMobileNav bind:open={mobileNavOpen} bind:theme />
|
||||||
<span class="font-semibold tracking-tight">EvoBGP</span>
|
<span class="font-semibold tracking-tight">EvoBGP</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -28,12 +28,12 @@
|
|||||||
<Menu class="size-5" />
|
<Menu class="size-5" />
|
||||||
</Button>
|
</Button>
|
||||||
</SheetTrigger>
|
</SheetTrigger>
|
||||||
<SheetContent side="left" class="bg-sidebar text-sidebar-foreground flex w-72 flex-col gap-0 p-0">
|
<SheetContent side="left" class="flex w-72 flex-col gap-0 bg-sidebar p-0 text-sidebar-foreground">
|
||||||
<SheetHeader class="border-border border-b p-4 text-left">
|
<SheetHeader class="border-b border-border p-4 text-left">
|
||||||
<SheetTitle>
|
<SheetTitle>
|
||||||
<a href={resolve('/')} class="font-semibold tracking-tight no-underline">EvoBGP</a>
|
<a href={resolve('/')} class="font-semibold tracking-tight no-underline">EvoBGP</a>
|
||||||
</SheetTitle>
|
</SheetTitle>
|
||||||
<p class="text-sidebar-foreground/50 text-xs font-normal">Панель управления</p>
|
<p class="text-xs font-normal text-sidebar-foreground/50">Панель управления</p>
|
||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
<div class="flex items-center gap-2 px-4 py-2">
|
<div class="flex items-center gap-2 px-4 py-2">
|
||||||
<ThemeMenu bind:theme />
|
<ThemeMenu bind:theme />
|
||||||
|
|||||||
@@ -25,8 +25,13 @@
|
|||||||
|
|
||||||
function linkClass(active: boolean, iconOnly: boolean) {
|
function linkClass(active: boolean, iconOnly: boolean) {
|
||||||
return cn(
|
return cn(
|
||||||
buttonVariants({ variant: active ? 'secondary' : 'ghost', size: iconOnly ? 'icon-sm' : 'sm' }),
|
buttonVariants({
|
||||||
iconOnly ? 'flex w-full items-center justify-center no-underline' : 'flex w-full items-center justify-start gap-2 no-underline',
|
variant: active ? 'secondary' : 'ghost',
|
||||||
|
size: iconOnly ? 'icon-sm' : 'sm'
|
||||||
|
}),
|
||||||
|
iconOnly
|
||||||
|
? 'flex w-full items-center justify-center no-underline'
|
||||||
|
: 'flex w-full items-center justify-start gap-2 no-underline',
|
||||||
active &&
|
active &&
|
||||||
(iconOnly
|
(iconOnly
|
||||||
? 'ring-sidebar-primary/50 bg-sidebar-accent/90 text-sidebar-accent-foreground ring-1'
|
? 'ring-sidebar-primary/50 bg-sidebar-accent/90 text-sidebar-accent-foreground ring-1'
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
import PanelLeftClose from '@lucide/svelte/icons/panel-left-close';
|
import PanelLeftClose from '@lucide/svelte/icons/panel-left-close';
|
||||||
import PanelLeftOpen from '@lucide/svelte/icons/panel-left-open';
|
import PanelLeftOpen from '@lucide/svelte/icons/panel-left-open';
|
||||||
import AppNavLinks from './app-nav-links.svelte';
|
import AppNavLinks from './app-nav-links.svelte';
|
||||||
|
import AppVersion from './app-version.svelte';
|
||||||
import ThemeMenu from './theme-menu.svelte';
|
import ThemeMenu from './theme-menu.svelte';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -20,7 +21,7 @@
|
|||||||
|
|
||||||
<aside
|
<aside
|
||||||
class={cn(
|
class={cn(
|
||||||
'border-border bg-sidebar sticky top-0 hidden h-screen shrink-0 flex-col overflow-hidden border-r transition-all duration-200 md:flex',
|
'sticky top-0 hidden h-screen shrink-0 flex-col overflow-hidden border-r border-border bg-sidebar transition-all duration-200 md:flex',
|
||||||
collapsed ? 'w-14' : 'w-56'
|
collapsed ? 'w-14' : 'w-56'
|
||||||
)}
|
)}
|
||||||
aria-label="Основная навигация"
|
aria-label="Основная навигация"
|
||||||
@@ -28,10 +29,11 @@
|
|||||||
<div class={cn('flex items-center gap-2 p-3', collapsed && 'justify-center')}>
|
<div class={cn('flex items-center gap-2 p-3', collapsed && 'justify-center')}>
|
||||||
{#if !collapsed}
|
{#if !collapsed}
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<a href={resolve('/')} class="text-sidebar-foreground block truncate font-semibold tracking-tight"
|
<a
|
||||||
>EvoBGP</a
|
href={resolve('/')}
|
||||||
|
class="block truncate font-semibold tracking-tight text-sidebar-foreground">EvoBGP</a
|
||||||
>
|
>
|
||||||
<p class="text-sidebar-foreground/50 truncate text-xs">Панель управления</p>
|
<p class="truncate text-xs text-sidebar-foreground/50">Панель управления</p>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<ThemeMenu bind:theme />
|
<ThemeMenu bind:theme />
|
||||||
@@ -40,7 +42,7 @@
|
|||||||
onclick={() => (collapsed = !collapsed)}
|
onclick={() => (collapsed = !collapsed)}
|
||||||
class={cn(
|
class={cn(
|
||||||
buttonVariants({ variant: 'ghost', size: 'icon-sm' }),
|
buttonVariants({ variant: 'ghost', size: 'icon-sm' }),
|
||||||
'text-sidebar-foreground/60 hover:text-sidebar-foreground shrink-0'
|
'shrink-0 text-sidebar-foreground/60 hover:text-sidebar-foreground'
|
||||||
)}
|
)}
|
||||||
aria-label={collapsed ? 'Развернуть меню' : 'Свернуть меню'}
|
aria-label={collapsed ? 'Развернуть меню' : 'Свернуть меню'}
|
||||||
>
|
>
|
||||||
@@ -53,4 +55,5 @@
|
|||||||
</div>
|
</div>
|
||||||
<Separator />
|
<Separator />
|
||||||
<AppNavLinks {collapsed} />
|
<AppNavLinks {collapsed} />
|
||||||
|
<AppVersion />
|
||||||
</aside>
|
</aside>
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
|
|
||||||
<header
|
<header
|
||||||
class={cn(
|
class={cn(
|
||||||
'border-border bg-background/95 supports-[backdrop-filter]:bg-background/60 sticky top-0 z-30 flex flex-col gap-3 border-b px-4 py-3 backdrop-blur md:px-6',
|
'sticky top-0 z-30 flex flex-col gap-3 border-b border-border bg-background/95 px-4 py-3 backdrop-blur supports-[backdrop-filter]:bg-background/60 md:px-6',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -28,7 +28,7 @@
|
|||||||
<h1 class="text-lg font-semibold tracking-tight md:text-xl">{title}</h1>
|
<h1 class="text-lg font-semibold tracking-tight md:text-xl">{title}</h1>
|
||||||
{/if}
|
{/if}
|
||||||
{#if description}
|
{#if description}
|
||||||
<p class="text-muted-foreground mt-0.5 text-sm">{description}</p>
|
<p class="mt-0.5 text-sm text-muted-foreground">{description}</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{#if actions}
|
{#if actions}
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { apiJSON } from '$lib/api/client.js';
|
||||||
|
|
||||||
|
type VersionInfo = {
|
||||||
|
version?: string;
|
||||||
|
api_version?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
let label = $state<string | null>(null);
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const info = await apiJSON<VersionInfo>('/v1/version');
|
||||||
|
const ver =
|
||||||
|
(typeof info.version === 'string' && info.version) ||
|
||||||
|
(typeof info.api_version === 'string' && info.api_version) ||
|
||||||
|
'';
|
||||||
|
label = ver ? `v${ver}` : null;
|
||||||
|
} catch {
|
||||||
|
label = null;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if label}
|
||||||
|
<p class="truncate px-3 pb-3 text-xs text-sidebar-foreground/45" title={label}>{label}</p>
|
||||||
|
{/if}
|
||||||
@@ -14,14 +14,17 @@
|
|||||||
import Moon from '@lucide/svelte/icons/moon';
|
import Moon from '@lucide/svelte/icons/moon';
|
||||||
import Monitor from '@lucide/svelte/icons/monitor';
|
import Monitor from '@lucide/svelte/icons/monitor';
|
||||||
|
|
||||||
let { theme = $bindable<ThemePreference>('system'), class: className }: { theme?: ThemePreference; class?: string } = $props();
|
let {
|
||||||
|
theme = $bindable<ThemePreference>('system'),
|
||||||
|
class: className
|
||||||
|
}: { theme?: ThemePreference; class?: string } = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger
|
<DropdownMenuTrigger
|
||||||
class={cn(
|
class={cn(
|
||||||
buttonVariants({ variant: 'ghost', size: 'icon-sm' }),
|
buttonVariants({ variant: 'ghost', size: 'icon-sm' }),
|
||||||
'text-sidebar-foreground/60 hover:text-sidebar-foreground shrink-0',
|
'shrink-0 text-sidebar-foreground/60 hover:text-sidebar-foreground',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
aria-label="Тема оформления"
|
aria-label="Тема оформления"
|
||||||
|
|||||||
@@ -30,7 +30,7 @@
|
|||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
<h1 class="text-2xl font-semibold tracking-tight">{title}</h1>
|
<h1 class="text-2xl font-semibold tracking-tight">{title}</h1>
|
||||||
{#if description}
|
{#if description}
|
||||||
<p class="text-muted-foreground mt-1 text-sm">{description}</p>
|
<p class="mt-1 text-sm text-muted-foreground">{description}</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,10 +4,10 @@
|
|||||||
|
|
||||||
## Layout
|
## Layout
|
||||||
|
|
||||||
| Token | Значение | Tailwind |
|
| Token | Значение | Tailwind |
|
||||||
|-------|----------|----------|
|
| ---------------- | -------- | ---------------------------------------- |
|
||||||
| `--page-padding` | 1.5rem | `p-page` через `p-[var(--page-padding)]` |
|
| `--page-padding` | 1.5rem | `p-page` через `p-[var(--page-padding)]` |
|
||||||
| `--section-gap` | 1.5rem | `gap-section` |
|
| `--section-gap` | 1.5rem | `gap-section` |
|
||||||
|
|
||||||
## Радиусы
|
## Радиусы
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
|
import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
|
||||||
import {
|
import {
|
||||||
buttonVariants,
|
buttonVariants,
|
||||||
type ButtonVariant,
|
type ButtonVariant,
|
||||||
type ButtonSize,
|
type ButtonSize
|
||||||
} from "$lib/ui/core/button/index.js";
|
} from '$lib/ui/core/button/index.js';
|
||||||
import { cn } from "$lib/utils.js";
|
import { cn } from '$lib/utils.js';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
class: className,
|
class: className,
|
||||||
variant = "default",
|
variant = 'default',
|
||||||
size = "default",
|
size = 'default',
|
||||||
...restProps
|
...restProps
|
||||||
}: AlertDialogPrimitive.ActionProps & {
|
}: AlertDialogPrimitive.ActionProps & {
|
||||||
variant?: ButtonVariant;
|
variant?: ButtonVariant;
|
||||||
@@ -22,6 +22,6 @@
|
|||||||
<AlertDialogPrimitive.Action
|
<AlertDialogPrimitive.Action
|
||||||
bind:ref
|
bind:ref
|
||||||
data-slot="alert-dialog-action"
|
data-slot="alert-dialog-action"
|
||||||
class={cn(buttonVariants({ variant, size }), "cn-alert-dialog-action", className)}
|
class={cn(buttonVariants({ variant, size }), 'cn-alert-dialog-action', className)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
/>
|
/>
|
||||||
@@ -1,17 +1,17 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
|
import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
|
||||||
import {
|
import {
|
||||||
buttonVariants,
|
buttonVariants,
|
||||||
type ButtonVariant,
|
type ButtonVariant,
|
||||||
type ButtonSize,
|
type ButtonSize
|
||||||
} from "$lib/ui/core/button/index.js";
|
} from '$lib/ui/core/button/index.js';
|
||||||
import { cn } from "$lib/utils.js";
|
import { cn } from '$lib/utils.js';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
class: className,
|
class: className,
|
||||||
variant = "outline",
|
variant = 'outline',
|
||||||
size = "default",
|
size = 'default',
|
||||||
...restProps
|
...restProps
|
||||||
}: AlertDialogPrimitive.CancelProps & {
|
}: AlertDialogPrimitive.CancelProps & {
|
||||||
variant?: ButtonVariant;
|
variant?: ButtonVariant;
|
||||||
@@ -22,6 +22,6 @@
|
|||||||
<AlertDialogPrimitive.Cancel
|
<AlertDialogPrimitive.Cancel
|
||||||
bind:ref
|
bind:ref
|
||||||
data-slot="alert-dialog-cancel"
|
data-slot="alert-dialog-cancel"
|
||||||
class={cn(buttonVariants({ variant, size }), "cn-alert-dialog-cancel", className)}
|
class={cn(buttonVariants({ variant, size }), 'cn-alert-dialog-cancel', className)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
/>
|
/>
|
||||||
@@ -1,18 +1,18 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
|
import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
|
||||||
import AlertDialogPortal from "./alert-dialog-portal.svelte";
|
import AlertDialogPortal from './alert-dialog-portal.svelte';
|
||||||
import AlertDialogOverlay from "./alert-dialog-overlay.svelte";
|
import AlertDialogOverlay from './alert-dialog-overlay.svelte';
|
||||||
import { cn, type WithoutChild, type WithoutChildrenOrChild } from "$lib/utils.js";
|
import { cn, type WithoutChild, type WithoutChildrenOrChild } from '$lib/utils.js';
|
||||||
import type { ComponentProps } from "svelte";
|
import type { ComponentProps } from 'svelte';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
class: className,
|
class: className,
|
||||||
size = "default",
|
size = 'default',
|
||||||
portalProps,
|
portalProps,
|
||||||
...restProps
|
...restProps
|
||||||
}: WithoutChild<AlertDialogPrimitive.ContentProps> & {
|
}: WithoutChild<AlertDialogPrimitive.ContentProps> & {
|
||||||
size?: "default" | "sm";
|
size?: 'default' | 'sm';
|
||||||
portalProps?: WithoutChildrenOrChild<ComponentProps<typeof AlertDialogPortal>>;
|
portalProps?: WithoutChildrenOrChild<ComponentProps<typeof AlertDialogPortal>>;
|
||||||
} = $props();
|
} = $props();
|
||||||
</script>
|
</script>
|
||||||
@@ -24,7 +24,7 @@
|
|||||||
data-slot="alert-dialog-content"
|
data-slot="alert-dialog-content"
|
||||||
data-size={size}
|
data-size={size}
|
||||||
class={cn(
|
class={cn(
|
||||||
"data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 bg-popover text-popover-foreground ring-foreground/10 gap-4 rounded-xl p-4 ring-1 duration-100 data-[size=default]:max-w-xs data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-sm group/alert-dialog-content fixed top-1/2 left-1/2 z-50 grid w-full -translate-x-1/2 -translate-y-1/2 outline-none",
|
'group/alert-dialog-content fixed top-1/2 left-1/2 z-50 grid w-full -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-[size=default]:max-w-xs data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-sm',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
|
import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
|
||||||
import { cn } from "$lib/utils.js";
|
import { cn } from '$lib/utils.js';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
@@ -12,6 +12,9 @@
|
|||||||
<AlertDialogPrimitive.Description
|
<AlertDialogPrimitive.Description
|
||||||
bind:ref
|
bind:ref
|
||||||
data-slot="alert-dialog-description"
|
data-slot="alert-dialog-description"
|
||||||
class={cn("text-muted-foreground *:[a]:hover:text-foreground text-sm text-balance md:text-pretty *:[a]:underline *:[a]:underline-offset-3", className)}
|
class={cn(
|
||||||
|
'text-sm text-balance text-muted-foreground md:text-pretty *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground',
|
||||||
|
className
|
||||||
|
)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
/>
|
/>
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
import { cn, type WithElementRef } from '$lib/utils.js';
|
||||||
import type { HTMLAttributes } from "svelte/elements";
|
import type { HTMLAttributes } from 'svelte/elements';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
bind:this={ref}
|
bind:this={ref}
|
||||||
data-slot="alert-dialog-footer"
|
data-slot="alert-dialog-footer"
|
||||||
class={cn(
|
class={cn(
|
||||||
"bg-muted/50 -mx-4 -mb-4 rounded-b-xl border-t p-4 flex flex-col-reverse gap-2 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end",
|
'-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { HTMLAttributes } from "svelte/elements";
|
import type { HTMLAttributes } from 'svelte/elements';
|
||||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
import { cn, type WithElementRef } from '$lib/utils.js';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
@@ -13,7 +13,10 @@
|
|||||||
<div
|
<div
|
||||||
bind:this={ref}
|
bind:this={ref}
|
||||||
data-slot="alert-dialog-header"
|
data-slot="alert-dialog-header"
|
||||||
class={cn("grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-4 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]", className)}
|
class={cn(
|
||||||
|
'grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-4 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]',
|
||||||
|
className
|
||||||
|
)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
>
|
>
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { HTMLAttributes } from "svelte/elements";
|
import type { HTMLAttributes } from 'svelte/elements';
|
||||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
import { cn, type WithElementRef } from '$lib/utils.js';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
@@ -13,7 +13,10 @@
|
|||||||
<div
|
<div
|
||||||
bind:this={ref}
|
bind:this={ref}
|
||||||
data-slot="alert-dialog-media"
|
data-slot="alert-dialog-media"
|
||||||
class={cn("bg-muted mb-2 inline-flex size-10 items-center justify-center rounded-md sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-6", className)}
|
class={cn(
|
||||||
|
"mb-2 inline-flex size-10 items-center justify-center rounded-md bg-muted sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-6",
|
||||||
|
className
|
||||||
|
)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
>
|
>
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
|
import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
|
||||||
import { cn } from "$lib/utils.js";
|
import { cn } from '$lib/utils.js';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
@@ -12,6 +12,9 @@
|
|||||||
<AlertDialogPrimitive.Overlay
|
<AlertDialogPrimitive.Overlay
|
||||||
bind:ref
|
bind:ref
|
||||||
data-slot="alert-dialog-overlay"
|
data-slot="alert-dialog-overlay"
|
||||||
class={cn("data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs fixed inset-0 z-50", className)}
|
class={cn(
|
||||||
|
'fixed inset-0 z-50 bg-black/10 duration-100 data-closed:animate-out data-closed:fade-out-0 data-open:animate-in data-open:fade-in-0 supports-backdrop-filter:backdrop-blur-xs',
|
||||||
|
className
|
||||||
|
)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
/>
|
/>
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
|
import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
|
||||||
|
|
||||||
let { ...restProps }: AlertDialogPrimitive.PortalProps = $props();
|
let { ...restProps }: AlertDialogPrimitive.PortalProps = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
|
import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
|
||||||
import { cn } from "$lib/utils.js";
|
import { cn } from '$lib/utils.js';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
@@ -12,6 +12,9 @@
|
|||||||
<AlertDialogPrimitive.Title
|
<AlertDialogPrimitive.Title
|
||||||
bind:ref
|
bind:ref
|
||||||
data-slot="alert-dialog-title"
|
data-slot="alert-dialog-title"
|
||||||
class={cn("text-base font-medium sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2", className)}
|
class={cn(
|
||||||
|
'text-base font-medium sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2',
|
||||||
|
className
|
||||||
|
)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
/>
|
/>
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
|
import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
|
||||||
|
|
||||||
let { ref = $bindable(null), ...restProps }: AlertDialogPrimitive.TriggerProps = $props();
|
let { ref = $bindable(null), ...restProps }: AlertDialogPrimitive.TriggerProps = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
|
import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
|
||||||
|
|
||||||
let { open = $bindable(false), ...restProps }: AlertDialogPrimitive.RootProps = $props();
|
let { open = $bindable(false), ...restProps }: AlertDialogPrimitive.RootProps = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
import Root from "./alert-dialog.svelte";
|
import Root from './alert-dialog.svelte';
|
||||||
import Portal from "./alert-dialog-portal.svelte";
|
import Portal from './alert-dialog-portal.svelte';
|
||||||
import Trigger from "./alert-dialog-trigger.svelte";
|
import Trigger from './alert-dialog-trigger.svelte';
|
||||||
import Title from "./alert-dialog-title.svelte";
|
import Title from './alert-dialog-title.svelte';
|
||||||
import Action from "./alert-dialog-action.svelte";
|
import Action from './alert-dialog-action.svelte';
|
||||||
import Cancel from "./alert-dialog-cancel.svelte";
|
import Cancel from './alert-dialog-cancel.svelte';
|
||||||
import Footer from "./alert-dialog-footer.svelte";
|
import Footer from './alert-dialog-footer.svelte';
|
||||||
import Header from "./alert-dialog-header.svelte";
|
import Header from './alert-dialog-header.svelte';
|
||||||
import Overlay from "./alert-dialog-overlay.svelte";
|
import Overlay from './alert-dialog-overlay.svelte';
|
||||||
import Content from "./alert-dialog-content.svelte";
|
import Content from './alert-dialog-content.svelte';
|
||||||
import Description from "./alert-dialog-description.svelte";
|
import Description from './alert-dialog-description.svelte';
|
||||||
import Media from "./alert-dialog-media.svelte";
|
import Media from './alert-dialog-media.svelte';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
Root,
|
Root,
|
||||||
@@ -36,5 +36,5 @@ export {
|
|||||||
Overlay as AlertDialogOverlay,
|
Overlay as AlertDialogOverlay,
|
||||||
Content as AlertDialogContent,
|
Content as AlertDialogContent,
|
||||||
Description as AlertDialogDescription,
|
Description as AlertDialogDescription,
|
||||||
Media as AlertDialogMedia,
|
Media as AlertDialogMedia
|
||||||
};
|
};
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { HTMLAttributes } from "svelte/elements";
|
import type { HTMLAttributes } from 'svelte/elements';
|
||||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
import { cn, type WithElementRef } from '$lib/utils.js';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
<div
|
<div
|
||||||
bind:this={ref}
|
bind:this={ref}
|
||||||
data-slot="alert-action"
|
data-slot="alert-action"
|
||||||
class={cn("absolute top-2 right-2", className)}
|
class={cn('absolute top-2 right-2', className)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
>
|
>
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { HTMLAttributes } from "svelte/elements";
|
import type { HTMLAttributes } from 'svelte/elements';
|
||||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
import { cn, type WithElementRef } from '$lib/utils.js';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
bind:this={ref}
|
bind:this={ref}
|
||||||
data-slot="alert-description"
|
data-slot="alert-description"
|
||||||
class={cn(
|
class={cn(
|
||||||
"text-muted-foreground text-sm text-balance md:text-pretty [&_p:not(:last-child)]:mb-4 [&_a]:hover:text-foreground [&_a]:underline [&_a]:underline-offset-3",
|
'text-sm text-balance text-muted-foreground md:text-pretty [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { HTMLAttributes } from "svelte/elements";
|
import type { HTMLAttributes } from 'svelte/elements';
|
||||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
import { cn, type WithElementRef } from '$lib/utils.js';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
bind:this={ref}
|
bind:this={ref}
|
||||||
data-slot="alert-title"
|
data-slot="alert-title"
|
||||||
class={cn(
|
class={cn(
|
||||||
"font-medium group-has-[>svg]/alert:col-start-2 [&_a]:hover:text-foreground [&_a]:underline [&_a]:underline-offset-3",
|
'font-medium group-has-[>svg]/alert:col-start-2 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
|
|||||||
@@ -1,30 +1,31 @@
|
|||||||
<script lang="ts" module>
|
<script lang="ts" module>
|
||||||
import { type VariantProps, tv } from "tailwind-variants";
|
import { type VariantProps, tv } from 'tailwind-variants';
|
||||||
|
|
||||||
export const alertVariants = tv({
|
export const alertVariants = tv({
|
||||||
base: "grid gap-0.5 rounded-lg border px-2.5 py-2 text-left text-sm has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-2 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-4 group/alert relative w-full",
|
base: "grid gap-0.5 rounded-lg border px-2.5 py-2 text-left text-sm has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-2 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-4 group/alert relative w-full",
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default: "bg-card text-card-foreground",
|
default: 'bg-card text-card-foreground',
|
||||||
destructive: "text-destructive bg-card *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-current",
|
destructive:
|
||||||
},
|
'text-destructive bg-card *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-current'
|
||||||
|
}
|
||||||
},
|
},
|
||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
variant: "default",
|
variant: 'default'
|
||||||
},
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
export type AlertVariant = VariantProps<typeof alertVariants>["variant"];
|
export type AlertVariant = VariantProps<typeof alertVariants>['variant'];
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { HTMLAttributes } from "svelte/elements";
|
import type { HTMLAttributes } from 'svelte/elements';
|
||||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
import { cn, type WithElementRef } from '$lib/utils.js';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
class: className,
|
class: className,
|
||||||
variant = "default",
|
variant = 'default',
|
||||||
children,
|
children,
|
||||||
...restProps
|
...restProps
|
||||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
|
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import Root from "./alert.svelte";
|
import Root from './alert.svelte';
|
||||||
import Description from "./alert-description.svelte";
|
import Description from './alert-description.svelte';
|
||||||
import Title from "./alert-title.svelte";
|
import Title from './alert-title.svelte';
|
||||||
import Action from "./alert-action.svelte";
|
import Action from './alert-action.svelte';
|
||||||
export { alertVariants, type AlertVariant } from "./alert.svelte";
|
export { alertVariants, type AlertVariant } from './alert.svelte';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
Root,
|
Root,
|
||||||
@@ -13,5 +13,5 @@ export {
|
|||||||
Root as Alert,
|
Root as Alert,
|
||||||
Description as AlertDescription,
|
Description as AlertDescription,
|
||||||
Title as AlertTitle,
|
Title as AlertTitle,
|
||||||
Action as AlertAction,
|
Action as AlertAction
|
||||||
};
|
};
|
||||||
@@ -1,35 +1,36 @@
|
|||||||
<script lang="ts" module>
|
<script lang="ts" module>
|
||||||
import { type VariantProps, tv } from "tailwind-variants";
|
import { type VariantProps, tv } from 'tailwind-variants';
|
||||||
|
|
||||||
export const badgeVariants = tv({
|
export const badgeVariants = tv({
|
||||||
base: "h-5 gap-1 rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium transition-all has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&>svg]:size-3! focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive group/badge inline-flex w-fit shrink-0 items-center justify-center overflow-hidden whitespace-nowrap transition-colors focus-visible:ring-[3px] [&>svg]:pointer-events-none",
|
base: 'h-5 gap-1 rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium transition-all has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&>svg]:size-3! focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive group/badge inline-flex w-fit shrink-0 items-center justify-center overflow-hidden whitespace-nowrap transition-colors focus-visible:ring-[3px] [&>svg]:pointer-events-none',
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
default: 'bg-primary text-primary-foreground [a]:hover:bg-primary/80',
|
||||||
secondary: "bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
|
secondary: 'bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80',
|
||||||
destructive: "bg-destructive/10 [a]:hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 text-destructive dark:bg-destructive/20",
|
destructive:
|
||||||
outline: "border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
|
'bg-destructive/10 [a]:hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 text-destructive dark:bg-destructive/20',
|
||||||
ghost: "hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
|
outline: 'border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground',
|
||||||
link: "text-primary underline-offset-4 hover:underline",
|
ghost: 'hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50',
|
||||||
},
|
link: 'text-primary underline-offset-4 hover:underline'
|
||||||
|
}
|
||||||
},
|
},
|
||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
variant: "default",
|
variant: 'default'
|
||||||
},
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
export type BadgeVariant = VariantProps<typeof badgeVariants>["variant"];
|
export type BadgeVariant = VariantProps<typeof badgeVariants>['variant'];
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { HTMLAnchorAttributes } from "svelte/elements";
|
import type { HTMLAnchorAttributes } from 'svelte/elements';
|
||||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
import { cn, type WithElementRef } from '$lib/utils.js';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
href,
|
href,
|
||||||
class: className,
|
class: className,
|
||||||
variant = "default",
|
variant = 'default',
|
||||||
children,
|
children,
|
||||||
...restProps
|
...restProps
|
||||||
}: WithElementRef<HTMLAnchorAttributes> & {
|
}: WithElementRef<HTMLAnchorAttributes> & {
|
||||||
@@ -38,7 +39,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:element
|
<svelte:element
|
||||||
this={href ? "a" : "span"}
|
this={href ? 'a' : 'span'}
|
||||||
bind:this={ref}
|
bind:this={ref}
|
||||||
data-slot="badge"
|
data-slot="badge"
|
||||||
{href}
|
{href}
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
export { default as Badge } from "./badge.svelte";
|
export { default as Badge } from './badge.svelte';
|
||||||
export { badgeVariants, type BadgeVariant } from "./badge.svelte";
|
export { badgeVariants, type BadgeVariant } from './badge.svelte';
|
||||||
@@ -1,38 +1,45 @@
|
|||||||
<script lang="ts" module>
|
<script lang="ts" module>
|
||||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
import { cn, type WithElementRef } from '$lib/utils.js';
|
||||||
import type { HTMLAnchorAttributes, HTMLButtonAttributes } from "svelte/elements";
|
import type { HTMLAnchorAttributes, HTMLButtonAttributes } from 'svelte/elements';
|
||||||
import { type VariantProps, tv } from "tailwind-variants";
|
import { type VariantProps, tv } from 'tailwind-variants';
|
||||||
|
|
||||||
export const buttonVariants = tv({
|
export const buttonVariants = tv({
|
||||||
base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-lg border border-transparent bg-clip-padding text-sm font-medium focus-visible:ring-3 active:not-aria-[haspopup]:translate-y-px aria-invalid:ring-3 [&_svg:not([class*='size-'])]:size-4 group/button inline-flex shrink-0 items-center justify-center whitespace-nowrap transition-all outline-none select-none disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-lg border border-transparent bg-clip-padding text-sm font-medium focus-visible:ring-3 active:not-aria-[haspopup]:translate-y-px aria-invalid:ring-3 [&_svg:not([class*='size-'])]:size-4 group/button inline-flex shrink-0 items-center justify-center whitespace-nowrap transition-all outline-none select-none disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
default: 'bg-primary text-primary-foreground [a]:hover:bg-primary/80',
|
||||||
outline: "border-border bg-background hover:bg-muted hover:text-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 aria-expanded:bg-muted aria-expanded:text-foreground",
|
outline:
|
||||||
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
|
'border-border bg-background hover:bg-muted hover:text-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 aria-expanded:bg-muted aria-expanded:text-foreground',
|
||||||
ghost: "hover:bg-muted hover:text-foreground dark:hover:bg-muted/50 aria-expanded:bg-muted aria-expanded:text-foreground",
|
secondary:
|
||||||
destructive: "bg-destructive/10 hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/20 text-destructive focus-visible:border-destructive/40 dark:hover:bg-destructive/30",
|
'bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground',
|
||||||
link: "text-primary underline-offset-4 hover:underline",
|
ghost:
|
||||||
|
'hover:bg-muted hover:text-foreground dark:hover:bg-muted/50 aria-expanded:bg-muted aria-expanded:text-foreground',
|
||||||
|
destructive:
|
||||||
|
'bg-destructive/10 hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/20 text-destructive focus-visible:border-destructive/40 dark:hover:bg-destructive/30',
|
||||||
|
link: 'text-primary underline-offset-4 hover:underline'
|
||||||
},
|
},
|
||||||
size: {
|
size: {
|
||||||
default: "h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
default:
|
||||||
|
'h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
|
||||||
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
|
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||||
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
|
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
|
||||||
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
lg: 'h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
|
||||||
icon: "size-8",
|
icon: 'size-8',
|
||||||
"icon-xs": "size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
|
'icon-xs':
|
||||||
"icon-sm": "size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
|
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
|
||||||
"icon-lg": "size-9",
|
'icon-sm':
|
||||||
},
|
'size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg',
|
||||||
|
'icon-lg': 'size-9'
|
||||||
|
}
|
||||||
},
|
},
|
||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
variant: "default",
|
variant: 'default',
|
||||||
size: "default",
|
size: 'default'
|
||||||
},
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
export type ButtonVariant = VariantProps<typeof buttonVariants>["variant"];
|
export type ButtonVariant = VariantProps<typeof buttonVariants>['variant'];
|
||||||
export type ButtonSize = VariantProps<typeof buttonVariants>["size"];
|
export type ButtonSize = VariantProps<typeof buttonVariants>['size'];
|
||||||
|
|
||||||
export type ButtonProps = WithElementRef<HTMLButtonAttributes> &
|
export type ButtonProps = WithElementRef<HTMLButtonAttributes> &
|
||||||
WithElementRef<HTMLAnchorAttributes> & {
|
WithElementRef<HTMLAnchorAttributes> & {
|
||||||
@@ -44,11 +51,11 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
let {
|
let {
|
||||||
class: className,
|
class: className,
|
||||||
variant = "default",
|
variant = 'default',
|
||||||
size = "default",
|
size = 'default',
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
href = undefined,
|
href = undefined,
|
||||||
type = "button",
|
type = 'button',
|
||||||
disabled,
|
disabled,
|
||||||
children,
|
children,
|
||||||
...restProps
|
...restProps
|
||||||
@@ -62,7 +69,7 @@
|
|||||||
class={cn(buttonVariants({ variant, size }), className)}
|
class={cn(buttonVariants({ variant, size }), className)}
|
||||||
href={disabled ? undefined : href}
|
href={disabled ? undefined : href}
|
||||||
aria-disabled={disabled}
|
aria-disabled={disabled}
|
||||||
role={disabled ? "link" : undefined}
|
role={disabled ? 'link' : undefined}
|
||||||
tabindex={disabled ? -1 : undefined}
|
tabindex={disabled ? -1 : undefined}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import Root, {
|
|||||||
type ButtonProps,
|
type ButtonProps,
|
||||||
type ButtonSize,
|
type ButtonSize,
|
||||||
type ButtonVariant,
|
type ButtonVariant,
|
||||||
buttonVariants,
|
buttonVariants
|
||||||
} from "./button.svelte";
|
} from './button.svelte';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
Root,
|
Root,
|
||||||
@@ -13,5 +13,5 @@ export {
|
|||||||
buttonVariants,
|
buttonVariants,
|
||||||
type ButtonProps,
|
type ButtonProps,
|
||||||
type ButtonSize,
|
type ButtonSize,
|
||||||
type ButtonVariant,
|
type ButtonVariant
|
||||||
};
|
};
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
import { cn, type WithElementRef } from '$lib/utils.js';
|
||||||
import type { HTMLAttributes } from "svelte/elements";
|
import type { HTMLAttributes } from 'svelte/elements';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
bind:this={ref}
|
bind:this={ref}
|
||||||
data-slot="card-action"
|
data-slot="card-action"
|
||||||
class={cn(
|
class={cn(
|
||||||
"cn-card-action col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
'cn-card-action col-start-2 row-span-2 row-start-1 self-start justify-self-end',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { HTMLAttributes } from "svelte/elements";
|
import type { HTMLAttributes } from 'svelte/elements';
|
||||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
import { cn, type WithElementRef } from '$lib/utils.js';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
<div
|
<div
|
||||||
bind:this={ref}
|
bind:this={ref}
|
||||||
data-slot="card-content"
|
data-slot="card-content"
|
||||||
class={cn("px-4 group-data-[size=sm]/card:px-3", className)}
|
class={cn('px-4 group-data-[size=sm]/card:px-3', className)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
>
|
>
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { HTMLAttributes } from "svelte/elements";
|
import type { HTMLAttributes } from 'svelte/elements';
|
||||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
import { cn, type WithElementRef } from '$lib/utils.js';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
<p
|
<p
|
||||||
bind:this={ref}
|
bind:this={ref}
|
||||||
data-slot="card-description"
|
data-slot="card-description"
|
||||||
class={cn("text-muted-foreground text-sm", className)}
|
class={cn('text-sm text-muted-foreground', className)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
>
|
>
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
import { cn, type WithElementRef } from '$lib/utils.js';
|
||||||
import type { HTMLAttributes } from "svelte/elements";
|
import type { HTMLAttributes } from 'svelte/elements';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
@@ -13,7 +13,10 @@
|
|||||||
<div
|
<div
|
||||||
bind:this={ref}
|
bind:this={ref}
|
||||||
data-slot="card-footer"
|
data-slot="card-footer"
|
||||||
class={cn("bg-muted/50 rounded-b-xl border-t p-4 group-data-[size=sm]/card:p-3 flex items-center", className)}
|
class={cn(
|
||||||
|
'flex items-center rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/card:p-3',
|
||||||
|
className
|
||||||
|
)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
>
|
>
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
import { cn, type WithElementRef } from '$lib/utils.js';
|
||||||
import type { HTMLAttributes } from "svelte/elements";
|
import type { HTMLAttributes } from 'svelte/elements';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
bind:this={ref}
|
bind:this={ref}
|
||||||
data-slot="card-header"
|
data-slot="card-header"
|
||||||
class={cn(
|
class={cn(
|
||||||
"gap-1 rounded-t-xl px-4 group-data-[size=sm]/card:px-3 [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3 group/card-header @container/card-header grid auto-rows-min items-start has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto]",
|
'group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-4 group-data-[size=sm]/card:px-3 has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { HTMLAttributes } from "svelte/elements";
|
import type { HTMLAttributes } from 'svelte/elements';
|
||||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
import { cn, type WithElementRef } from '$lib/utils.js';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
<div
|
<div
|
||||||
bind:this={ref}
|
bind:this={ref}
|
||||||
data-slot="card-title"
|
data-slot="card-title"
|
||||||
class={cn("text-base leading-snug font-medium group-data-[size=sm]/card:text-sm", className)}
|
class={cn('text-base leading-snug font-medium group-data-[size=sm]/card:text-sm', className)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
>
|
>
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
|
|||||||
@@ -1,21 +1,24 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { HTMLAttributes } from "svelte/elements";
|
import type { HTMLAttributes } from 'svelte/elements';
|
||||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
import { cn, type WithElementRef } from '$lib/utils.js';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
class: className,
|
class: className,
|
||||||
children,
|
children,
|
||||||
size = "default",
|
size = 'default',
|
||||||
...restProps
|
...restProps
|
||||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & { size?: "default" | "sm" } = $props();
|
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & { size?: 'default' | 'sm' } = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
bind:this={ref}
|
bind:this={ref}
|
||||||
data-slot="card"
|
data-slot="card"
|
||||||
data-size={size}
|
data-size={size}
|
||||||
class={cn("ring-foreground/10 bg-card text-card-foreground gap-4 overflow-hidden rounded-xl py-4 text-sm ring-1 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl group/card flex flex-col", className)}
|
class={cn(
|
||||||
|
'group/card flex flex-col gap-4 overflow-hidden rounded-xl bg-card py-4 text-sm text-card-foreground ring-1 ring-foreground/10 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl',
|
||||||
|
className
|
||||||
|
)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
>
|
>
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import Root from "./card.svelte";
|
import Root from './card.svelte';
|
||||||
import Content from "./card-content.svelte";
|
import Content from './card-content.svelte';
|
||||||
import Description from "./card-description.svelte";
|
import Description from './card-description.svelte';
|
||||||
import Footer from "./card-footer.svelte";
|
import Footer from './card-footer.svelte';
|
||||||
import Header from "./card-header.svelte";
|
import Header from './card-header.svelte';
|
||||||
import Title from "./card-title.svelte";
|
import Title from './card-title.svelte';
|
||||||
import Action from "./card-action.svelte";
|
import Action from './card-action.svelte';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
Root,
|
Root,
|
||||||
@@ -21,5 +21,5 @@ export {
|
|||||||
Footer as CardFooter,
|
Footer as CardFooter,
|
||||||
Header as CardHeader,
|
Header as CardHeader,
|
||||||
Title as CardTitle,
|
Title as CardTitle,
|
||||||
Action as CardAction,
|
Action as CardAction
|
||||||
};
|
};
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Checkbox as CheckboxPrimitive } from "bits-ui";
|
import { Checkbox as CheckboxPrimitive } from 'bits-ui';
|
||||||
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
|
import { cn, type WithoutChildrenOrChild } from '$lib/utils.js';
|
||||||
import CheckIcon from '@lucide/svelte/icons/check';
|
import CheckIcon from '@lucide/svelte/icons/check';
|
||||||
import MinusIcon from '@lucide/svelte/icons/minus';
|
import MinusIcon from '@lucide/svelte/icons/minus';
|
||||||
|
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
bind:ref
|
bind:ref
|
||||||
data-slot="checkbox"
|
data-slot="checkbox"
|
||||||
class={cn(
|
class={cn(
|
||||||
"border-input dark:bg-input/30 data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary data-checked:border-primary aria-invalid:aria-checked:border-primary aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 flex size-4 items-center justify-center rounded-[4px] border transition-colors group-has-disabled/field:opacity-50 focus-visible:ring-3 aria-invalid:ring-3 peer relative shrink-0 outline-none after:absolute after:-inset-x-3 after:-inset-y-2 disabled:cursor-not-allowed disabled:opacity-50",
|
'peer relative flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-input transition-colors outline-none group-has-disabled/field:opacity-50 after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 dark:data-checked:bg-primary',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
bind:checked
|
bind:checked
|
||||||
@@ -27,12 +27,12 @@
|
|||||||
{#snippet children({ checked, indeterminate })}
|
{#snippet children({ checked, indeterminate })}
|
||||||
<div
|
<div
|
||||||
data-slot="checkbox-indicator"
|
data-slot="checkbox-indicator"
|
||||||
class="[&>svg]:size-3.5 grid place-content-center text-current transition-none"
|
class="grid place-content-center text-current transition-none [&>svg]:size-3.5"
|
||||||
>
|
>
|
||||||
{#if checked}
|
{#if checked}
|
||||||
<CheckIcon />
|
<CheckIcon />
|
||||||
{:else if indeterminate}
|
{:else if indeterminate}
|
||||||
<MinusIcon />
|
<MinusIcon />
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import Root from "./checkbox.svelte";
|
import Root from './checkbox.svelte';
|
||||||
export {
|
export {
|
||||||
Root,
|
Root,
|
||||||
//
|
//
|
||||||
Root as Checkbox,
|
Root as Checkbox
|
||||||
};
|
};
|
||||||
Loaded 100 of 224 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user