Compare commits

...
7 Commits
Author SHA1 Message Date
Denozordec 0c11ecfa48 feat(web): enhance module management UI with new localization and state handling
CI / changes (push) Successful in 7s
CI / commitlint (push) Has been skipped
CI / openapi (push) Has been skipped
CI / web (push) Successful in 31s
CI / go (push) Has been skipped
CI / bird2 (push) Has been skipped
CI / release (push) Successful in 3m30s
- Added new utility functions for module state representation in Russian, improving localization.
- Introduced derived states for counting enabled and disabled modules, enhancing user insights.
- Updated module management components to display last updated timestamps and improved descriptions.
- Refactored imports to utilize core UI components for better maintainability and consistency.
2026-05-20 12:23:16 +07:00
Denozordec ab8660ac42 feat(web): enhance operations UI with new job status handling and component updates
- Added a new function for determining job status badge variants to improve UI consistency.
- Refactored imports to utilize the core UI library for better maintainability.
- Updated job report and filters components to enhance user experience and streamline functionality.
- Improved layout and responsiveness across operations-related components.
2026-05-20 12:14:33 +07:00
Denozordec ce747673fd feat(web): refactor module display logic and enhance UI components
- Introduced new utility functions for date formatting and module interval labeling.
- Replaced inline badge variant logic with a dedicated function for improved readability.
- Updated imports to streamline component usage and enhance maintainability.
- Enhanced the overall structure of the module display for better clarity and user experience.
2026-05-20 12:10:11 +07:00
Denozordec e558570967 feat(web): enhance monitoring page with new status tracking and UI components
CI / changes (push) Successful in 8s
CI / commitlint (push) Has been skipped
CI / openapi (push) Has been skipped
CI / web (push) Successful in 28s
CI / go (push) Has been skipped
CI / bird2 (push) Has been skipped
CI / release (push) Successful in 3m36s
- Refactored monitoring page to utilize new UI components from the core library.
- Added detailed job status tracking and error handling features.
- Improved overall layout and responsiveness of the monitoring interface.
- Introduced new derived states for better management of health and job statuses.
- Updated imports to streamline component usage and enhance maintainability.
2026-05-20 11:41:58 +07:00
DenozordecandCursor b6b081b04f style(web): format app-version.svelte for prettier
CI / changes (push) Successful in 7s
CI / openapi (push) Has been skipped
CI / commitlint (push) Has been skipped
CI / web (push) Successful in 28s
CI / go (push) Has been skipped
CI / bird2 (push) Has been skipped
CI / release (push) Successful in 3m45s
Импорт в одну строку — иначе падает npm run lint в CI.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-20 11:21:13 +07:00
DenozordecandCursor 13b4c20148 fix(web): show semver on monitoring from legacy /v1/version
CI / changes (push) Successful in 7s
CI / commitlint (push) Has been skipped
CI / openapi (push) Has been skipped
CI / web (push) Failing after 35s
CI / go (push) Has been skipped
CI / bird2 (push) Has been skipped
CI / release (push) Has been skipped
На проде API отдаёт только api_version и git_sha=unknown без поля version;
карточка показывала SHA вместо semver. Общий хелпер version-info.ts:
version → api_version, placeholder SHA не в заголовке.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-20 11:13:15 +07:00
Denozordec 700d415422 chore: update CI workflow and release process
CI / changes (push) Successful in 7s
CI / commitlint (push) Has been skipped
CI / openapi (push) Has been skipped
CI / web (push) Successful in 30s
CI / go (push) Successful in 25s
CI / bird2 (push) Successful in 15s
CI / release (push) Successful in 19s
- Replaced `@semantic-release/git` with `@semantic-release/exec` for handling release commands.
- Updated `.releaserc.json` to reflect changes in release command execution.
- Modified CI workflow to streamline the release process, including tagging and Docker image publishing.
- Removed obsolete publish workflow and integrated its functionality into the main CI workflow.
- Enhanced documentation to clarify the new release process and CI pipeline structure.
2026-05-20 01:05:22 +07:00
44 changed files with 4955 additions and 3221 deletions
+25 -37
View File
@@ -1,65 +1,53 @@
# Gitea Actions
Workflow-файлы в [workflows/](workflows/):
Workflow: [workflows/ci.yaml](workflows/ci.yaml).
| 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 |
| Job | PR | push в main |
|-----|-----|-------------|
| changes, openapi, web, go, bird2 | quality gates | quality gates |
| commitlint | да | — |
| **release** | — | semantic-release + docker push (один run) |
Подробнее о версионировании: [docs/releasing.md](../docs/releasing.md).
Подробнее: [docs/releasing.md](../docs/releasing.md).
## CI (quality gates)
Job **changes** вычисляет флаги по путям в diff (`openapi`, `go`, `web`, `bird_conf`, …). Изменение `.gitea/workflows/*` поднимает полный прогон.
Job **changes** вычисляет флаги по путям в diff. Изменение `.gitea/workflows/*` поднимает полный прогон.
На **pull request** дополнительно запускается **commitlint** (Conventional Commits).
На **pull request** **commitlint** (Conventional Commits).
Требования к runner:
Runner: `ubuntu-latest`, **bird2** из apt, Docker для job **release**.
- Метка `ubuntu-latest`.
- Job `bird2` ставит **bird2** из репозитория Ubuntu и вызывает `bird -c … -p`.
- Job **publish** требует Docker и доступ к Container Registry.
## Release (job в ci.yaml)
## Release (semantic-release)
После успешных quality gates на **push в main** job **release**:
После успешного CI на push в `main`/`master` workflow **Release** запускает `npx semantic-release` (корневой `package.json`, [.releaserc.json](../.releaserc.json)).
1. `npx semantic-release` — тег `vX.Y.Z` на **текущий commit** (без дополнительного commit в main).
2. Gitea Release + `CHANGELOG.md` как attachment (не в git).
3. `docker buildx bake default --push` с `VERSION=X.Y.Z` — в том же job.
Результат при releasable-коммитах:
- git tag `vX.Y.Z`;
- commit `CHANGELOG.md` с `[skip ci]`;
- Gitea Release с release notes.
Если releasable-коммитов нет — semantic-release no-op, образы не публикуются.
### Секреты
Один PAT — **`ACTIONS_PAT`**: push/t/tags, releases, запись в Container Registry. Если не задан — fallback на **`gitea.token`** (нужны права на releases и packages в Gitea).
`GITEA_URL`: `https://git.shts.su` (задано в workflow).
## Publish (образы)
Запускается только при **push git-тега** `vX.Y.Z` (не при каждом push в main).
Собираются все образы из `deploy/docker/*` (`docker buildx bake default --push`). Semver из тега передаётся в Go-бинарники (`VERSION`, `GIT_SHA`, `BUILD_TIME`).
Registry: **`git.shts.su`**. Login: `${{ gitea.actor }}` / `${{ secrets.ACTIONS_PAT || gitea.token }}` (тот же **`ACTIONS_PAT`**, что и для release).
**`ACTIONS_PAT`**: push tags, releases, Container Registry. Fallback: **`gitea.token`**.
### Теги образов
```text
git.shts.su/<owner_lowercase>/<имя>:<тег>
git.shts.su/<owner>/<имя>:latest
git.shts.su/<owner>/<имя>:v1.2.3
git.shts.su/<owner>/<имя>:1.2.3
git.shts.su/<owner>/<имя>:<short-sha>
git.shts.su/<owner>/<имя>:sha-<full-sha>
```
Имена: `evobgp-api`, `evobgp-all`, `evobgp-scheduler`, `evobgp-ingest`, `evobgp-render`, `evobgp-deploy`, `evobgp-node`, `evobgp-web`, `evobgp-web-all`, `evobgp-agent`, `evobgp-bird2`.
Теги при релизе `v1.2.3`: `latest`, `v1.2.3`, короткий SHA, `sha-<full_commit>`.
Имена образов: `evobgp-api`, `evobgp-all`, `evobgp-scheduler`, `evobgp-ingest`, `evobgp-render`, `evobgp-deploy`, `evobgp-node`, `evobgp-web`, `evobgp-web-all`, `evobgp-agent`, `evobgp-bird2`.
Пример:
```bash
docker pull git.shts.su/myuser/evobgp-api:v1.2.3
docker pull git.shts.su/myuser/evobgp-api:1.2.3
```
Подробности — [docs/quickstart.md](../docs/quickstart.md), [deploy/docker/README.md](../deploy/docker/README.md).
См. [deploy/docker/README.md](../deploy/docker/README.md), [docs/quickstart.md](../docs/quickstart.md).
+85
View File
@@ -225,3 +225,88 @@ jobs:
set -euxo pipefail
npm ci
npx commitlint --from "${{ github.event.pull_request.base.sha }}" --to "${{ github.event.pull_request.head.sha }}"
# ---------------------------------------------------------------------------
# Один push в main: semantic-release (тег на текущий commit, без доп. commit) + docker push.
# ---------------------------------------------------------------------------
release:
needs: [changes, openapi, web, go, bird2]
if: >-
always() &&
github.event_name == 'push' &&
(github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master') &&
needs.changes.result == 'success' &&
(needs.openapi.result == 'success' || needs.openapi.result == 'skipped') &&
(needs.web.result == 'success' || needs.web.result == 'skipped') &&
(needs.go.result == 'success' || needs.go.result == 'skipped') &&
(needs.bird2.result == 'success' || needs.bird2.result == 'skipped')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.ACTIONS_PAT || gitea.token }}
persist-credentials: true
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: npm
cache-dependency-path: package-lock.json
- name: Install release tooling
run: npm ci
- name: Semantic release
run: npx semantic-release
env:
GITEA_URL: https://git.shts.su
GITEA_TOKEN: ${{ secrets.ACTIONS_PAT || gitea.token }}
- name: Detect new release
id: rel
run: |
set -euo pipefail
if [ -f .release-version ]; then
echo "version=$(tr -d '[:space:]' < .release-version)" >> "$GITHUB_OUTPUT"
echo "released=true" >> "$GITHUB_OUTPUT"
else
echo "released=false" >> "$GITHUB_OUTPUT"
echo "No releasable commits — skipping image publish"
fi
- name: Set up Docker Buildx
if: steps.rel.outputs.released == 'true'
uses: docker/setup-buildx-action@v3
- name: Prepare image metadata
if: steps.rel.outputs.released == 'true'
id: meta
run: |
set -euo pipefail
echo "version=${{ steps.rel.outputs.version }}" >> "$GITHUB_OUTPUT"
owner_lc="$(echo '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]')"
echo "owner_lc=$owner_lc" >> "$GITHUB_OUTPUT"
short_sha="$(echo '${{ github.sha }}' | cut -c1-7)"
echo "short_sha=$short_sha" >> "$GITHUB_OUTPUT"
echo "build_time=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT"
- name: Log in to Gitea Registry
if: steps.rel.outputs.released == 'true'
uses: docker/login-action@v3
with:
registry: git.shts.su
username: ${{ gitea.actor }}
password: ${{ secrets.ACTIONS_PAT || gitea.token }}
- name: Build and push images (bake)
if: steps.rel.outputs.released == 'true'
env:
REGISTRY: git.shts.su/${{ steps.meta.outputs.owner_lc }}
IMAGE_TAG: latest
VERSION: ${{ steps.meta.outputs.version }}
SHORT_SHA: ${{ steps.meta.outputs.short_sha }}
SHA_FULL: ${{ github.sha }}
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
-48
View File
@@ -1,48 +0,0 @@
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
-33
View File
@@ -1,33 +0,0 @@
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 }}
+4
View File
@@ -4,6 +4,10 @@ node_modules/
# Generated by deploy/docker/write-bake-override.sh (CI/local bake)
deploy/docker/docker-bake.override.hcl
# CI-only markers (not committed)
.release-version
CHANGELOG.md
# OS
.DS_Store
Thumbs.db
+2 -3
View File
@@ -27,10 +27,9 @@
}
],
[
"@semantic-release/git",
"@semantic-release/exec",
{
"assets": ["CHANGELOG.md"],
"message": "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}"
"successCmd": "echo ${nextRelease.version} > .release-version"
}
],
[
+1 -1
View File
@@ -50,6 +50,6 @@ docker compose --profile reference up -d
- [docs/releasing.md](docs/releasing.md) — пайплайн, commit conventions, секреты CI
- API: `GET /version` и `GET /v1/version` (поле `version`)
- Docker-образы публикуются по git-тегу `vX.Y.Z` (workflow **Publish**)
- Docker-образы: теги `latest`, `vX.Y.Z`, `X.Y.Z` — в **том же CI run**, что и релиз (job `release`)
Лицензия и условия использования — по политике владельца репозитория.
+1 -1
View File
@@ -4,7 +4,7 @@
Сборка образов — **`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`.
**Publish:** push в `main` после quality gates — job **release** в [.gitea/workflows/ci.yaml](../.gitea/workflows/ci.yaml): semantic-release + bake с `VERSION` из релиза.
Локально BuildKit также кэширует `/go/pkg/mod` и `~/.cache/go-build` через `RUN --mount=type=cache`.
+4 -1
View File
@@ -149,7 +149,10 @@ function "image-tags" {
"${REGISTRY}/${name}:${IMAGE_TAG}",
"${REGISTRY}/${name}:${SHORT_SHA}",
],
notequal(VERSION, "dev") && notequal(VERSION, "") ? ["${REGISTRY}/${name}:v${VERSION}"] : []
notequal(VERSION, "dev") && notequal(VERSION, "") ? [
"${REGISTRY}/${name}:v${VERSION}",
"${REGISTRY}/${name}:${VERSION}",
] : []
)
}
+22 -33
View File
@@ -15,39 +15,35 @@ EvoBGP использует [Conventional Commits](https://www.conventionalcommi
Подробные правила сообщений коммитов: [.cursor/rules/conventional-commits.mdc](../.cursor/rules/conventional-commits.mdc).
## CI-пайплайн
## CI-пайплайн (один push в main)
```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
→ CI: openapi, web, go, bird2 (параллельно)
job release (в том же workflow, после quality gates):
semantic-release: git tag vX.Y.Z на текущий commit (без доп. commit)
Gitea Release + CHANGELOG.md как attachment
docker buildx bake с VERSION=X.Y.Z
→ образы: latest, vX.Y.Z, X.Y.Z, sha-*, короткий SHA
```
Workflow-файлы:
Pull request: только quality gates + commitlint; релиз и образы **не** публикуются.
- [.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) — публикация образов
Workflow: [.gitea/workflows/ci.yaml](../.gitea/workflows/ci.yaml) (job **release**).
Конфиг semantic-release: [.releaserc.json](../.releaserc.json).
Конфиг semantic-release: [.releaserc.json](../.releaserc.json) — без `@semantic-release/git` (CHANGELOG не коммитится в репозиторий).
## Секреты Gitea
Один PAT в репозитории**`ACTIONS_PAT`** (Settings → Actions → Secrets). Используется для semantic-release, push тегов/CHANGELOG и docker login в registry.
Один PAT — **`ACTIONS_PAT`** (Settings → Actions → Secrets).
| Право PAT | Зачем |
|-----------|--------|
| push / write repository | commit `CHANGELOG.md`, push тегов |
| releases | Gitea Release через semantic-release |
| packages (Container Registry) | workflow **Publish** |
| push tags | git tag `vX.Y.Z` на commit merge |
| releases | Gitea Release + notes |
| packages (Container Registry) | push образов |
Если `ACTIONS_PAT` не задан, workflow пробует **`gitea.token`** job-токен (нужны права на releases и packages в настройках Gitea).
Fallback: **`gitea.token`** (нужны права на releases и packages).
## Источник правды для версии в runtime
@@ -56,28 +52,21 @@ Semver из git-тега пробрасывается в Go-бинарники
- локально (`go run`) — `version: "dev"`
- в образе после релиза — совпадает с тегом (например `1.2.3`)
API:
- `GET /version`
- `GET /v1/version`
Ответ включает `version`, `git_sha`, `build_time`; поле `api_version` — deprecated alias.
API: `GET /version`, `GET /v1/version` — поля `version`, `git_sha`, `build_time`.
Web UI показывает версию из API (footer sidebar, страница «Мониторинг»).
## CHANGELOG
Файл [`CHANGELOG.md`](../CHANGELOG.md) создаётся и обновляется semantic-release. Копия прикрепляется к Gitea Release.
Release notes — в Gitea Release; файл `CHANGELOG.md` генерируется в CI и прикрепляется как asset, **не** попадает в git history.
## Проверка после релиза
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`.
1. Один run workflow **CI** на push в main: job **release** зелёный.
2. Gitea: тег `vX.Y.Z` на том же commit, что и merge; Release с notes.
3. Container Registry: `evobgp-api:vX.Y.Z`, `evobgp-api:X.Y.Z`, `evobgp-api:latest`.
4. `curl http://localhost:8080/version``"version":"X.Y.Z"`.
## Первый релиз (bootstrap)
Merge PR в `main` с conventional commit типа `feat(release): ...` (не `chore` — иначе релиз не создастся). Ожидаемый результат: **v1.0.0**.
После merge убедитесь, что workflow **Release** завершился успешно и workflow **Publish** собрал образы по тегу.
Merge в `main` с `feat(release): ...`**v1.0.0** в том же CI run.
+168 -139
View File
@@ -11,7 +11,7 @@
"@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/exec": "^7.0.0",
"@semantic-release/release-notes-generator": "^14.0.3",
"semantic-release": "^25.0.2"
}
@@ -761,40 +761,28 @@
"node": ">=18"
}
},
"node_modules/@semantic-release/git": {
"version": "10.0.1",
"resolved": "https://registry.npmjs.org/@semantic-release/git/-/git-10.0.1.tgz",
"integrity": "sha512-eWrx5KguUcU2wUPaO6sfvZI0wPafUKAMNC18aXY4EnNcrZL86dEmpNVnC9uMpGZkmZJ9EfCVJBQx4pV4EMGT1w==",
"node_modules/@semantic-release/exec": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/@semantic-release/exec/-/exec-7.1.0.tgz",
"integrity": "sha512-4ycZ2atgEUutspPZ2hxO6z8JoQt4+y/kkHvfZ1cZxgl9WKJId1xPj+UadwInj+gMn2Gsv+fLnbrZ4s+6tK2TFQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@semantic-release/error": "^3.0.0",
"@semantic-release/error": "^4.0.0",
"aggregate-error": "^3.0.0",
"debug": "^4.0.0",
"dir-glob": "^3.0.0",
"execa": "^5.0.0",
"lodash": "^4.17.4",
"micromatch": "^4.0.0",
"p-reduce": "^2.0.0"
"execa": "^9.0.0",
"lodash-es": "^4.17.21",
"parse-json": "^8.0.0"
},
"engines": {
"node": ">=14.17"
"node": ">=20.8.1"
},
"peerDependencies": {
"semantic-release": ">=18.0.0"
"semantic-release": ">=24.1.0"
}
},
"node_modules/@semantic-release/git/node_modules/@semantic-release/error": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@semantic-release/error/-/error-3.0.0.tgz",
"integrity": "sha512-5hiM4Un+tpl4cKw3lV4UgzJj+SmfNIDCLLw0TepzQxz9ZGV5ixnqkzIVF+3tp0ZHgcMKE+VNGHJjEeyFG2dcSw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14.17"
}
},
"node_modules/@semantic-release/git/node_modules/aggregate-error": {
"node_modules/@semantic-release/exec/node_modules/aggregate-error": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz",
"integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==",
@@ -808,7 +796,7 @@
"node": ">=8"
}
},
"node_modules/@semantic-release/git/node_modules/clean-stack": {
"node_modules/@semantic-release/exec/node_modules/clean-stack": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz",
"integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==",
@@ -818,7 +806,61 @@
"node": ">=6"
}
},
"node_modules/@semantic-release/git/node_modules/indent-string": {
"node_modules/@semantic-release/exec/node_modules/execa": {
"version": "9.6.1",
"resolved": "https://registry.npmjs.org/execa/-/execa-9.6.1.tgz",
"integrity": "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@sindresorhus/merge-streams": "^4.0.0",
"cross-spawn": "^7.0.6",
"figures": "^6.1.0",
"get-stream": "^9.0.0",
"human-signals": "^8.0.1",
"is-plain-obj": "^4.1.0",
"is-stream": "^4.0.1",
"npm-run-path": "^6.0.0",
"pretty-ms": "^9.2.0",
"signal-exit": "^4.1.0",
"strip-final-newline": "^4.0.0",
"yoctocolors": "^2.1.1"
},
"engines": {
"node": "^18.19.0 || >=20.5.0"
},
"funding": {
"url": "https://github.com/sindresorhus/execa?sponsor=1"
}
},
"node_modules/@semantic-release/exec/node_modules/get-stream": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/get-stream/-/get-stream-9.0.1.tgz",
"integrity": "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@sec-ant/readable-stream": "^0.4.1",
"is-stream": "^4.0.1"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@semantic-release/exec/node_modules/human-signals": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/human-signals/-/human-signals-8.0.1.tgz",
"integrity": "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=18.18.0"
}
},
"node_modules/@semantic-release/exec/node_modules/indent-string": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz",
"integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==",
@@ -828,6 +870,106 @@
"node": ">=8"
}
},
"node_modules/@semantic-release/exec/node_modules/is-stream": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz",
"integrity": "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@semantic-release/exec/node_modules/npm-run-path": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-6.0.0.tgz",
"integrity": "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==",
"dev": true,
"license": "MIT",
"dependencies": {
"path-key": "^4.0.0",
"unicorn-magic": "^0.3.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@semantic-release/exec/node_modules/parse-json": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/parse-json/-/parse-json-8.3.0.tgz",
"integrity": "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.26.2",
"index-to-position": "^1.1.0",
"type-fest": "^4.39.1"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@semantic-release/exec/node_modules/path-key": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz",
"integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@semantic-release/exec/node_modules/signal-exit": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
"integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/@semantic-release/exec/node_modules/strip-final-newline": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-4.0.0.tgz",
"integrity": "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@semantic-release/exec/node_modules/unicorn-magic": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz",
"integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@semantic-release/github": {
"version": "12.0.8",
"resolved": "https://registry.npmjs.org/@semantic-release/github/-/github-12.0.8.tgz",
@@ -2335,30 +2477,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/execa": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz",
"integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==",
"dev": true,
"license": "MIT",
"dependencies": {
"cross-spawn": "^7.0.3",
"get-stream": "^6.0.0",
"human-signals": "^2.1.0",
"is-stream": "^2.0.0",
"merge-stream": "^2.0.0",
"npm-run-path": "^4.0.1",
"onetime": "^5.1.2",
"signal-exit": "^3.0.3",
"strip-final-newline": "^2.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sindresorhus/execa?sponsor=1"
}
},
"node_modules/fast-content-type-parse": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/fast-content-type-parse/-/fast-content-type-parse-3.0.0.tgz",
@@ -2970,16 +3088,6 @@
"node": ">= 20"
}
},
"node_modules/human-signals": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz",
"integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=10.17.0"
}
},
"node_modules/ignore": {
"version": "7.0.5",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz",
@@ -3171,19 +3279,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/is-stream": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz",
"integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/is-text-path": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/is-text-path/-/is-text-path-2.0.0.tgz",
@@ -3683,16 +3778,6 @@
"node": ">= 0.6"
}
},
"node_modules/mimic-fn": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz",
"integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/mimic-response": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-4.0.0.tgz",
@@ -3961,19 +4046,6 @@
"node": "^20.17.0 || >=22.9.0"
}
},
"node_modules/npm-run-path": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz",
"integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==",
"dev": true,
"license": "MIT",
"dependencies": {
"path-key": "^3.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/npm/node_modules/@gar/promise-retry": {
"version": "1.0.3",
"dev": true,
@@ -5736,22 +5808,6 @@
"node": ">=0.10.0"
}
},
"node_modules/onetime": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz",
"integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==",
"dev": true,
"license": "MIT",
"dependencies": {
"mimic-fn": "^2.1.0"
},
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-cancelable": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-4.0.1.tgz",
@@ -5852,16 +5908,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-reduce": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/p-reduce/-/p-reduce-2.1.0.tgz",
"integrity": "sha512-2USApvnsutq8uoxZBGbbWM0JIYLiEMJ9RlaN7fAzVNb9OZN0SHjjTTfIcb667XynS5Y1VhwDJVDa72TnPzAYWw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/p-timeout": {
"version": "6.1.4",
"resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-6.1.4.tgz",
@@ -6850,13 +6896,6 @@
"node": ">=8"
}
},
"node_modules/signal-exit": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
"integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
"dev": true,
"license": "ISC"
},
"node_modules/signale": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/signale/-/signale-1.4.0.tgz",
@@ -7121,16 +7160,6 @@
"node": ">=4"
}
},
"node_modules/strip-final-newline": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz",
"integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/strip-json-comments": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
+1 -1
View File
@@ -7,7 +7,7 @@
"@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/exec": "^7.0.0",
"@semantic-release/release-notes-generator": "^14.0.3",
"semantic-release": "^25.0.2"
}
+49
View File
@@ -0,0 +1,49 @@
/** Ответ GET /v1/version (см. docs/openapi.yaml VersionInfo). */
export type VersionInfo = {
version?: string;
api_version?: string;
git_sha?: string;
build_time?: string;
};
const PLACEHOLDER_SHA = new Set(['', 'unknown']);
function nonEmptyString(value: unknown): string {
if (typeof value === 'string') {
const s = value.trim();
return s;
}
if (value == null) return '';
return String(value).trim();
}
/** Semver или legacy api_version; пустая строка, если полей нет. */
export function resolveVersionString(info: VersionInfo | null | undefined): string {
if (!info) return '';
return nonEmptyString(info.version) || nonEmptyString(info.api_version);
}
/** Короткий git SHA; пусто для placeholder «unknown». */
export function resolveGitSha(info: VersionInfo | null | undefined): string {
if (!info) return '';
const sha = nonEmptyString(info.git_sha);
if (!sha || PLACEHOLDER_SHA.has(sha.toLowerCase())) return '';
return sha;
}
/** Заголовок карточки «Версия» на /monitoring. */
export function formatVersionHeadline(info: VersionInfo | null | undefined): string {
if (!info) return '—';
const ver = resolveVersionString(info);
const sha = resolveGitSha(info);
if (ver && sha) return `${ver} (${sha.slice(0, 12)})`;
if (ver) return ver;
if (sha) return sha.slice(0, 12);
return '—';
}
/** Подпись в сайдбаре (v1.2.3). */
export function formatVersionSidebarLabel(info: VersionInfo | null | undefined): string | null {
const ver = resolveVersionString(info);
return ver ? `v${ver}` : null;
}
@@ -0,0 +1,342 @@
<script lang="ts">
import { apiFetch, apiMutate } from '$lib/api/client.js';
import type { AsEntry, BgpCommunity, ModuleRow } from '$lib/api/types.js';
import { formatDateTime } from '$lib/modules/display.js';
import {
communityLabel,
sanitizeFilenamePart,
supportsCsvIO
} from '$lib/components/modules/module-helpers.js';
import { Button } from '$lib/ui/core/button/index.js';
import { Checkbox } from '$lib/ui/core/checkbox/index.js';
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription
} from '$lib/ui/core/card/index.js';
import AppDataTable from '$lib/ui/patterns/data-table/app-data-table.svelte';
import { confirm } from '$lib/ui/patterns/confirm/confirm-state.svelte.js';
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
import ModuleAsEntryDialog from '$lib/components/modules/ModuleAsEntryDialog.svelte';
import Plus from '@lucide/svelte/icons/plus';
import Pencil from '@lucide/svelte/icons/pencil';
import Trash2 from '@lucide/svelte/icons/trash-2';
import Upload from '@lucide/svelte/icons/upload';
import Download from '@lucide/svelte/icons/download';
type Props = {
moduleId: string;
mod: ModuleRow;
entries: AsEntry[];
communities: BgpCommunity[];
loading?: boolean;
onChanged: () => void | Promise<void>;
};
let { moduleId, mod, entries, communities, loading = false, onChanged }: Props = $props();
let dialogOpen = $state(false);
let editTarget = $state<AsEntry | null>(null);
let selectedIds = $state(new Set<string>());
let deletingBulk = $state(false);
let csvImporting = $state(false);
let csvExporting = $state(false);
let csvFileInput = $state<HTMLInputElement | null>(null);
const selectedCount = $derived(selectedIds.size);
const allSelected = $derived(entries.length > 0 && selectedIds.size === entries.length);
const columns = [
{ id: 'select', label: '', class: 'w-10' },
{ id: 'asn', label: 'ASN', sortable: true, sortValue: (e: AsEntry) => e.asn },
{
id: 'name',
label: 'Название AS',
sortable: true,
sortValue: (e: AsEntry) => e.asn_name ?? ''
},
{
id: 'prefixes',
label: 'Префиксов',
sortable: true,
sortValue: (e: AsEntry) => e.prefix_count ?? 0,
class: 'text-right'
},
{
id: 'updated',
label: 'Обновлено',
sortable: true,
sortValue: (e: AsEntry) => e.asn_resolved_at ?? ''
},
{ id: 'community', label: 'Community' },
{ id: 'actions', label: '', class: 'w-20' }
] as const;
$effect(() => {
const validIds = new Set(entries.map((e) => e.id));
selectedIds = new Set([...selectedIds].filter((id) => validIds.has(id)));
});
function toggleSelection(id: string) {
const next = new Set(selectedIds);
if (next.has(id)) next.delete(id);
else next.add(id);
selectedIds = next;
}
function toggleAll(checked: boolean) {
selectedIds = checked ? new Set(entries.map((e) => e.id)) : new Set<string>();
}
function openCreate() {
editTarget = null;
dialogOpen = true;
}
function openEdit(entry: AsEntry) {
editTarget = entry;
dialogOpen = true;
}
function requestDelete(entry: AsEntry) {
void confirm({
title: 'Удалить запись?',
description: `ASN: ${entry.asn}`,
confirmLabel: 'Удалить',
destructive: true,
onConfirm: async () => {
await apiMutate(`/v1/modules/${moduleId}/as-entries/${entry.id}`, 'DELETE', undefined, {
idempotent: false
});
notify.success('Удалено');
await onChanged();
}
});
}
function requestBulkDelete() {
if (selectedCount === 0) return;
void confirm({
title: 'Удалить выбранные AS-записи?',
description: `Будет удалено: ${selectedCount}`,
confirmLabel: 'Удалить',
destructive: true,
onConfirm: bulkDelete
});
}
async function bulkDelete() {
if (selectedCount === 0) return;
deletingBulk = true;
let deleted = 0;
try {
for (const id of selectedIds) {
try {
await apiMutate(`/v1/modules/${moduleId}/as-entries/${id}`, 'DELETE', undefined, {
idempotent: false
});
deleted += 1;
} catch (e) {
notifyApiError(e);
}
}
if (deleted > 0) notify.success(`Удалено AS-записей: ${deleted}`);
await onChanged();
} finally {
deletingBulk = false;
}
}
async function readErrorText(res: Response): Promise<string> {
const body = (await res.text()).trim();
return body || `HTTP ${res.status}`;
}
async function exportCsv() {
if (!supportsCsvIO(mod.type) || csvExporting) return;
csvExporting = true;
try {
const res = await apiFetch(`/v1/modules/${moduleId}/entries.csv`, {
method: 'GET',
headers: { Accept: 'text/csv' }
});
if (!res.ok) {
notify.error(await readErrorText(res));
return;
}
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${sanitizeFilenamePart(mod.name)}-${mod.type.toLowerCase()}-entries.csv`;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
} catch (e) {
notifyApiError(e);
} finally {
csvExporting = false;
}
}
function openImportPicker() {
if (!supportsCsvIO(mod.type) || csvImporting) return;
csvFileInput?.click();
}
async function handleImportChange(event: Event) {
const input = event.currentTarget as HTMLInputElement | null;
const file = input?.files?.[0];
if (!file || csvImporting) return;
csvImporting = true;
try {
const fileText = await file.text();
const res = await apiFetch(`/v1/modules/${moduleId}/entries.csv`, {
method: 'POST',
headers: { 'Content-Type': 'text/csv' },
body: fileText
});
if (!res.ok) {
notify.error(await readErrorText(res));
return;
}
const payload = (await res.json()) as { imported?: number };
notify.success(`Импортировано записей: ${payload.imported ?? 0}`);
await onChanged();
} catch (e) {
notifyApiError(e);
} finally {
csvImporting = false;
if (input) input.value = '';
}
}
</script>
<input
class="hidden"
type="file"
accept=".csv,text/csv"
bind:this={csvFileInput}
onchange={handleImportChange}
/>
<Card>
<CardHeader class="flex flex-col gap-3 pb-2 sm:flex-row sm:items-center sm:justify-between">
<div class="min-w-0 flex-1">
<CardTitle class="text-base">AS-записи</CardTitle>
<CardDescription>
Номер AS и community; имя, число префиксов и дата обновляются при успешном refresh
(RIPEstat).
</CardDescription>
</div>
<div class="flex shrink-0 flex-wrap items-center justify-end gap-2 self-start sm:self-auto">
<Button
variant="outline"
size="sm"
onclick={openImportPicker}
disabled={!supportsCsvIO(mod.type) || csvImporting || csvExporting}
>
<Upload />
{csvImporting ? 'Импорт…' : 'Импорт CSV'}
</Button>
<Button
variant="outline"
size="sm"
onclick={exportCsv}
disabled={!supportsCsvIO(mod.type) || csvExporting || csvImporting}
>
<Download />
{csvExporting ? 'Экспорт…' : 'Экспорт CSV'}
</Button>
<Button size="sm" onclick={openCreate}><Plus />Добавить</Button>
{#if selectedCount > 0}
<Button variant="destructive" size="sm" onclick={requestBulkDelete} disabled={deletingBulk}>
<Trash2 />
Удалить ({selectedCount})
</Button>
{/if}
</div>
</CardHeader>
<CardContent class="p-4 pt-0">
<AppDataTable
columns={[...columns]}
rows={entries}
rowKey={(e) => e.id}
{loading}
emptyTitle="Нет AS-записей"
emptyDescription="Добавьте ASN или импортируйте CSV."
>
{#snippet toolbar()}
{#if entries.length > 0}
<div class="flex items-center gap-2">
<Checkbox
checked={allSelected}
onCheckedChange={(v) => toggleAll(v === true)}
aria-label="Выбрать все AS-записи"
/>
<span class="text-sm text-muted-foreground">Выбрать все</span>
</div>
{/if}
{/snippet}
{#snippet cell({ row: entry, column })}
{#if column.id === 'select'}
<Checkbox
checked={selectedIds.has(entry.id)}
aria-label={`Выбрать AS ${entry.asn}`}
onCheckedChange={() => toggleSelection(entry.id)}
/>
{:else if column.id === 'asn'}
<span class="font-mono">{entry.asn}</span>
{:else if column.id === 'name'}
<span
class="max-w-[14rem] truncate text-sm text-muted-foreground"
title={entry.asn_name ?? ''}
>
{entry.asn_name?.trim() ? entry.asn_name : '—'}
</span>
{:else if column.id === 'prefixes'}
<span class="font-mono text-sm">
{entry.prefix_count != null ? entry.prefix_count : '—'}
</span>
{:else if column.id === 'updated'}
<span class="text-sm whitespace-nowrap text-muted-foreground">
{formatDateTime(entry.asn_resolved_at)}
</span>
{:else if column.id === 'community'}
<span class="text-sm text-muted-foreground">
{communityLabel(entry.community_id, communities)}
</span>
{:else if column.id === 'actions'}
<div class="flex gap-1">
<Button variant="ghost" size="icon-sm" onclick={() => openEdit(entry)}>
<Pencil class="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon-sm"
class="text-destructive"
onclick={() => requestDelete(entry)}
>
<Trash2 class="size-3.5" />
</Button>
</div>
{/if}
{/snippet}
</AppDataTable>
</CardContent>
</Card>
<ModuleAsEntryDialog
bind:open={dialogOpen}
{moduleId}
edit={editTarget}
{communities}
onSaved={onChanged}
onClose={() => {
editTarget = null;
}}
/>
@@ -0,0 +1,126 @@
<script lang="ts">
import { apiMutate } from '$lib/api/client.js';
import type { AsEntry, AsEntryCreate, AsEntryPatch, BgpCommunity } from '$lib/api/types.js';
import {
communityLabel,
communityOptionLabel,
fromNullableSelect,
NONE_OPTION,
nullableSelectValue
} from '$lib/components/modules/module-helpers.js';
import { Button } from '$lib/ui/core/button/index.js';
import { Input } from '$lib/ui/core/input/index.js';
import { Label } from '$lib/ui/core/label/index.js';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogDescription
} from '$lib/ui/core/dialog/index.js';
import { Select, SelectContent, SelectItem, SelectTrigger } from '$lib/ui/core/select/index.js';
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
type Props = {
open: boolean;
moduleId: string;
edit: AsEntry | null;
communities: BgpCommunity[];
onSaved: () => void | Promise<void>;
onClose: () => void;
};
let { open = $bindable(), moduleId, edit, communities, onSaved, onClose }: Props = $props();
let saving = $state(false);
let form = $state<AsEntryCreate>({ asn: 0, community_id: null });
$effect(() => {
if (open) {
form = edit
? { asn: edit.asn, community_id: edit.community_id }
: { asn: 0, community_id: null };
}
});
async function save() {
const asn = Number(form.asn);
if (!Number.isFinite(asn) || asn < 1 || asn > 4294967295) {
notify.error('Укажите корректный ASN (14294967295)');
return;
}
saving = true;
try {
const body: AsEntryCreate | AsEntryPatch = { asn, community_id: form.community_id };
if (edit) {
await apiMutate(`/v1/modules/${moduleId}/as-entries/${edit.id}`, 'PATCH', body);
notify.success('Запись обновлена');
} else {
await apiMutate(`/v1/modules/${moduleId}/as-entries`, 'POST', body as AsEntryCreate);
notify.success('Запись добавлена');
}
open = false;
await onSaved();
} catch (e) {
notifyApiError(e);
} finally {
saving = false;
}
}
function handleOpenChange(next: boolean) {
open = next;
if (!next) onClose();
}
</script>
<Dialog {open} onOpenChange={handleOpenChange}>
<DialogContent class="sm:max-w-sm">
<DialogHeader>
<DialogTitle>{edit ? 'Редактировать запись' : 'Новая AS-запись'}</DialogTitle>
<DialogDescription>
Номер автономной системы и community для политики анонса.
</DialogDescription>
</DialogHeader>
<div class="space-y-4 py-2">
<div class="space-y-1.5">
<Label for="as-asn">ASN</Label>
<Input
id="as-asn"
type="number"
placeholder="12345"
bind:value={form.asn}
min={1}
max={4294967295}
/>
</div>
<div class="space-y-1.5">
<Label for="as-comm">Community</Label>
<Select
type="single"
value={nullableSelectValue(form.community_id)}
onValueChange={(v) => {
form.community_id = fromNullableSelect(v);
}}
>
<SelectTrigger id="as-comm" class="w-full">
{form.community_id ? communityLabel(form.community_id, communities) : 'Не выбрано'}
</SelectTrigger>
<SelectContent>
<SelectItem value={NONE_OPTION}>Не выбрано</SelectItem>
{#each communities as c (c.id)}
<SelectItem value={c.id}>{communityOptionLabel(c)}</SelectItem>
{/each}
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button variant="outline" onclick={() => handleOpenChange(false)}>Отмена</Button>
<Button onclick={save} disabled={saving}>
{saving ? 'Сохранение…' : edit ? 'Сохранить' : 'Добавить'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -0,0 +1,259 @@
<script lang="ts">
import { apiMutate } from '$lib/api/client.js';
import type {
BgpCommunity,
CdnPreviewResponse,
CdnSource,
CdnSourceCreate
} from '$lib/api/types.js';
import {
communityLabel,
communityOptionLabel,
fromNullableSelect,
normalizeCdnSourceKind,
NONE_OPTION,
nullableSelectValue
} from '$lib/components/modules/module-helpers.js';
import { Button } from '$lib/ui/core/button/index.js';
import { Input } from '$lib/ui/core/input/index.js';
import { Label } from '$lib/ui/core/label/index.js';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter
} from '$lib/ui/core/dialog/index.js';
import { Select, SelectContent, SelectItem, SelectTrigger } from '$lib/ui/core/select/index.js';
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
type Props = {
open: boolean;
moduleId: string;
edit: CdnSource | null;
communities: BgpCommunity[];
onSaved: () => void | Promise<void>;
onClose: () => void;
};
let { open = $bindable(), moduleId, edit, communities, onSaved, onClose }: Props = $props();
let saving = $state(false);
let previewLoading = $state(false);
let previewItems = $state<string[]>([]);
let previewTotal = $state(0);
let previewTruncated = $state(false);
let previewError = $state<string | null>(null);
let previewOk = $state(false);
let form = $state<CdnSourceCreate & { refresh_interval_sec?: number | null }>({
url: '',
source_kind: 'plaintext',
prefix_path: '',
community_id: null
});
function clearPreview() {
previewLoading = false;
previewItems = [];
previewTotal = 0;
previewTruncated = false;
previewError = null;
previewOk = false;
}
$effect(() => {
if (open) {
clearPreview();
form = edit
? {
url: edit.url,
source_kind: normalizeCdnSourceKind(edit.source_kind),
prefix_path: edit.prefix_path ?? '',
community_id: edit.community_id,
refresh_interval_sec: edit.refresh_interval_sec
}
: { url: '', source_kind: 'plaintext', prefix_path: '', community_id: null };
}
});
async function previewCdn() {
const urlTrim = form.url.trim();
if (!urlTrim) {
notify.error('Укажите URL');
return;
}
previewLoading = true;
previewError = null;
previewOk = false;
try {
const res = await apiMutate<CdnPreviewResponse>(
`/v1/modules/${moduleId}/cdn-sources/preview`,
'POST',
{
url: urlTrim,
source_kind: form.source_kind,
prefix_path: form.prefix_path?.trim() ?? ''
}
);
previewItems = res.items;
previewTotal = res.total;
previewTruncated = res.truncated;
previewOk = true;
} catch (e) {
previewError = e instanceof Error ? e.message : String(e);
previewItems = [];
previewTotal = 0;
previewTruncated = false;
previewOk = false;
} finally {
previewLoading = false;
}
}
async function save() {
const urlTrim = form.url.trim();
if (!urlTrim) {
notify.error('Укажите URL');
return;
}
saving = true;
try {
const body = {
...form,
url: urlTrim,
source_kind: form.source_kind,
prefix_path: form.prefix_path?.trim() ?? ''
};
if (edit) {
await apiMutate(`/v1/modules/${moduleId}/cdn-sources/${edit.id}`, 'PATCH', body);
notify.success('Источник обновлён');
} else {
await apiMutate(`/v1/modules/${moduleId}/cdn-sources`, 'POST', body);
notify.success('Источник добавлен');
}
clearPreview();
open = false;
await onSaved();
} catch (e) {
notifyApiError(e);
} finally {
saving = false;
}
}
function handleOpenChange(next: boolean) {
open = next;
if (!next) {
clearPreview();
onClose();
}
}
</script>
<Dialog {open} onOpenChange={handleOpenChange}>
<DialogContent class="sm:max-w-lg">
<DialogHeader>
<DialogTitle>{edit ? 'Редактировать источник' : 'Новый CDN-источник'}</DialogTitle>
</DialogHeader>
<div class="space-y-4 py-2">
<div class="space-y-1.5">
<Label for="cdn-url">URL</Label>
<Input id="cdn-url" placeholder="https://example.com/list.txt" bind:value={form.url} />
</div>
<div class="space-y-1.5">
<Label for="cdn-kind">Тип источника</Label>
<Select
type="single"
value={form.source_kind}
onValueChange={(v) => {
form.source_kind = v || 'plaintext';
}}
>
<SelectTrigger id="cdn-kind" class="w-full">
{form.source_kind === 'json' ? 'json' : 'plaintext'}
</SelectTrigger>
<SelectContent>
<SelectItem value="plaintext">plaintext</SelectItem>
<SelectItem value="json">json</SelectItem>
</SelectContent>
</Select>
</div>
<div class="space-y-1.5">
<Label for="cdn-prefix-path">JSON path (prefix_path)</Label>
<Input
id="cdn-prefix-path"
placeholder="напр. prefixes[] или data.items[].cidr"
bind:value={form.prefix_path}
/>
{#if form.source_kind === 'json' && !form.prefix_path?.trim()}
<p class="text-xs text-muted-foreground">
Для JSON укажите путь к полям с CIDR; пустой путь может не дать префиксов.
</p>
{/if}
</div>
<div class="space-y-1.5">
<Label for="cdn-comm">Community</Label>
<Select
type="single"
value={nullableSelectValue(form.community_id)}
onValueChange={(v) => {
form.community_id = fromNullableSelect(v);
}}
>
<SelectTrigger id="cdn-comm" class="w-full">
{form.community_id ? communityLabel(form.community_id, communities) : 'Не выбрано'}
</SelectTrigger>
<SelectContent>
<SelectItem value={NONE_OPTION}>Не выбрано</SelectItem>
{#each communities as c (c.id)}
<SelectItem value={c.id}>{communityOptionLabel(c)}</SelectItem>
{/each}
</SelectContent>
</Select>
</div>
<div class="space-y-1.5">
<Label for="cdn-interval">Интервал обновления (сек)</Label>
<Input
id="cdn-interval"
type="number"
placeholder="3600"
bind:value={form.refresh_interval_sec}
/>
</div>
<div class="flex flex-col gap-2 rounded-lg border border-border p-3">
<div class="flex flex-wrap items-center gap-2">
<Button
type="button"
variant="secondary"
size="sm"
onclick={previewCdn}
disabled={previewLoading}
>
{previewLoading ? 'Загрузка…' : 'Предпросмотр'}
</Button>
{#if previewError}
<span class="text-sm text-destructive">{previewError}</span>
{:else if previewOk}
<span class="text-sm text-muted-foreground">
Всего: {previewTotal}{#if previewTruncated}
<span class="text-amber-600 dark:text-amber-500"> (обрезано)</span>{/if}
</span>
{/if}
</div>
{#if previewItems.length}
<ul class="max-h-48 overflow-y-auto rounded-md border bg-muted/40 p-2 font-mono text-xs">
{#each previewItems as item, i (`${i}-${item}`)}
<li class="py-0.5">{item}</li>
{/each}
</ul>
{/if}
</div>
</div>
<DialogFooter>
<Button variant="outline" onclick={() => handleOpenChange(false)}>Отмена</Button>
<Button onclick={save} disabled={saving}>
{saving ? 'Сохранение…' : edit ? 'Сохранить' : 'Добавить'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -0,0 +1,238 @@
<script lang="ts">
import { apiMutate } from '$lib/api/client.js';
import type { BgpCommunity, CdnSource } from '$lib/api/types.js';
import { formatDateTime } from '$lib/modules/display.js';
import {
communityLabel,
normalizeCdnSourceKind
} from '$lib/components/modules/module-helpers.js';
import { Badge } from '$lib/ui/core/badge/index.js';
import { Button } from '$lib/ui/core/button/index.js';
import { Checkbox } from '$lib/ui/core/checkbox/index.js';
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription
} from '$lib/ui/core/card/index.js';
import AppDataTable from '$lib/ui/patterns/data-table/app-data-table.svelte';
import { confirm } from '$lib/ui/patterns/confirm/confirm-state.svelte.js';
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
import ModuleCdnSourceDialog from '$lib/components/modules/ModuleCdnSourceDialog.svelte';
import Plus from '@lucide/svelte/icons/plus';
import Pencil from '@lucide/svelte/icons/pencil';
import Trash2 from '@lucide/svelte/icons/trash-2';
type Props = {
moduleId: string;
sources: CdnSource[];
communities: BgpCommunity[];
loading?: boolean;
onChanged: () => void | Promise<void>;
};
let { moduleId, sources, communities, loading = false, onChanged }: Props = $props();
let dialogOpen = $state(false);
let editTarget = $state<CdnSource | null>(null);
let selectedIds = $state(new Set<string>());
let deletingBulk = $state(false);
const selectedCount = $derived(selectedIds.size);
const allSelected = $derived(sources.length > 0 && selectedIds.size === sources.length);
const columns = [
{ id: 'select', label: '', class: 'w-10' },
{ id: 'url', label: 'URL', sortable: true, sortValue: (s: CdnSource) => s.url },
{ id: 'kind', label: 'Тип', sortable: true, sortValue: (s: CdnSource) => s.source_kind },
{ id: 'community', label: 'Community' },
{
id: 'interval',
label: 'Интервал',
sortable: true,
sortValue: (s: CdnSource) => s.refresh_interval_sec ?? 0
},
{
id: 'refreshed',
label: 'Последнее обновление',
sortable: true,
sortValue: (s: CdnSource) => s.last_refreshed_at ?? ''
},
{ id: 'actions', label: '', class: 'w-20' }
] as const;
$effect(() => {
const validIds = new Set(sources.map((s) => s.id));
selectedIds = new Set([...selectedIds].filter((id) => validIds.has(id)));
});
function toggleSelection(id: string) {
const next = new Set(selectedIds);
if (next.has(id)) next.delete(id);
else next.add(id);
selectedIds = next;
}
function toggleAll(checked: boolean) {
selectedIds = checked ? new Set(sources.map((s) => s.id)) : new Set<string>();
}
function openCreate() {
editTarget = null;
dialogOpen = true;
}
function openEdit(src: CdnSource) {
editTarget = src;
dialogOpen = true;
}
function requestDelete(src: CdnSource) {
void confirm({
title: 'Удалить CDN-источник?',
description: src.url,
confirmLabel: 'Удалить',
destructive: true,
onConfirm: async () => {
await apiMutate(`/v1/modules/${moduleId}/cdn-sources/${src.id}`, 'DELETE', undefined, {
idempotent: false
});
notify.success('Удалено');
await onChanged();
}
});
}
function requestBulkDelete() {
if (selectedCount === 0) return;
void confirm({
title: 'Удалить выбранные CDN-источники?',
description: `Будет удалено: ${selectedCount}`,
confirmLabel: 'Удалить',
destructive: true,
onConfirm: bulkDelete
});
}
async function bulkDelete() {
if (selectedCount === 0) return;
deletingBulk = true;
let deleted = 0;
try {
for (const id of selectedIds) {
try {
await apiMutate(`/v1/modules/${moduleId}/cdn-sources/${id}`, 'DELETE', undefined, {
idempotent: false
});
deleted += 1;
} catch (e) {
notifyApiError(e);
}
}
if (deleted > 0) notify.success(`Удалено CDN-источников: ${deleted}`);
await onChanged();
} finally {
deletingBulk = false;
}
}
</script>
<Card>
<CardHeader class="flex flex-col gap-3 pb-2 sm:flex-row sm:items-center sm:justify-between">
<div class="min-w-0 flex-1">
<CardTitle class="text-base">CDN-источники</CardTitle>
<CardDescription>URL источников для скачивания списков CIDR.</CardDescription>
</div>
<div class="flex shrink-0 flex-wrap items-center justify-end gap-2 self-start sm:self-auto">
<Button size="sm" onclick={openCreate}><Plus />Добавить</Button>
{#if selectedCount > 0}
<Button variant="destructive" size="sm" onclick={requestBulkDelete} disabled={deletingBulk}>
<Trash2 />
Удалить ({selectedCount})
</Button>
{/if}
</div>
</CardHeader>
<CardContent class="p-4 pt-0">
<AppDataTable
columns={[...columns]}
rows={sources}
rowKey={(s) => s.id}
{loading}
emptyTitle="Нет CDN-источников"
emptyDescription="Добавьте URL для загрузки списков CIDR."
>
{#snippet toolbar()}
{#if sources.length > 0}
<div class="flex items-center gap-2">
<Checkbox
checked={allSelected}
onCheckedChange={(v) => toggleAll(v === true)}
aria-label="Выбрать все CDN-источники"
/>
<span class="text-sm text-muted-foreground">Выбрать все</span>
</div>
{/if}
{/snippet}
{#snippet cell({ row: src, column })}
{#if column.id === 'select'}
<Checkbox
checked={selectedIds.has(src.id)}
aria-label="Выбрать CDN-источник"
onCheckedChange={() => toggleSelection(src.id)}
/>
{:else if column.id === 'url'}
<span class="max-w-xs truncate font-mono text-xs" title={src.url}>{src.url}</span>
{:else if column.id === 'kind'}
<div class="flex flex-col gap-0.5">
<Badge variant="outline">{normalizeCdnSourceKind(src.source_kind)}</Badge>
{#if src.prefix_path?.trim()}
<span
class="font-mono text-xs break-all text-muted-foreground"
title={src.prefix_path}>{src.prefix_path}</span
>
{/if}
</div>
{:else if column.id === 'community'}
<span class="text-sm text-muted-foreground">
{communityLabel(src.community_id, communities)}
</span>
{:else if column.id === 'interval'}
<span class="text-sm text-muted-foreground">
{src.refresh_interval_sec != null ? `${src.refresh_interval_sec}с` : '—'}
</span>
{:else if column.id === 'refreshed'}
<span class="text-sm whitespace-nowrap text-muted-foreground">
{formatDateTime(src.last_refreshed_at)}
</span>
{:else if column.id === 'actions'}
<div class="flex gap-1">
<Button variant="ghost" size="icon-sm" onclick={() => openEdit(src)}>
<Pencil class="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon-sm"
class="text-destructive"
onclick={() => requestDelete(src)}
>
<Trash2 class="size-3.5" />
</Button>
</div>
{/if}
{/snippet}
</AppDataTable>
</CardContent>
</Card>
<ModuleCdnSourceDialog
bind:open={dialogOpen}
{moduleId}
edit={editTarget}
{communities}
onSaved={onChanged}
onClose={() => {
editTarget = null;
}}
/>
@@ -0,0 +1,144 @@
<script lang="ts">
import { apiMutate } from '$lib/api/client.js';
import type { ModuleCreate } from '$lib/api/types.js';
import { moduleTypeRu } from '$lib/ui-labels.js';
import { Button } from '$lib/ui/core/button/index.js';
import { Input } from '$lib/ui/core/input/index.js';
import { Label } from '$lib/ui/core/label/index.js';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogDescription
} from '$lib/ui/core/dialog/index.js';
import { Select, SelectContent, SelectItem, SelectTrigger } from '$lib/ui/core/select/index.js';
import { Switch } from '$lib/ui/core/switch/index.js';
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
type Props = {
open: boolean;
onClose: () => void;
onCreated: () => void | Promise<void>;
};
let { open = $bindable(), onClose, onCreated }: Props = $props();
let saving = $state(false);
let form = $state<ModuleCreate>({
type: 'AS_PREFIXES',
name: '',
enabled: true,
priority: 0
});
const moduleTypes = [
{ value: 'AS_PREFIXES', label: moduleTypeRu('AS_PREFIXES') },
{ value: 'CDN_CIDRS', label: moduleTypeRu('CDN_CIDRS') },
{ value: 'DOMAINS', label: moduleTypeRu('DOMAINS') },
{ value: 'IP_RANGES', label: moduleTypeRu('IP_RANGES') }
] as const;
function resetForm() {
form = { type: 'AS_PREFIXES', name: '', enabled: true, priority: 0 };
}
async function create() {
if (!form.name.trim()) {
notify.error('Укажите название модуля');
return;
}
saving = true;
try {
await apiMutate('/v1/modules', 'POST', form);
notify.success('Модуль создан');
open = false;
resetForm();
await onCreated();
} catch (e) {
notifyApiError(e);
} finally {
saving = false;
}
}
function handleOpenChange(next: boolean) {
open = next;
if (!next) {
onClose();
resetForm();
}
}
</script>
<Dialog {open} onOpenChange={handleOpenChange}>
<DialogContent class="sm:max-w-md">
<DialogHeader>
<DialogTitle>Новый модуль</DialogTitle>
<DialogDescription>Создание нового модуля префиксов.</DialogDescription>
</DialogHeader>
<div class="space-y-4 py-2">
<div class="space-y-1.5">
<Label for="m-name">Название</Label>
<Input id="m-name" bind:value={form.name} placeholder="my-asn-module" />
</div>
<div class="space-y-1.5">
<Label for="m-type">Тип</Label>
<Select
type="single"
value={form.type}
onValueChange={(v) => {
if (v) form.type = v as typeof form.type;
}}
>
<SelectTrigger id="m-type" class="w-full">
{moduleTypes.find((t) => t.value === form.type)?.label ?? 'Выберите тип'}
</SelectTrigger>
<SelectContent>
{#each moduleTypes as t (t.value)}
<SelectItem value={t.value}>{t.label}</SelectItem>
{/each}
</SelectContent>
</Select>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="space-y-1.5">
<Label for="m-priority">Приоритет</Label>
<Input id="m-priority" type="number" bind:value={form.priority} />
</div>
<div class="space-y-1.5">
<Label for="m-interval">Интервал (сек)</Label>
<Input
id="m-interval"
type="number"
placeholder="3600"
bind:value={form.refresh_interval_sec}
/>
</div>
</div>
<div
class="flex flex-row items-center justify-between gap-4 rounded-lg border border-border bg-muted/30 p-3"
>
<div class="grid min-w-0 flex-1 gap-1 pr-2">
<Label for="m-enabled" class="leading-snug text-foreground">Включён</Label>
<p class="text-xs leading-snug text-muted-foreground">
Модуль участвует в сборке ревизий, если включён.
</p>
</div>
<Switch
id="m-enabled"
class="shrink-0"
checked={form.enabled !== false}
onCheckedChange={(v) => {
form = { ...form, enabled: v };
}}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onclick={() => handleOpenChange(false)}>Отмена</Button>
<Button onclick={create} disabled={saving}>{saving ? 'Создание…' : 'Создать'}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -0,0 +1,57 @@
<script lang="ts">
import { resolve } from '$app/paths';
import type { ModuleRow } from '$lib/api/types.js';
import { moduleEnabledRu, moduleEnabledBadgeVariant, moduleTypeRu } from '$lib/ui-labels.js';
import { Badge } from '$lib/ui/core/badge/index.js';
import { Button } from '$lib/ui/core/button/index.js';
import PageHeader from '$lib/ui/app/page-header/page-header.svelte';
import ArrowLeft from '@lucide/svelte/icons/arrow-left';
import Pencil from '@lucide/svelte/icons/pencil';
import Trash2 from '@lucide/svelte/icons/trash-2';
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
import Blocks from '@lucide/svelte/icons/blocks';
type Props = {
mod: ModuleRow;
refreshing: boolean;
onRefresh: () => void;
onEdit: () => void;
onDelete: () => void;
};
let { mod, refreshing, onRefresh, onEdit, onDelete }: Props = $props();
</script>
<div class="flex min-w-0 items-start gap-2">
<Button variant="ghost" size="icon-sm" class="mt-1 shrink-0" href={resolve('/modules')}>
<ArrowLeft class="size-4" />
</Button>
<PageHeader
class="min-w-0 flex-1"
title={mod.name}
description={mod.id}
icon={Blocks}
iconClass="bg-chart-2/15 text-chart-2"
>
{#snippet actions()}
<div class="flex flex-wrap items-center gap-2">
<Badge variant="outline">{moduleTypeRu(mod.type)}</Badge>
<Badge variant={moduleEnabledBadgeVariant(!!mod.enabled)} class="text-xs">
{moduleEnabledRu(!!mod.enabled)}
</Badge>
<Button variant="outline" size="sm" onclick={onRefresh} disabled={refreshing}>
<RefreshCw class={refreshing ? 'animate-spin' : ''} />
Обновить
</Button>
<Button variant="outline" size="sm" onclick={onEdit}>
<Pencil />
Редактировать
</Button>
<Button variant="destructive" size="sm" onclick={onDelete}>
<Trash2 />
Удалить
</Button>
</div>
{/snippet}
</PageHeader>
</div>
@@ -0,0 +1,303 @@
<script lang="ts">
import { apiFetch, apiMutate } from '$lib/api/client.js';
import type { BgpCommunity, DomainEntry, ModuleRow } from '$lib/api/types.js';
import {
communityLabel,
sanitizeFilenamePart,
supportsCsvIO
} from '$lib/components/modules/module-helpers.js';
import { Button } from '$lib/ui/core/button/index.js';
import { Checkbox } from '$lib/ui/core/checkbox/index.js';
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription
} from '$lib/ui/core/card/index.js';
import AppDataTable from '$lib/ui/patterns/data-table/app-data-table.svelte';
import { confirm } from '$lib/ui/patterns/confirm/confirm-state.svelte.js';
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
import ModuleDomainEntryDialog from '$lib/components/modules/ModuleDomainEntryDialog.svelte';
import Plus from '@lucide/svelte/icons/plus';
import Pencil from '@lucide/svelte/icons/pencil';
import Trash2 from '@lucide/svelte/icons/trash-2';
import Upload from '@lucide/svelte/icons/upload';
import Download from '@lucide/svelte/icons/download';
type Props = {
moduleId: string;
mod: ModuleRow;
entries: DomainEntry[];
communities: BgpCommunity[];
loading?: boolean;
onChanged: () => void | Promise<void>;
};
let { moduleId, mod, entries, communities, loading = false, onChanged }: Props = $props();
let dialogOpen = $state(false);
let editTarget = $state<DomainEntry | null>(null);
let selectedIds = $state(new Set<string>());
let deletingBulk = $state(false);
let csvImporting = $state(false);
let csvExporting = $state(false);
let csvFileInput = $state<HTMLInputElement | null>(null);
const selectedCount = $derived(selectedIds.size);
const allSelected = $derived(entries.length > 0 && selectedIds.size === entries.length);
const columns = [
{ id: 'select', label: '', class: 'w-10' },
{ id: 'fqdn', label: 'FQDN', sortable: true, sortValue: (e: DomainEntry) => e.fqdn },
{ id: 'community', label: 'Community' },
{ id: 'actions', label: '', class: 'w-20' }
] as const;
$effect(() => {
const validIds = new Set(entries.map((e) => e.id));
selectedIds = new Set([...selectedIds].filter((id) => validIds.has(id)));
});
function toggleSelection(id: string) {
const next = new Set(selectedIds);
if (next.has(id)) next.delete(id);
else next.add(id);
selectedIds = next;
}
function toggleAll(checked: boolean) {
selectedIds = checked ? new Set(entries.map((e) => e.id)) : new Set<string>();
}
function openCreate() {
editTarget = null;
dialogOpen = true;
}
function openEdit(entry: DomainEntry) {
editTarget = entry;
dialogOpen = true;
}
function requestDelete(entry: DomainEntry) {
void confirm({
title: 'Удалить домен?',
description: entry.fqdn,
confirmLabel: 'Удалить',
destructive: true,
onConfirm: async () => {
await apiMutate(`/v1/modules/${moduleId}/domain-entries/${entry.id}`, 'DELETE', undefined, {
idempotent: false
});
notify.success('Удалено');
await onChanged();
}
});
}
function requestBulkDelete() {
if (selectedCount === 0) return;
void confirm({
title: 'Удалить выбранные домены?',
description: `Будет удалено: ${selectedCount}`,
confirmLabel: 'Удалить',
destructive: true,
onConfirm: bulkDelete
});
}
async function bulkDelete() {
if (selectedCount === 0) return;
deletingBulk = true;
let deleted = 0;
try {
for (const id of selectedIds) {
try {
await apiMutate(`/v1/modules/${moduleId}/domain-entries/${id}`, 'DELETE', undefined, {
idempotent: false
});
deleted += 1;
} catch (e) {
notifyApiError(e);
}
}
if (deleted > 0) notify.success(`Удалено доменов: ${deleted}`);
await onChanged();
} finally {
deletingBulk = false;
}
}
async function readErrorText(res: Response): Promise<string> {
const body = (await res.text()).trim();
return body || `HTTP ${res.status}`;
}
async function exportCsv() {
if (!supportsCsvIO(mod.type) || csvExporting) return;
csvExporting = true;
try {
const res = await apiFetch(`/v1/modules/${moduleId}/entries.csv`, {
method: 'GET',
headers: { Accept: 'text/csv' }
});
if (!res.ok) {
notify.error(await readErrorText(res));
return;
}
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${sanitizeFilenamePart(mod.name)}-${mod.type.toLowerCase()}-entries.csv`;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
} catch (e) {
notifyApiError(e);
} finally {
csvExporting = false;
}
}
function openImportPicker() {
if (!supportsCsvIO(mod.type) || csvImporting) return;
csvFileInput?.click();
}
async function handleImportChange(event: Event) {
const input = event.currentTarget as HTMLInputElement | null;
const file = input?.files?.[0];
if (!file || csvImporting) return;
csvImporting = true;
try {
const fileText = await file.text();
const res = await apiFetch(`/v1/modules/${moduleId}/entries.csv`, {
method: 'POST',
headers: { 'Content-Type': 'text/csv' },
body: fileText
});
if (!res.ok) {
notify.error(await readErrorText(res));
return;
}
const payload = (await res.json()) as { imported?: number };
notify.success(`Импортировано записей: ${payload.imported ?? 0}`);
await onChanged();
} catch (e) {
notifyApiError(e);
} finally {
csvImporting = false;
if (input) input.value = '';
}
}
</script>
<input
class="hidden"
type="file"
accept=".csv,text/csv"
bind:this={csvFileInput}
onchange={handleImportChange}
/>
<Card>
<CardHeader class="flex flex-col gap-3 pb-2 sm:flex-row sm:items-center sm:justify-between">
<div class="min-w-0 flex-1">
<CardTitle class="text-base">Домены</CardTitle>
<CardDescription>FQDN для резолвинга через DoH.</CardDescription>
</div>
<div class="flex shrink-0 flex-wrap items-center justify-end gap-2 self-start sm:self-auto">
<Button
variant="outline"
size="sm"
onclick={openImportPicker}
disabled={!supportsCsvIO(mod.type) || csvImporting || csvExporting}
>
<Upload />
{csvImporting ? 'Импорт…' : 'Импорт CSV'}
</Button>
<Button
variant="outline"
size="sm"
onclick={exportCsv}
disabled={!supportsCsvIO(mod.type) || csvExporting || csvImporting}
>
<Download />
{csvExporting ? 'Экспорт…' : 'Экспорт CSV'}
</Button>
<Button size="sm" onclick={openCreate}><Plus />Добавить</Button>
{#if selectedCount > 0}
<Button variant="destructive" size="sm" onclick={requestBulkDelete} disabled={deletingBulk}>
<Trash2 />
Удалить ({selectedCount})
</Button>
{/if}
</div>
</CardHeader>
<CardContent class="p-4 pt-0">
<AppDataTable
columns={[...columns]}
rows={entries}
rowKey={(e) => e.id}
{loading}
emptyTitle="Нет доменов"
emptyDescription="Добавьте FQDN или импортируйте CSV."
>
{#snippet toolbar()}
{#if entries.length > 0}
<div class="flex items-center gap-2">
<Checkbox
checked={allSelected}
onCheckedChange={(v) => toggleAll(v === true)}
aria-label="Выбрать все домены"
/>
<span class="text-sm text-muted-foreground">Выбрать все</span>
</div>
{/if}
{/snippet}
{#snippet cell({ row: entry, column })}
{#if column.id === 'select'}
<Checkbox
checked={selectedIds.has(entry.id)}
aria-label={`Выбрать домен ${entry.fqdn}`}
onCheckedChange={() => toggleSelection(entry.id)}
/>
{:else if column.id === 'fqdn'}
<span class="font-mono">{entry.fqdn}</span>
{:else if column.id === 'community'}
<span class="text-sm text-muted-foreground">
{communityLabel(entry.community_id, communities)}
</span>
{:else if column.id === 'actions'}
<div class="flex gap-1">
<Button variant="ghost" size="icon-sm" onclick={() => openEdit(entry)}>
<Pencil class="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon-sm"
class="text-destructive"
onclick={() => requestDelete(entry)}
>
<Trash2 class="size-3.5" />
</Button>
</div>
{/if}
{/snippet}
</AppDataTable>
</CardContent>
</Card>
<ModuleDomainEntryDialog
bind:open={dialogOpen}
{moduleId}
edit={editTarget}
{communities}
onSaved={onChanged}
onClose={() => {
editTarget = null;
}}
/>
@@ -0,0 +1,109 @@
<script lang="ts">
import { apiMutate } from '$lib/api/client.js';
import type { BgpCommunity, DomainEntry, DomainEntryCreate } from '$lib/api/types.js';
import {
communityLabel,
communityOptionLabel,
fromNullableSelect,
NONE_OPTION,
nullableSelectValue
} from '$lib/components/modules/module-helpers.js';
import { Button } from '$lib/ui/core/button/index.js';
import { Input } from '$lib/ui/core/input/index.js';
import { Label } from '$lib/ui/core/label/index.js';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter
} from '$lib/ui/core/dialog/index.js';
import { Select, SelectContent, SelectItem, SelectTrigger } from '$lib/ui/core/select/index.js';
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
type Props = {
open: boolean;
moduleId: string;
edit: DomainEntry | null;
communities: BgpCommunity[];
onSaved: () => void | Promise<void>;
onClose: () => void;
};
let { open = $bindable(), moduleId, edit, communities, onSaved, onClose }: Props = $props();
let saving = $state(false);
let form = $state<DomainEntryCreate>({ fqdn: '', community_id: null });
$effect(() => {
if (open) {
form = edit
? { fqdn: edit.fqdn, community_id: edit.community_id }
: { fqdn: '', community_id: null };
}
});
async function save() {
saving = true;
try {
if (edit) {
await apiMutate(`/v1/modules/${moduleId}/domain-entries/${edit.id}`, 'PATCH', form);
notify.success('Домен обновлён');
} else {
await apiMutate(`/v1/modules/${moduleId}/domain-entries`, 'POST', form);
notify.success('Домен добавлен');
}
open = false;
await onSaved();
} catch (e) {
notifyApiError(e);
} finally {
saving = false;
}
}
function handleOpenChange(next: boolean) {
open = next;
if (!next) onClose();
}
</script>
<Dialog {open} onOpenChange={handleOpenChange}>
<DialogContent class="sm:max-w-sm">
<DialogHeader>
<DialogTitle>{edit ? 'Редактировать домен' : 'Новый домен'}</DialogTitle>
</DialogHeader>
<div class="space-y-4 py-2">
<div class="space-y-1.5">
<Label for="dom-fqdn">FQDN</Label>
<Input id="dom-fqdn" placeholder="example.com" bind:value={form.fqdn} />
</div>
<div class="space-y-1.5">
<Label for="dom-comm">Community</Label>
<Select
type="single"
value={nullableSelectValue(form.community_id)}
onValueChange={(v) => {
form.community_id = fromNullableSelect(v);
}}
>
<SelectTrigger id="dom-comm" class="w-full">
{form.community_id ? communityLabel(form.community_id, communities) : 'Не выбрано'}
</SelectTrigger>
<SelectContent>
<SelectItem value={NONE_OPTION}>Не выбрано</SelectItem>
{#each communities as c (c.id)}
<SelectItem value={c.id}>{communityOptionLabel(c)}</SelectItem>
{/each}
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button variant="outline" onclick={() => handleOpenChange(false)}>Отмена</Button>
<Button onclick={save} disabled={saving}>
{saving ? 'Сохранение…' : edit ? 'Сохранить' : 'Добавить'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -0,0 +1,283 @@
<script lang="ts">
import { apiMutate } from '$lib/api/client.js';
import type {
BgpCommunity,
DohProfile,
DohResolverPolicy,
ModulePatch,
ModuleRow
} from '$lib/api/types.js';
import { dohPolicyRu } from '$lib/ui-labels.js';
import {
communityLabel,
communityOptionLabel,
fromNullableSelect,
moduleDohProfileIds,
NONE_OPTION,
nullableSelectValue
} from '$lib/components/modules/module-helpers.js';
import { Button } from '$lib/ui/core/button/index.js';
import { Input } from '$lib/ui/core/input/index.js';
import { Label } from '$lib/ui/core/label/index.js';
import { Checkbox } from '$lib/ui/core/checkbox/index.js';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter
} from '$lib/ui/core/dialog/index.js';
import { Select, SelectContent, SelectItem, SelectTrigger } from '$lib/ui/core/select/index.js';
import { Switch } from '$lib/ui/core/switch/index.js';
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
const dohPolicyOptions: { value: DohResolverPolicy; label: string; hint: string }[] = [
{
value: 'primary_only',
label: 'Только первый',
hint: 'Используется первый выбранный DoH-профиль.'
},
{
value: 'failover',
label: 'Резервирование',
hint: 'Профили по порядку до первого успешного ответа.'
},
{
value: 'union',
label: 'Объединение',
hint: 'Все A/AAAA со всех профилей (geo-split DNS).'
}
];
type Props = {
open: boolean;
mod: ModuleRow;
moduleId: string;
communities: BgpCommunity[];
dohProfiles: DohProfile[];
onSaved: () => void | Promise<void>;
onClose: () => void;
};
let {
open = $bindable(),
mod,
moduleId,
communities,
dohProfiles,
onSaved,
onClose
}: Props = $props();
let editForm = $state<ModulePatch>({});
let editSaving = $state(false);
$effect(() => {
if (open && mod) {
editForm = {
name: mod.name,
enabled: mod.enabled,
priority: mod.priority,
refresh_interval_sec: mod.refresh_interval_sec,
cron_expr: mod.cron_expr,
default_community_id: mod.default_community_id,
doh_profile_ids: moduleDohProfileIds(mod),
doh_resolver_policy: mod.doh_resolver_policy ?? 'primary_only'
};
}
});
function toggleEditDohProfile(id: string, checked: boolean) {
let ids = [...(editForm.doh_profile_ids ?? [])];
if (checked) {
if (!ids.includes(id)) ids.push(id);
} else {
ids = ids.filter((x) => x !== id);
}
editForm = { ...editForm, doh_profile_ids: ids };
}
function isEditDohProfileSelected(id: string): boolean {
return (editForm.doh_profile_ids ?? []).includes(id);
}
async function saveMod() {
editSaving = true;
try {
const cron =
typeof editForm.cron_expr === 'string' ? editForm.cron_expr.trim() : editForm.cron_expr;
const intervalRaw = editForm.refresh_interval_sec;
const interval =
intervalRaw === null || intervalRaw === undefined ? null : Number(intervalRaw);
const payload: ModulePatch = {
...editForm,
cron_expr: cron ? cron : null,
refresh_interval_sec: Number.isFinite(interval) ? interval : null,
default_community_id: fromNullableSelect(
nullableSelectValue(editForm.default_community_id)
),
doh_profile_ids: editForm.doh_profile_ids ?? [],
doh_resolver_policy: editForm.doh_resolver_policy ?? 'primary_only'
};
await apiMutate<ModuleRow>(`/v1/modules/${moduleId}`, 'PATCH', payload);
notify.success('Модуль обновлён');
open = false;
await onSaved();
} catch (e) {
notifyApiError(e);
} finally {
editSaving = false;
}
}
function handleOpenChange(next: boolean) {
open = next;
if (!next) onClose();
}
</script>
<Dialog {open} onOpenChange={handleOpenChange}>
<DialogContent class="sm:max-w-md">
<DialogHeader>
<DialogTitle>Редактировать модуль</DialogTitle>
</DialogHeader>
<div class="space-y-4 py-2">
<div class="space-y-1.5">
<Label for="e-name">Название</Label>
<Input id="e-name" bind:value={editForm.name} />
</div>
<div class="grid grid-cols-2 gap-4">
<div class="space-y-1.5">
<Label for="e-priority">Приоритет</Label>
<Input id="e-priority" type="number" bind:value={editForm.priority} />
</div>
<div class="space-y-1.5">
<Label for="e-interval">Интервал (сек)</Label>
<Input id="e-interval" type="number" bind:value={editForm.refresh_interval_sec} />
</div>
</div>
<div class="space-y-1.5">
<Label for="e-cron">Cron-выражение</Label>
<Input id="e-cron" placeholder="0 */6 * * *" bind:value={editForm.cron_expr} />
</div>
<div class="space-y-1.5">
<Label for="e-comm">Community по умолчанию</Label>
<Select
type="single"
value={nullableSelectValue(editForm.default_community_id)}
onValueChange={(v) => {
editForm.default_community_id = fromNullableSelect(v);
}}
>
<SelectTrigger id="e-comm" class="w-full">
{editForm.default_community_id
? communityLabel(editForm.default_community_id, communities)
: 'Не выбрано'}
</SelectTrigger>
<SelectContent>
<SelectItem value={NONE_OPTION}>Не выбрано</SelectItem>
{#each communities as c (c.id)}
<SelectItem value={c.id}>{communityOptionLabel(c)}</SelectItem>
{/each}
</SelectContent>
</Select>
<Button
type="button"
variant="ghost"
size="sm"
class="h-7 px-2"
onclick={() => {
editForm.default_community_id = null;
}}
>
Сбросить
</Button>
</div>
{#if mod.type === 'DOMAINS'}
<div class="space-y-1.5">
<Label for="e-doh-policy">Политика DoH</Label>
<Select
type="single"
value={editForm.doh_resolver_policy ?? 'primary_only'}
onValueChange={(v) => {
if (v) editForm.doh_resolver_policy = v as DohResolverPolicy;
}}
>
<SelectTrigger id="e-doh-policy" class="w-full">
{dohPolicyRu(editForm.doh_resolver_policy)}
</SelectTrigger>
<SelectContent>
{#each dohPolicyOptions as opt (opt.value)}
<SelectItem value={opt.value}>{opt.label}</SelectItem>
{/each}
</SelectContent>
</Select>
<p class="text-xs text-muted-foreground">
{dohPolicyOptions.find(
(o) => o.value === (editForm.doh_resolver_policy ?? 'primary_only')
)?.hint}
</p>
</div>
<div class="space-y-2">
<Label>DoH-профили</Label>
<p class="text-xs text-muted-foreground">
Порядок выбора = порядок в списке (сверху вниз).
</p>
<div class="max-h-40 space-y-2 overflow-y-auto rounded-md border p-3">
{#each dohProfiles as d (d.id)}
<label class="flex items-start gap-2 text-sm">
<Checkbox
checked={isEditDohProfileSelected(d.id)}
onCheckedChange={(v) => toggleEditDohProfile(d.id, v === true)}
/>
<span class="min-w-0 break-all">
<span class="font-medium">{d.name?.trim() ? d.name : d.url}</span>
{#if d.name?.trim()}
<span class="block font-mono text-xs text-muted-foreground">{d.url}</span>
{/if}
</span>
</label>
{:else}
<p class="text-xs text-muted-foreground">Нет профилей — создайте в справочниках.</p>
{/each}
</div>
<Button
type="button"
variant="ghost"
size="sm"
class="h-7 px-2"
onclick={() => {
editForm = { ...editForm, doh_profile_ids: [] };
}}
>
Сбросить профили
</Button>
</div>
{/if}
<div
class="flex flex-row items-center justify-between gap-4 rounded-lg border border-border bg-muted/30 p-3"
>
<div class="grid min-w-0 flex-1 gap-1 pr-2">
<Label for="e-enabled" class="leading-snug text-foreground">Включён</Label>
<p class="text-xs leading-snug text-muted-foreground">
Отключённые модули не участвуют в обновлении конфигурации.
</p>
</div>
<Switch
id="e-enabled"
class="shrink-0"
checked={editForm.enabled !== false}
onCheckedChange={(v) => {
editForm = { ...editForm, enabled: v };
}}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onclick={() => handleOpenChange(false)}>Отмена</Button>
<Button onclick={saveMod} disabled={editSaving}>
{editSaving ? 'Сохранение…' : 'Сохранить'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -0,0 +1,104 @@
<script lang="ts">
import { apiMutate } from '$lib/api/client.js';
import type { BgpCommunity, IpRangeEntry, IpRangeEntryCreate } from '$lib/api/types.js';
import { communityLabel, communityOptionLabel } from '$lib/components/modules/module-helpers.js';
import { Button } from '$lib/ui/core/button/index.js';
import { Input } from '$lib/ui/core/input/index.js';
import { Label } from '$lib/ui/core/label/index.js';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter
} from '$lib/ui/core/dialog/index.js';
import { Select, SelectContent, SelectItem, SelectTrigger } from '$lib/ui/core/select/index.js';
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
type Props = {
open: boolean;
moduleId: string;
edit: IpRangeEntry | null;
communities: BgpCommunity[];
onSaved: () => void | Promise<void>;
onClose: () => void;
};
let { open = $bindable(), moduleId, edit, communities, onSaved, onClose }: Props = $props();
let saving = $state(false);
let form = $state<IpRangeEntryCreate>({ prefix: '', community_id: '' });
$effect(() => {
if (open) {
form = edit
? { prefix: edit.prefix, community_id: edit.community_id }
: { prefix: '', community_id: '' };
}
});
async function save() {
saving = true;
try {
if (edit) {
await apiMutate(`/v1/modules/${moduleId}/ip-range-entries/${edit.id}`, 'PATCH', form);
notify.success('Диапазон обновлён');
} else {
await apiMutate(`/v1/modules/${moduleId}/ip-range-entries`, 'POST', form);
notify.success('Диапазон добавлен');
}
open = false;
await onSaved();
} catch (e) {
notifyApiError(e);
} finally {
saving = false;
}
}
function handleOpenChange(next: boolean) {
open = next;
if (!next) onClose();
}
</script>
<Dialog {open} onOpenChange={handleOpenChange}>
<DialogContent class="sm:max-w-sm">
<DialogHeader>
<DialogTitle>{edit ? 'Редактировать диапазон' : 'Новый IP-диапазон'}</DialogTitle>
</DialogHeader>
<div class="space-y-4 py-2">
<div class="space-y-1.5">
<Label for="ip-prefix">Префикс (CIDR)</Label>
<Input id="ip-prefix" placeholder="203.0.113.0/24" bind:value={form.prefix} />
</div>
<div class="space-y-1.5">
<Label for="ip-comm">Community (обязательно)</Label>
<Select
type="single"
value={form.community_id}
onValueChange={(v) => {
form.community_id = v;
}}
>
<SelectTrigger id="ip-comm" class="w-full">
{form.community_id
? communityLabel(form.community_id, communities)
: 'Выберите community'}
</SelectTrigger>
<SelectContent>
{#each communities as c (c.id)}
<SelectItem value={c.id}>{communityOptionLabel(c)}</SelectItem>
{/each}
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button variant="outline" onclick={() => handleOpenChange(false)}>Отмена</Button>
<Button onclick={save} disabled={saving}>
{saving ? 'Сохранение…' : edit ? 'Сохранить' : 'Добавить'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -0,0 +1,311 @@
<script lang="ts">
import { apiFetch, apiMutate } from '$lib/api/client.js';
import type { BgpCommunity, IpRangeEntry, ModuleRow } from '$lib/api/types.js';
import {
communityLabel,
sanitizeFilenamePart,
supportsCsvIO
} from '$lib/components/modules/module-helpers.js';
import { Button } from '$lib/ui/core/button/index.js';
import { Checkbox } from '$lib/ui/core/checkbox/index.js';
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription
} from '$lib/ui/core/card/index.js';
import AppDataTable from '$lib/ui/patterns/data-table/app-data-table.svelte';
import { confirm } from '$lib/ui/patterns/confirm/confirm-state.svelte.js';
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
import ModuleIpRangeEntryDialog from '$lib/components/modules/ModuleIpRangeEntryDialog.svelte';
import Plus from '@lucide/svelte/icons/plus';
import Pencil from '@lucide/svelte/icons/pencil';
import Trash2 from '@lucide/svelte/icons/trash-2';
import Upload from '@lucide/svelte/icons/upload';
import Download from '@lucide/svelte/icons/download';
type Props = {
moduleId: string;
mod: ModuleRow;
entries: IpRangeEntry[];
communities: BgpCommunity[];
loading?: boolean;
onChanged: () => void | Promise<void>;
};
let { moduleId, mod, entries, communities, loading = false, onChanged }: Props = $props();
let dialogOpen = $state(false);
let editTarget = $state<IpRangeEntry | null>(null);
let selectedIds = $state(new Set<string>());
let deletingBulk = $state(false);
let csvImporting = $state(false);
let csvExporting = $state(false);
let csvFileInput = $state<HTMLInputElement | null>(null);
const selectedCount = $derived(selectedIds.size);
const allSelected = $derived(entries.length > 0 && selectedIds.size === entries.length);
const columns = [
{ id: 'select', label: '', class: 'w-10' },
{
id: 'prefix',
label: 'Префикс (CIDR)',
sortable: true,
sortValue: (e: IpRangeEntry) => e.prefix
},
{ id: 'community', label: 'Community' },
{ id: 'actions', label: '', class: 'w-20' }
] as const;
$effect(() => {
const validIds = new Set(entries.map((e) => e.id));
selectedIds = new Set([...selectedIds].filter((id) => validIds.has(id)));
});
function toggleSelection(id: string) {
const next = new Set(selectedIds);
if (next.has(id)) next.delete(id);
else next.add(id);
selectedIds = next;
}
function toggleAll(checked: boolean) {
selectedIds = checked ? new Set(entries.map((e) => e.id)) : new Set<string>();
}
function openCreate() {
editTarget = null;
dialogOpen = true;
}
function openEdit(entry: IpRangeEntry) {
editTarget = entry;
dialogOpen = true;
}
function requestDelete(entry: IpRangeEntry) {
void confirm({
title: 'Удалить диапазон?',
description: entry.prefix,
confirmLabel: 'Удалить',
destructive: true,
onConfirm: async () => {
await apiMutate(
`/v1/modules/${moduleId}/ip-range-entries/${entry.id}`,
'DELETE',
undefined,
{ idempotent: false }
);
notify.success('Удалено');
await onChanged();
}
});
}
function requestBulkDelete() {
if (selectedCount === 0) return;
void confirm({
title: 'Удалить выбранные диапазоны?',
description: `Будет удалено: ${selectedCount}`,
confirmLabel: 'Удалить',
destructive: true,
onConfirm: bulkDelete
});
}
async function bulkDelete() {
if (selectedCount === 0) return;
deletingBulk = true;
let deleted = 0;
try {
for (const id of selectedIds) {
try {
await apiMutate(`/v1/modules/${moduleId}/ip-range-entries/${id}`, 'DELETE', undefined, {
idempotent: false
});
deleted += 1;
} catch (e) {
notifyApiError(e);
}
}
if (deleted > 0) notify.success(`Удалено диапазонов: ${deleted}`);
await onChanged();
} finally {
deletingBulk = false;
}
}
async function readErrorText(res: Response): Promise<string> {
const body = (await res.text()).trim();
return body || `HTTP ${res.status}`;
}
async function exportCsv() {
if (!supportsCsvIO(mod.type) || csvExporting) return;
csvExporting = true;
try {
const res = await apiFetch(`/v1/modules/${moduleId}/entries.csv`, {
method: 'GET',
headers: { Accept: 'text/csv' }
});
if (!res.ok) {
notify.error(await readErrorText(res));
return;
}
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${sanitizeFilenamePart(mod.name)}-${mod.type.toLowerCase()}-entries.csv`;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
} catch (e) {
notifyApiError(e);
} finally {
csvExporting = false;
}
}
function openImportPicker() {
if (!supportsCsvIO(mod.type) || csvImporting) return;
csvFileInput?.click();
}
async function handleImportChange(event: Event) {
const input = event.currentTarget as HTMLInputElement | null;
const file = input?.files?.[0];
if (!file || csvImporting) return;
csvImporting = true;
try {
const fileText = await file.text();
const res = await apiFetch(`/v1/modules/${moduleId}/entries.csv`, {
method: 'POST',
headers: { 'Content-Type': 'text/csv' },
body: fileText
});
if (!res.ok) {
notify.error(await readErrorText(res));
return;
}
const payload = (await res.json()) as { imported?: number };
notify.success(`Импортировано записей: ${payload.imported ?? 0}`);
await onChanged();
} catch (e) {
notifyApiError(e);
} finally {
csvImporting = false;
if (input) input.value = '';
}
}
</script>
<input
class="hidden"
type="file"
accept=".csv,text/csv"
bind:this={csvFileInput}
onchange={handleImportChange}
/>
<Card>
<CardHeader class="flex flex-col gap-3 pb-2 sm:flex-row sm:items-center sm:justify-between">
<div class="min-w-0 flex-1">
<CardTitle class="text-base">IP-диапазоны</CardTitle>
<CardDescription>Статические CIDR для анонса.</CardDescription>
</div>
<div class="flex shrink-0 flex-wrap items-center justify-end gap-2 self-start sm:self-auto">
<Button
variant="outline"
size="sm"
onclick={openImportPicker}
disabled={!supportsCsvIO(mod.type) || csvImporting || csvExporting}
>
<Upload />
{csvImporting ? 'Импорт…' : 'Импорт CSV'}
</Button>
<Button
variant="outline"
size="sm"
onclick={exportCsv}
disabled={!supportsCsvIO(mod.type) || csvExporting || csvImporting}
>
<Download />
{csvExporting ? 'Экспорт…' : 'Экспорт CSV'}
</Button>
<Button size="sm" onclick={openCreate}><Plus />Добавить</Button>
{#if selectedCount > 0}
<Button variant="destructive" size="sm" onclick={requestBulkDelete} disabled={deletingBulk}>
<Trash2 />
Удалить ({selectedCount})
</Button>
{/if}
</div>
</CardHeader>
<CardContent class="p-4 pt-0">
<AppDataTable
columns={[...columns]}
rows={entries}
rowKey={(e) => e.id}
{loading}
emptyTitle="Нет диапазонов"
emptyDescription="Добавьте CIDR или импортируйте CSV."
>
{#snippet toolbar()}
{#if entries.length > 0}
<div class="flex items-center gap-2">
<Checkbox
checked={allSelected}
onCheckedChange={(v) => toggleAll(v === true)}
aria-label="Выбрать все диапазоны"
/>
<span class="text-sm text-muted-foreground">Выбрать все</span>
</div>
{/if}
{/snippet}
{#snippet cell({ row: entry, column })}
{#if column.id === 'select'}
<Checkbox
checked={selectedIds.has(entry.id)}
aria-label={`Выбрать диапазон ${entry.prefix}`}
onCheckedChange={() => toggleSelection(entry.id)}
/>
{:else if column.id === 'prefix'}
<span class="font-mono">{entry.prefix}</span>
{:else if column.id === 'community'}
<span class="text-sm text-muted-foreground">
{communityLabel(entry.community_id, communities)}
</span>
{:else if column.id === 'actions'}
<div class="flex gap-1">
<Button variant="ghost" size="icon-sm" onclick={() => openEdit(entry)}>
<Pencil class="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon-sm"
class="text-destructive"
onclick={() => requestDelete(entry)}
>
<Trash2 class="size-3.5" />
</Button>
</div>
{/if}
{/snippet}
</AppDataTable>
</CardContent>
</Card>
<ModuleIpRangeEntryDialog
bind:open={dialogOpen}
{moduleId}
edit={editTarget}
{communities}
onSaved={onChanged}
onClose={() => {
editTarget = null;
}}
/>
@@ -0,0 +1,155 @@
<script lang="ts">
import type { AsEntry, BgpCommunity, DohProfile, ModuleRow } from '$lib/api/types.js';
import { formatDateTime, moduleIntervalLabel } from '$lib/modules/display.js';
import { dohPolicyRu } from '$lib/ui-labels.js';
import {
communityLabel,
dohProfileLabel,
moduleDohProfileIds
} from '$lib/components/modules/module-helpers.js';
import { Card, CardContent, CardHeader, CardTitle } from '$lib/ui/core/card/index.js';
import CardSkeleton from '$lib/ui/patterns/feedback/card-skeleton.svelte';
import { cn } from '$lib/utils.js';
import ArrowDownUp from '@lucide/svelte/icons/arrow-down-up';
import Timer from '@lucide/svelte/icons/timer';
import ShieldCheck from '@lucide/svelte/icons/shield-check';
import Network from '@lucide/svelte/icons/network';
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
type Props = {
mod: ModuleRow | null;
communities: BgpCommunity[];
dohProfiles: DohProfile[];
asEntries: AsEntry[];
loading?: boolean;
};
let { mod, communities, dohProfiles, asEntries, loading = false }: Props = $props();
const asPrefixTotal = $derived(
asEntries.reduce((acc, entry) => acc + (entry.prefix_count ?? 0), 0)
);
const statAccents = [
{
border: 'border-l-chart-3',
bg: 'bg-chart-3/5',
iconBg: 'bg-chart-3/15',
iconText: 'text-chart-3'
},
{
border: 'border-l-chart-5',
bg: 'bg-chart-5/5',
iconBg: 'bg-chart-5/15',
iconText: 'text-chart-5'
},
{
border: 'border-l-chart-4',
bg: 'bg-chart-4/5',
iconBg: 'bg-chart-4/15',
iconText: 'text-chart-4'
},
{
border: 'border-l-chart-2',
bg: 'bg-chart-2/5',
iconBg: 'bg-chart-2/15',
iconText: 'text-chart-2'
},
{
border: 'border-l-muted-foreground',
bg: 'bg-muted/30',
iconBg: 'bg-muted',
iconText: 'text-muted-foreground'
}
] as const;
const kpiCards = $derived.by(() => {
if (!mod) return [];
const dohIds = moduleDohProfileIds(mod);
return [
{
id: 'priority',
label: 'Приоритет',
value: String(mod.priority ?? 0),
description: 'порядок в сборке ревизии',
icon: ArrowDownUp,
accent: statAccents[0]
},
{
id: 'interval',
label: 'Интервал',
value: moduleIntervalLabel(mod),
description: 'refresh_interval_sec / cron',
icon: Timer,
accent: statAccents[1],
mono: true
},
{
id: 'doh',
label: 'DoH',
value: mod.type === 'DOMAINS' ? dohPolicyRu(mod.doh_resolver_policy) : '—',
description:
mod.type === 'DOMAINS'
? dohIds.length
? dohIds.map((id) => dohProfileLabel(id, dohProfiles)).join('; ')
: 'Системный DNS'
: 'не применимо',
icon: Network,
accent: statAccents[2]
},
{
id: 'community',
label: 'Community по умолч.',
value: communityLabel(mod.default_community_id, communities),
description: 'для записей без своего community',
icon: ShieldCheck,
accent: statAccents[3]
},
{
id: 'refreshed',
label: 'Последнее обновление',
value: formatDateTime(mod.last_refreshed_at),
description:
mod.type === 'AS_PREFIXES'
? `ASN: ${asEntries.length}, префиксов: ${asPrefixTotal}`
: 'время последнего refresh',
icon: RefreshCw,
accent: statAccents[4]
}
];
});
</script>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-5">
{#if loading}
{#each Array(5) as _, i (i)}
<CardSkeleton />
{/each}
{:else}
{#each kpiCards as card (card.id)}
{@const Icon = card.icon}
{@const a = card.accent}
<Card class={cn('overflow-hidden border-l-4 shadow-sm', a.border, a.bg)}>
<CardHeader class="pb-2">
<p class="flex items-center gap-1.5 text-xs text-muted-foreground">
<span
class={cn('flex size-7 shrink-0 items-center justify-center rounded-md', a.iconBg)}
aria-hidden="true"
>
<Icon class={cn('size-3.5', a.iconText)} />
</span>
{card.label}
</p>
<CardTitle
class={cn('text-base font-semibold break-all', card.mono ? 'font-mono text-sm' : '')}
>
{card.value}
</CardTitle>
</CardHeader>
<CardContent>
<p class="line-clamp-2 text-xs text-muted-foreground">{card.description}</p>
</CardContent>
</Card>
{/each}
{/if}
</div>
@@ -0,0 +1,115 @@
<script lang="ts">
import { resolve } from '$app/paths';
import type { AsEntry, CdnSource, DomainEntry, IpRangeEntry, ModuleRow } from '$lib/api/types.js';
import { Button } from '$lib/ui/core/button/index.js';
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription
} from '$lib/ui/core/card/index.js';
type Props = {
mod: ModuleRow;
asEntries: AsEntry[];
cdnSources: CdnSource[];
domainEntries: DomainEntry[];
ipEntries: IpRangeEntry[];
};
let { mod, asEntries, cdnSources, domainEntries, ipEntries }: Props = $props();
const asPrefixTotal = $derived(
asEntries.reduce((acc, entry) => acc + (entry.prefix_count ?? 0), 0)
);
</script>
<Card>
<CardHeader>
<CardTitle class="text-base">Операционный отчёт модуля</CardTitle>
<CardDescription>
Читаемая сводка по данным модуля: источники, объёмы и ожидаемый результат для
refresh/агрегации.
</CardDescription>
</CardHeader>
<CardContent class="grid gap-3 md:grid-cols-2">
{#if mod.type === 'DOMAINS'}
<div class="rounded-lg border p-3">
<p class="text-sm font-medium">Домены и ожидаемые IP</p>
<p class="text-xs text-muted-foreground">
После refresh домены резолвятся в IP и конвертируются в префиксы.
</p>
<div class="mt-2 space-y-1">
{#each domainEntries.slice(0, 8) as entry (entry.id)}
<p class="font-mono text-xs break-all">{entry.fqdn}</p>
{:else}
<p class="text-xs text-muted-foreground">Нет доменов</p>
{/each}
{#if domainEntries.length > 8}
<p class="text-xs text-muted-foreground">…и ещё {domainEntries.length - 8}</p>
{/if}
</div>
</div>
{:else if mod.type === 'AS_PREFIXES'}
<div class="rounded-lg border p-3">
<p class="text-sm font-medium">ASN и число полученных префиксов</p>
<p class="text-xs text-muted-foreground">
Счётчик префиксов обновляется после успешного refresh (RIPEstat).
</p>
<div class="mt-2 space-y-1">
<p class="text-xs">
Всего ASN: <span class="font-semibold">{asEntries.length}</span>
</p>
<p class="text-xs">
Сумма префиксов: <span class="font-semibold">{asPrefixTotal}</span>
</p>
</div>
</div>
{:else if mod.type === 'CDN_CIDRS'}
<div class="rounded-lg border p-3">
<p class="text-sm font-medium">CDN ссылки и импортируемые префиксы</p>
<p class="text-xs text-muted-foreground">
Каждый URL поставляет список CIDR для агрегации.
</p>
<div class="mt-2 space-y-1">
{#each cdnSources.slice(0, 6) as src (src.id)}
<p class="font-mono text-xs break-all">{src.url}</p>
{:else}
<p class="text-xs text-muted-foreground">Нет CDN источников</p>
{/each}
{#if cdnSources.length > 6}
<p class="text-xs text-muted-foreground">…и ещё {cdnSources.length - 6}</p>
{/if}
</div>
</div>
{:else if mod.type === 'IP_RANGES'}
<div class="rounded-lg border p-3">
<p class="text-sm font-medium">IP ranges для агрегации</p>
<p class="text-xs text-muted-foreground">
Статические CIDR, которые попадают в итоговую ревизию.
</p>
<div class="mt-2 space-y-1">
{#each ipEntries.slice(0, 8) as entry (entry.id)}
<p class="font-mono text-xs">{entry.prefix}</p>
{:else}
<p class="text-xs text-muted-foreground">Нет диапазонов</p>
{/each}
{#if ipEntries.length > 8}
<p class="text-xs text-muted-foreground">…и ещё {ipEntries.length - 8}</p>
{/if}
</div>
</div>
{/if}
<div class="rounded-lg border p-3">
<p class="text-sm font-medium">Результат операции</p>
<p class="text-xs text-muted-foreground">
Подробный результат по конкретному запуску refresh смотрите в Операции → Задачи →
module_refresh: там отображаются источники, количество префиксов и итог агрегации.
</p>
<Button variant="link" class="mt-2 h-auto p-0" href={resolve('/operations?tab=jobs')}>
Открыть задачи
</Button>
</div>
</CardContent>
</Card>
@@ -0,0 +1,60 @@
import type { BgpCommunity, DohProfile, ModuleRow } from '$lib/api/types.js';
export const NONE_OPTION = '__none__';
export function supportsCsvIO(type: ModuleRow['type'] | null | undefined): boolean {
return type === 'AS_PREFIXES' || type === 'DOMAINS' || type === 'IP_RANGES';
}
export function sanitizeFilenamePart(v: string): string {
const cleaned = v
.trim()
.toLowerCase()
.replace(/[^a-z0-9._-]+/g, '-')
.replace(/-+/g, '-')
.replace(/^[-_.]+|[-_.]+$/g, '');
return cleaned || 'module';
}
export function communityLabel(id: string | null, communities: BgpCommunity[]): string {
if (!id) return '—';
const c = communities.find((x) => x.id === id);
if (!c) return id.slice(0, 8) + '…';
const t = c.title?.trim();
return t || c.community;
}
export function communityOptionLabel(c: BgpCommunity): string {
const t = c.title?.trim();
return t || c.community;
}
export function nullableSelectValue(value: string | null | undefined): string {
if (value === null || value === undefined || value === '') return NONE_OPTION;
return value;
}
export function fromNullableSelect(value: string): string | null {
if (value === NONE_OPTION || value === '') return null;
return value;
}
export function moduleDohProfileIds(modRow: ModuleRow | null): string[] {
if (!modRow) return [];
if (modRow.doh_profile_ids?.length) return modRow.doh_profile_ids;
return modRow.doh_profile_id ? [modRow.doh_profile_id] : [];
}
export function dohProfileLabel(id: string, dohProfiles: DohProfile[]): string {
const p = dohProfiles.find((d) => d.id === id);
return p ? (p.name?.trim() ? `${p.name} (${p.url})` : p.url) : id.slice(0, 8) + '…';
}
export function normalizeCdnSourceKind(k: string): 'plaintext' | 'json' {
return k.trim().toLowerCase() === 'json' ? 'json' : 'plaintext';
}
export function syncSelection(selected: Set<string>, existingIds: string[]): Set<string> {
const validIds = new Set(existingIds);
return new Set([...selected].filter((id) => validIds.has(id)));
}
@@ -1,13 +1,15 @@
<script lang="ts">
import type { RevisionDiff, RevisionPrefix, RevisionRow } from '$lib/api/types.js';
import { Button } from '$lib/components/ui/button/index.js';
import { Button } from '$lib/ui/core/button/index.js';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from '$lib/components/ui/card/index.js';
} from '$lib/ui/core/card/index.js';
import { Select, SelectContent, SelectItem, SelectTrigger } from '$lib/ui/core/select/index.js';
import { formatDateTime } from '$lib/modules/display.js';
import { sortRevisionDiffItems } from '$lib/sort-prefixes.js';
type Props = {
@@ -19,7 +21,6 @@
onDiffRevAChange: (value: string) => void;
onDiffRevBChange: (value: string) => void;
onLoadDiff: () => void;
formatDate: (d?: string | null) => string;
};
let {
@@ -30,11 +31,13 @@
diffLoading,
onDiffRevAChange,
onDiffRevBChange,
onLoadDiff,
formatDate
onLoadDiff
}: Props = $props();
/** Бэкенд отдаёт `prefixes.added` / `prefixes.removed`; верхний уровень added/removed — опционально. */
function revisionLabel(rev: RevisionRow): string {
return `${rev.id.slice(0, 8)}… (${formatDateTime(rev.created_at)})`;
}
function diffAddedRaw(d: RevisionDiff | null): (string | RevisionPrefix)[] {
if (!d) return [];
if (d.prefixes && Array.isArray(d.prefixes.added)) return d.prefixes.added;
@@ -82,26 +85,36 @@
</CardHeader>
<CardContent class="min-h-0 space-y-4">
<div class="flex flex-col gap-2 sm:flex-row">
<select
value={diffRevA}
onchange={(e) => onDiffRevAChange((e.currentTarget as HTMLSelectElement).value)}
class="h-8 min-w-0 flex-1 rounded-md border border-border bg-background px-2 text-sm"
>
<option value="">Ревизия A</option>
{#each revisions as rev (rev.id)}
<option value={rev.id}>{rev.id.slice(0, 8)} ({formatDate(rev.created_at)})</option>
{/each}
</select>
<select
value={diffRevB}
onchange={(e) => onDiffRevBChange((e.currentTarget as HTMLSelectElement).value)}
class="h-8 min-w-0 flex-1 rounded-md border border-border bg-background px-2 text-sm"
>
<option value="">Ревизия B</option>
{#each revisions as rev (rev.id)}
<option value={rev.id}>{rev.id.slice(0, 8)} ({formatDate(rev.created_at)})</option>
{/each}
</select>
<Select type="single" value={diffRevA} onValueChange={(v) => onDiffRevAChange(v ?? '')}>
<SelectTrigger class="min-w-0 flex-1">
{diffRevA
? revisions.find((r) => r.id === diffRevA)
? revisionLabel(revisions.find((r) => r.id === diffRevA)!)
: diffRevA
: 'Ревизия A'}
</SelectTrigger>
<SelectContent>
<SelectItem value="">Ревизия A</SelectItem>
{#each revisions as rev (rev.id)}
<SelectItem value={rev.id}>{revisionLabel(rev)}</SelectItem>
{/each}
</SelectContent>
</Select>
<Select type="single" value={diffRevB} onValueChange={(v) => onDiffRevBChange(v ?? '')}>
<SelectTrigger class="min-w-0 flex-1">
{diffRevB
? revisions.find((r) => r.id === diffRevB)
? revisionLabel(revisions.find((r) => r.id === diffRevB)!)
: diffRevB
: 'Ревизия B'}
</SelectTrigger>
<SelectContent>
<SelectItem value="">Ревизия B</SelectItem>
{#each revisions as rev (rev.id)}
<SelectItem value={rev.id}>{revisionLabel(rev)}</SelectItem>
{/each}
</SelectContent>
</Select>
<Button
size="sm"
class="shrink-0 self-start sm:self-auto"
@@ -117,10 +130,9 @@
class="grid min-h-0 grid-cols-1 overflow-hidden rounded-md border border-border sm:grid-cols-2"
style="scrollbar-gutter: stable;"
>
<!-- + Добавлено -->
<div class="flex min-h-0 min-w-0 flex-col border-b border-border sm:border-r sm:border-b-0">
<div
class="flex shrink-0 items-center border-b border-border bg-muted/60 px-3 py-2 font-mono text-xs font-semibold text-green-600 dark:text-green-400"
class="flex shrink-0 items-center border-b border-border bg-muted/60 px-3 py-2 font-mono text-xs font-semibold text-success"
>
<span class="mr-2 w-10 shrink-0 text-right text-muted-foreground select-none">+</span>
<span>Добавлено ({addedSorted.length})</span>
@@ -137,7 +149,7 @@
<tbody>
{#each addedSorted as line, i (`a-${i}-${line}`)}
<tr
class="border-b border-l-2 border-border/50 border-l-green-500/80 bg-green-500/[0.08] hover:bg-muted/30 dark:bg-green-500/15"
class="border-b border-l-2 border-success/30 bg-success/5 hover:bg-muted/30"
>
<td
class="w-10 shrink-0 border-r border-transparent py-0.5 pr-1 pl-2 text-right align-top text-[11px] text-muted-foreground tabular-nums select-none"
@@ -157,10 +169,9 @@
</div>
</div>
<!-- Удалено -->
<div class="flex min-h-0 min-w-0 flex-col">
<div
class="flex shrink-0 items-center border-b border-border bg-muted/60 px-3 py-2 font-mono text-xs font-semibold text-red-600 dark:text-red-400"
class="flex shrink-0 items-center border-b border-border bg-muted/60 px-3 py-2 font-mono text-xs font-semibold text-destructive"
>
<span class="mr-2 w-10 shrink-0 text-right text-muted-foreground select-none"></span>
<span>Удалено ({removedSorted.length})</span>
@@ -177,7 +188,7 @@
<tbody>
{#each removedSorted as line, i (`r-${i}-${line}`)}
<tr
class="border-b border-l-2 border-border/50 border-l-red-500/80 bg-red-500/[0.08] hover:bg-muted/30 dark:bg-red-500/15"
class="border-b border-l-2 border-destructive/30 bg-destructive/5 hover:bg-muted/30"
>
<td
class="w-10 shrink-0 border-r border-transparent py-0.5 pr-1 pl-2 text-right align-top text-[11px] text-muted-foreground tabular-nums select-none"
@@ -1,7 +1,7 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import { Label } from '$lib/components/ui/label/index.js';
import { Button } from '$lib/ui/core/button/index.js';
import { Input } from '$lib/ui/core/input/index.js';
import { Label } from '$lib/ui/core/label/index.js';
import Filter from '@lucide/svelte/icons/filter';
import Search from '@lucide/svelte/icons/search';
import X from '@lucide/svelte/icons/x';
@@ -2,15 +2,17 @@
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
import type { JobRow } from '$lib/api/types.js';
import type { JobDetailedReport, JobLogEntry } from './types.js';
import { Badge } from '$lib/components/ui/badge/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import { Badge } from '$lib/ui/core/badge/index.js';
import { Button } from '$lib/ui/core/button/index.js';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from '$lib/components/ui/card/index.js';
} from '$lib/ui/core/card/index.js';
import EmptyState from '$lib/ui/patterns/empty-state/empty-state.svelte';
import { formatDateTime } from '$lib/modules/display.js';
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
import Eye from '@lucide/svelte/icons/eye';
import X from '@lucide/svelte/icons/x';
@@ -49,7 +51,6 @@
getJobLogEntries: (job: JobRow) => JobLogEntry[];
getJobLogTotal: (job: JobRow, entries?: JobLogEntry[]) => number;
jobStatusVariant: (status: string) => 'default' | 'secondary' | 'outline' | 'destructive';
formatDate: (d?: string | null) => string;
};
let {
@@ -69,8 +70,7 @@
isJobExpanded,
getJobLogEntries,
getJobLogTotal,
jobStatusVariant,
formatDate
jobStatusVariant
}: Props = $props();
const reportCols = reportRowColumns as import('@tanstack/table-core').ColumnDef<
@@ -178,7 +178,7 @@
<CalendarClock class="size-3.5 shrink-0" aria-hidden="true" />
Создана
</p>
<p class="mt-1">{formatDate(job.created_at)}</p>
<p class="mt-1">{formatDateTime(job.created_at)}</p>
</div>
<div
class="rounded-md border border-chart-3/25 bg-chart-3/5 px-2.5 py-2 dark:bg-chart-3/10"
@@ -187,7 +187,7 @@
<PlayCircle class="size-3.5 shrink-0" aria-hidden="true" />
Запущена
</p>
<p class="mt-1">{formatDate(job.started_at)}</p>
<p class="mt-1">{formatDateTime(job.started_at)}</p>
</div>
<div
class="rounded-md border border-chart-4/25 bg-chart-4/5 px-2.5 py-2 dark:bg-chart-4/10"
@@ -196,7 +196,7 @@
<Flag class="size-3.5 shrink-0" aria-hidden="true" />
Завершена
</p>
<p class="mt-1">{formatDate(job.finished_at)}</p>
<p class="mt-1">{formatDateTime(job.finished_at)}</p>
</div>
</div>
</div>
@@ -450,9 +450,14 @@
{/if}
</div>
{:else}
<div class="text-muted-foreground py-10 text-center text-sm">
{jobsLoading ? 'Загрузка…' : 'Нет задач'}
</div>
{#if jobsLoading}
<div class="py-8 text-center text-sm text-muted-foreground">Загрузка…</div>
{:else}
<EmptyState
title="Нет задач"
description="Задачи появятся после refresh, apply или rollback."
/>
{/if}
{/each}
</CardContent>
</Card>
@@ -1,8 +1,8 @@
<script lang="ts">
import type { BirdStatus } from '$lib/api/types.js';
import { Badge } from '$lib/components/ui/badge/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import { Card } from '$lib/components/ui/card/index.js';
import { Badge } from '$lib/ui/core/badge/index.js';
import { Button } from '$lib/ui/core/button/index.js';
import { Card } from '$lib/ui/core/card/index.js';
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
import Play from '@lucide/svelte/icons/play';
import RotateCcw from '@lucide/svelte/icons/rotate-ccw';
@@ -114,9 +114,14 @@
</div>
<p class="font-semibold">Состояние BIRD</p>
{#if birdStatus}
<Badge variant={birdHealthyBadgeVariant(birdStatus.healthy)}
>{birdHealthyShortLabel(birdStatus.healthy)}</Badge
<Badge
variant={birdHealthyBadgeVariant(birdStatus.healthy)}
class={birdStatus.healthy === true
? 'border-success/30 bg-success/15 text-success'
: undefined}
>
{birdHealthyShortLabel(birdStatus.healthy)}
</Badge>
{/if}
</div>
<p class="max-w-[56ch] text-sm text-foreground/80">
@@ -1,21 +1,15 @@
<script lang="ts">
import type { RevisionRow } from '$lib/api/types.js';
import { Button } from '$lib/components/ui/button/index.js';
import { Button } from '$lib/ui/core/button/index.js';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from '$lib/components/ui/card/index.js';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow
} from '$lib/components/ui/table/index.js';
} from '$lib/ui/core/card/index.js';
import AppDataTable from '$lib/ui/patterns/data-table/app-data-table.svelte';
import { formatDateTime } from '$lib/modules/display.js';
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
import Undo from '@lucide/svelte/icons/undo';
import Eye from '@lucide/svelte/icons/eye';
@@ -28,7 +22,6 @@
onOpenPreview: (rev: RevisionRow) => void;
onRollbackRequest: (rev: RevisionRow) => void;
onDownloadDiagnosticLog: (rev: RevisionRow) => void;
formatDate: (d?: string | null) => string;
};
let {
@@ -37,9 +30,31 @@
onReload,
onOpenPreview,
onRollbackRequest,
onDownloadDiagnosticLog,
formatDate
onDownloadDiagnosticLog
}: Props = $props();
const columns = [
{
id: 'id',
label: 'ID',
sortable: true,
sortValue: (rev: RevisionRow) => rev.id
},
{
id: 'created',
label: 'Создана',
sortable: true,
sortValue: (rev: RevisionRow) => rev.created_at ?? ''
},
{
id: 'prefixes',
label: 'Префиксов',
sortable: true,
sortValue: (rev: RevisionRow) => rev.materialized_prefix_count ?? 0
},
{ id: 'hash', label: 'Хэш' },
{ id: 'actions', label: '', class: 'w-32' }
] as const;
</script>
<Card>
@@ -61,55 +76,45 @@
<RefreshCw class={revLoading ? 'animate-spin' : ''} />
</Button>
</CardHeader>
<CardContent class="min-w-0 p-0">
<div class="max-w-full overflow-x-auto overscroll-x-contain [scrollbar-gutter:stable]">
<Table class="min-w-[44rem]">
<TableHeader>
<TableRow>
<TableHead>ID</TableHead>
<TableHead>Создана</TableHead>
<TableHead>Префиксов</TableHead>
<TableHead>Хэш</TableHead>
<TableHead class="w-32"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{#each revisions as rev (rev.id)}
<TableRow>
<TableCell class="font-mono text-xs">{rev.id.slice(0, 8)}</TableCell>
<TableCell class="text-sm">{formatDate(rev.created_at)}</TableCell>
<TableCell>{rev.materialized_prefix_count}</TableCell>
<TableCell class="font-mono text-xs text-muted-foreground"
>{rev.content_hash.slice(0, 12)}</TableCell
>
<TableCell>
<div class="flex gap-1">
<Button variant="ghost" size="icon-sm" onclick={() => onOpenPreview(rev)}>
<Eye class="size-3.5" />
</Button>
<Button variant="ghost" size="icon-sm" onclick={() => onRollbackRequest(rev)}>
<Undo class="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon-sm"
title="Скачать диагностический лог"
onclick={() => onDownloadDiagnosticLog(rev)}
>
<Download class="size-3.5" />
</Button>
</div>
</TableCell>
</TableRow>
{:else}
<TableRow>
<TableCell colspan={5} class="text-muted-foreground py-8 text-center">
{revLoading ? 'Загрузка…' : 'Нет ревизий'}
</TableCell>
</TableRow>
{/each}
</TableBody>
</Table>
</div>
<CardContent class="min-w-0 p-4 pt-0">
<AppDataTable
columns={[...columns]}
rows={revisions}
rowKey={(rev) => rev.id}
loading={revLoading}
emptyTitle="Нет ревизий"
emptyDescription="Ревизии появятся после обновления модулей."
>
{#snippet cell({ row: rev, column })}
{#if column.id === 'id'}
<span class="font-mono text-xs">{rev.id.slice(0, 8)}</span>
{:else if column.id === 'created'}
<span class="text-sm">{formatDateTime(rev.created_at)}</span>
{:else if column.id === 'prefixes'}
{rev.materialized_prefix_count}
{:else if column.id === 'hash'}
<span class="font-mono text-xs text-muted-foreground"
>{rev.content_hash.slice(0, 12)}</span
>
{:else if column.id === 'actions'}
<div class="flex gap-1">
<Button variant="ghost" size="icon-sm" onclick={() => onOpenPreview(rev)}>
<Eye class="size-3.5" />
</Button>
<Button variant="ghost" size="icon-sm" onclick={() => onRollbackRequest(rev)}>
<Undo class="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon-sm"
title="Скачать диагностический лог"
onclick={() => onDownloadDiagnosticLog(rev)}
>
<Download class="size-3.5" />
</Button>
</div>
{/if}
{/snippet}
</AppDataTable>
</CardContent>
</Card>
@@ -6,9 +6,9 @@
getCoreRowModel,
getPaginationRowModel
} from '@tanstack/table-core';
import { createSvelteTable, FlexRender } from '$lib/components/ui/data-table/index.js';
import * as Table from '$lib/components/ui/table/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import { createSvelteTable, FlexRender } from '$lib/ui/core/data-table/index.js';
import * as Table from '$lib/ui/core/table/index.js';
import { Button } from '$lib/ui/core/button/index.js';
type Props = {
rows: RowData[];
+43
View File
@@ -0,0 +1,43 @@
import type { ModuleRow } from '$lib/api/types.js';
/** Форматирование ISO-даты для таблиц модулей и расписания. */
export function formatDateTime(value: string | null | undefined): string {
if (typeof value !== 'string' || value.trim().length === 0) return '—';
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) return '—';
return parsed.toLocaleString('ru-RU');
}
/** Подпись интервала refresh: секунды, cron или комбинация. */
export function moduleIntervalLabel(moduleRow: ModuleRow): string {
const cron = typeof moduleRow.cron_expr === 'string' ? moduleRow.cron_expr.trim() : '';
const raw = moduleRow.refresh_interval_sec as unknown;
const interval =
typeof raw === 'number'
? raw
: typeof raw === 'string' && raw.trim().length > 0
? Number(raw)
: null;
const intervalLabel = interval !== null && Number.isFinite(interval) ? `${interval}с` : '';
if (cron && intervalLabel) return `${intervalLabel} (${cron})`;
if (cron) return cron;
if (intervalLabel) return intervalLabel;
return '—';
}
export function moduleTypeBadgeVariant(
type: string
): 'default' | 'secondary' | 'outline' | 'destructive' {
switch (type) {
case 'AS_PREFIXES':
return 'default';
case 'CDN_CIDRS':
return 'secondary';
case 'DOMAINS':
return 'outline';
case 'IP_RANGES':
return 'outline';
default:
return 'outline';
}
}
+169
View File
@@ -0,0 +1,169 @@
/** Pure-helpers для страницы /monitoring. */
export type OverallStatus = 'ok' | 'warn' | 'error' | 'unknown';
export type JobsKpi = { running: number; failed: number; total: number };
export type ReadyStatus = { status: string; checks?: Record<string, unknown> };
export type HealthStatus = { ok: boolean; status?: string; error?: string };
export type CheckBadge = {
label: string;
variant: 'default' | 'secondary' | 'destructive' | 'outline';
class?: string;
hint?: string;
};
export function toErrorMessage(error: unknown): string {
if (error instanceof Error && error.message.trim() !== '') return error.message;
return 'Ошибка запроса';
}
export function summarizeJobsStatuses(items: Array<{ status?: string }>): JobsKpi {
let running = 0;
let failed = 0;
for (const row of items) {
const status = String(row.status ?? '').toLowerCase();
if (status === 'queued' || status === 'running' || status === 'cancel_requested') {
running += 1;
}
if (status === 'failed' || status === 'error' || status === 'canceled') {
failed += 1;
}
}
return { running, failed, total: items.length };
}
export function deriveOverallStatus(input: {
health: HealthStatus | null;
ready: ReadyStatus | null;
jobs: JobsKpi | null;
birdConfigured: boolean;
birdHealthy: boolean | null;
}): OverallStatus {
const { health, ready, jobs, birdConfigured, birdHealthy } = input;
if (health === null && ready === null && jobs === null) return 'unknown';
if (!health?.ok) return 'error';
if (ready !== null && ready.status !== 'ready') return 'error';
if (jobs !== null && jobs.failed > 0) return 'warn';
if (birdConfigured && birdHealthy === false) return 'warn';
return 'ok';
}
export function overallStatusLabel(status: OverallStatus): string {
switch (status) {
case 'ok':
return 'В норме';
case 'warn':
return 'Внимание';
case 'error':
return 'Ошибка';
default:
return 'Нет данных';
}
}
export function overallStatusHint(
status: OverallStatus,
input: {
healthOk: boolean | undefined;
jobsFailed: number;
}
): string {
if (status === 'unknown') return 'Нет данных. Запустите обновление.';
if (status === 'error') {
if (!input.healthOk) return 'Проверьте доступность API и логи сервиса.';
return 'Readiness не в норме: проверьте postgres/store/jobs.';
}
if (status === 'warn') {
if (input.jobsFailed > 0) return 'Есть ошибки в задачах: откройте операции и последние jobs.';
return 'Проверьте BGP-сессии и вывод birdc.';
}
return 'Критичных отклонений не обнаружено.';
}
export function overallBadgeVariant(status: OverallStatus): CheckBadge['variant'] {
if (status === 'ok') return 'default';
if (status === 'warn') return 'secondary';
if (status === 'error') return 'destructive';
return 'outline';
}
export function overallBadgeClass(status: OverallStatus): string | undefined {
if (status === 'ok') return 'border-success/30 bg-success/15 text-success';
if (status === 'warn') return 'border-warning/30 bg-warning/15 text-warning';
return undefined;
}
/** Нормализация значений ready.checks и health/readiness в badge. */
export function checkStatusBadge(value: unknown): CheckBadge {
const raw = String(value ?? '').trim();
const lower = raw.toLowerCase();
if (lower === 'ok' || lower === 'ready' || lower === 'true' || lower === 'up') {
return {
label: 'OK',
variant: 'default',
class: 'border-success/30 bg-success/15 text-success'
};
}
if (lower === 'memory') {
return {
label: 'In-memory',
variant: 'secondary',
hint: 'Очередь задач в памяти процесса, не shared между воркерами.'
};
}
if (
lower === 'failed' ||
lower === 'false' ||
lower === 'error' ||
lower === 'down' ||
lower === 'unavailable'
) {
return { label: 'Ошибка', variant: 'destructive' };
}
if (raw === '') {
return { label: '—', variant: 'outline' };
}
return { label: raw, variant: 'outline' };
}
/** Человекочитаемое имя проверки readiness. */
export function checkDisplayName(key: string): string {
switch (key) {
case 'postgres':
return 'PostgreSQL';
case 'store':
return 'Хранилище';
case 'jobs':
return 'Очередь задач';
default:
return key;
}
}
export function livenessBadge(health: HealthStatus | null): CheckBadge {
if (health === null) return { label: '—', variant: 'outline' };
if (health.ok) {
return {
label: 'В норме',
variant: 'default',
class: 'border-success/30 bg-success/15 text-success'
};
}
return { label: 'Недоступен', variant: 'destructive' };
}
export function readinessBadge(ready: ReadyStatus | null): CheckBadge {
if (ready === null) return { label: '—', variant: 'outline' };
if (ready.status === 'ready') {
return {
label: 'Готов',
variant: 'default',
class: 'border-success/30 bg-success/15 text-success'
};
}
return { label: ready.status || 'Не готов', variant: 'destructive' };
}
+38 -2
View File
@@ -1,5 +1,3 @@
/** Русские подписи для enum из API (задачи, модули, логи refresh). */
export function jobStatusRu(status: string): string {
switch (status) {
case 'queued':
@@ -17,6 +15,18 @@ export function jobStatusRu(status: string): string {
}
}
/** Variant Badge для статуса задачи (shadcn). */
export function jobStatusBadgeVariant(
status: string
): 'default' | 'secondary' | 'outline' | 'destructive' {
const lower = status.toLowerCase();
if (lower === 'succeeded') return 'default';
if (lower === 'running' || lower === 'queued') return 'secondary';
if (lower === 'failed' || lower === 'error' || lower === 'canceled' || lower === 'cancelled')
return 'destructive';
return 'outline';
}
/** Подпись типа задачи для фильтров (значения API те же). */
export function jobKindFilterRu(kind: string): string {
switch (kind) {
@@ -33,6 +43,32 @@ export function jobKindFilterRu(kind: string): string {
}
}
/** Включён / выключен модуль (поле enabled). */
export function moduleEnabledRu(enabled: boolean): string {
return enabled ? 'Вкл' : 'Выкл';
}
/** Variant Badge для enabled модуля. */
export function moduleEnabledBadgeVariant(
enabled: boolean
): 'default' | 'secondary' | 'outline' | 'destructive' {
return enabled ? 'default' : 'secondary';
}
/** Политика DoH-резолвера для модулей DOMAINS. */
export function dohPolicyRu(policy: string | null | undefined): string {
switch (policy) {
case 'primary_only':
return 'Только первый';
case 'failover':
return 'Резервирование';
case 'union':
return 'Объединение';
default:
return 'Только первый';
}
}
export function moduleTypeRu(type: string): string {
switch (type) {
case 'AS_PREFIXES':
+2 -10
View File
@@ -1,11 +1,7 @@
<script lang="ts">
import { onMount } from 'svelte';
import { apiJSON } from '$lib/api/client.js';
type VersionInfo = {
version?: string;
api_version?: string;
};
import { formatVersionSidebarLabel, type VersionInfo } from '$lib/api/version-info.js';
let label = $state<string | null>(null);
@@ -13,11 +9,7 @@
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;
label = formatVersionSidebarLabel(info);
} catch {
label = null;
}
+1 -4
View File
@@ -7,8 +7,6 @@ import Gauge from '@lucide/svelte/icons/gauge';
import LayoutDashboard from '@lucide/svelte/icons/layout-dashboard';
import Network from '@lucide/svelte/icons/network';
import Settings from '@lucide/svelte/icons/settings';
import Zap from '@lucide/svelte/icons/zap';
export type NavItem = {
href: string;
label: string;
@@ -20,8 +18,7 @@ export const mainNav: NavItem[] = [
{ href: '/modules', label: 'Модули', icon: Boxes },
{ href: '/directories', label: 'Справочники', icon: BookOpen },
{ href: '/network', label: 'Сеть', icon: Network },
{ href: '/revisions', label: 'Ревизии', icon: Activity },
{ href: '/operations', label: 'Операции', icon: Zap },
{ href: '/operations', label: 'Ревизии', icon: Activity },
{ href: '/schedule', label: 'Расписание', icon: CalendarClock },
{ href: '/monitoring', label: 'Мониторинг', icon: Gauge }
];
+153 -175
View File
@@ -1,65 +1,105 @@
<script lang="ts">
import { onMount } from 'svelte';
import { resolve } from '$app/paths';
import { apiJSON, apiMutate } from '$lib/api/client.js';
import type { ModuleRow, ModulesResponse, ModuleCreate } from '$lib/api/types.js';
import { Badge } from '$lib/components/ui/badge/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import { Label } from '$lib/components/ui/label/index.js';
import type { ModuleRow, ModulesResponse } from '$lib/api/types.js';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogDescription
} from '$lib/components/ui/dialog/index.js';
formatDateTime,
moduleIntervalLabel,
moduleTypeBadgeVariant
} from '$lib/modules/display.js';
import { moduleEnabledRu, moduleEnabledBadgeVariant, moduleTypeRu } from '$lib/ui-labels.js';
import { Badge } from '$lib/ui/core/badge/index.js';
import { Button } from '$lib/ui/core/button/index.js';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger
} from '$lib/components/ui/select/index.js';
import { Switch } from '$lib/components/ui/switch/index.js';
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription
} from '$lib/ui/core/card/index.js';
import { Alert, AlertDescription, AlertTitle } from '$lib/ui/core/alert/index.js';
import { Checkbox } from '$lib/ui/core/checkbox/index.js';
import AppDataTable from '$lib/ui/patterns/data-table/app-data-table.svelte';
import CardSkeleton from '$lib/ui/patterns/feedback/card-skeleton.svelte';
import PageHeader from '$lib/ui/app/page-header/page-header.svelte';
import { confirm } from '$lib/ui/patterns/confirm/confirm-state.svelte.js';
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
import { resolve } from '$app/paths';
import ModuleCreateDialog from '$lib/components/modules/ModuleCreateDialog.svelte';
import { cn } from '$lib/utils.js';
import Plus from '@lucide/svelte/icons/plus';
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
import ExternalLink from '@lucide/svelte/icons/external-link';
import Trash2 from '@lucide/svelte/icons/trash-2';
import { moduleTypeRu } from '$lib/ui-labels.js';
import Boxes from '@lucide/svelte/icons/boxes';
import Info from '@lucide/svelte/icons/info';
import CheckCircle2 from '@lucide/svelte/icons/check-circle-2';
import CircleOff from '@lucide/svelte/icons/circle-off';
let rows = $state<ModuleRow[]>([]);
let loading = $state(false);
let initialLoading = $state(true);
let dialogOpen = $state(false);
let saving = $state(false);
let selectedModuleIds = $state(new Set<string>());
let deletingBulkModules = $state(false);
let selectedModuleIds = $state(new Set<string>());
let lastUpdated = $state<Date | null>(null);
const selectedModulesCount = $derived(selectedModuleIds.size);
const allModulesSelected = $derived(rows.length > 0 && selectedModuleIds.size === rows.length);
const someModulesSelected = $derived(selectedModuleIds.size > 0 && !allModulesSelected);
const enabledCount = $derived(rows.filter((m) => m.enabled).length);
const disabledCount = $derived(rows.filter((m) => !m.enabled).length);
let form = $state<ModuleCreate>({
type: 'AS_PREFIXES',
name: '',
enabled: true,
priority: 0
});
const moduleTypes = [
{ value: 'AS_PREFIXES', label: 'AS (номера)' },
{ value: 'CDN_CIDRS', label: 'CDN CIDRs' },
{ value: 'DOMAINS', label: 'Домены' },
{ value: 'IP_RANGES', label: 'IP Ranges' }
const statAccents = [
{
border: 'border-l-chart-1',
bg: 'bg-chart-1/5',
iconBg: 'bg-chart-1/15',
iconText: 'text-chart-1'
},
{
border: 'border-l-chart-2',
bg: 'bg-chart-2/5',
iconBg: 'bg-chart-2/15',
iconText: 'text-chart-2'
},
{
border: 'border-l-chart-4',
bg: 'bg-chart-4/5',
iconBg: 'bg-chart-4/15',
iconText: 'text-chart-4'
}
] as const;
const kpiCards = $derived.by(() => [
{
id: 'total',
label: 'Всего модулей',
value: initialLoading ? '—' : String(rows.length),
description: 'AS, CDN, домены, IP',
icon: Boxes,
accent: statAccents[0],
badge: 'в системе'
},
{
id: 'enabled',
label: 'Включено',
value: initialLoading ? '—' : String(enabledCount),
description: 'участвуют в ревизиях',
icon: CheckCircle2,
accent: statAccents[1],
badge: 'активных'
},
{
id: 'disabled',
label: 'Выключено',
value: initialLoading ? '—' : String(disabledCount),
description: disabledCount > 0 ? 'не участвуют в сборке' : 'все модули включены',
icon: CircleOff,
accent: statAccents[2],
badge: disabledCount > 0 ? 'отключены' : 'нет отключённых'
}
]);
const moduleColumns = [
{ id: 'select', label: '', class: 'w-10' },
{ id: 'name', label: 'Название', sortable: true, sortValue: (m: ModuleRow) => m.name },
@@ -82,78 +122,23 @@
] as const;
async function load() {
loading = true;
if (!initialLoading) loading = true;
try {
const m = await apiJSON<ModulesResponse>('/v1/modules?limit=200');
rows = m.items ?? [];
const validIds = new Set(rows.map((item) => item.id));
selectedModuleIds = new Set([...selectedModuleIds].filter((id) => validIds.has(id)));
lastUpdated = new Date();
} catch (e) {
notifyApiError(e);
} finally {
loading = false;
initialLoading = false;
}
}
onMount(load);
async function create() {
if (!form.name.trim()) {
notify.error('Укажите название модуля');
return;
}
saving = true;
try {
await apiMutate('/v1/modules', 'POST', form);
notify.success('Модуль создан');
dialogOpen = false;
form = { type: 'AS_PREFIXES', name: '', enabled: true, priority: 0 };
await load();
} catch (e) {
notifyApiError(e);
} finally {
saving = false;
}
}
function typeBadgeVariant(type: string) {
switch (type) {
case 'AS_PREFIXES':
return 'default';
case 'CDN_CIDRS':
return 'secondary';
case 'DOMAINS':
return 'outline';
case 'IP_RANGES':
return 'outline';
default:
return 'outline';
}
}
function moduleIntervalLabel(moduleRow: ModuleRow): string {
const cron = typeof moduleRow.cron_expr === 'string' ? moduleRow.cron_expr.trim() : '';
const raw = moduleRow.refresh_interval_sec as unknown;
const interval =
typeof raw === 'number'
? raw
: typeof raw === 'string' && raw.trim().length > 0
? Number(raw)
: null;
const intervalLabel = interval !== null && Number.isFinite(interval) ? `${interval}с` : '';
if (cron && intervalLabel) return `${intervalLabel} (${cron})`;
if (cron) return cron;
if (intervalLabel) return intervalLabel;
return '—';
}
function formatDateTime(value: string | null | undefined): string {
if (typeof value !== 'string' || value.trim().length === 0) return '—';
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) return '—';
return parsed.toLocaleString('ru-RU');
}
function toggleModuleSelection(id: string) {
const next = new Set(selectedModuleIds);
if (next.has(id)) next.delete(id);
@@ -202,7 +187,9 @@
<div class="flex flex-col gap-6">
<PageHeader
title="Модули префиксов"
description="Управление модулями — AS, CDN, домены, IP-диапазоны."
description={lastUpdated
? `Управление модулями — AS, CDN, домены, IP-диапазоны. Обновлено: ${lastUpdated.toLocaleTimeString('ru-RU')}`
: 'Управление модулями — AS, CDN, домены, IP-диапазоны.'}
icon={Boxes}
iconClass="bg-chart-1/15 text-chart-1"
>
@@ -218,9 +205,55 @@
{/snippet}
</PageHeader>
<Alert class="border-info/30 bg-info/5">
<Info class="text-info" />
<AlertTitle>О модулях префиксов</AlertTitle>
<AlertDescription>
Модули собирают префиксы из AS (RIPEstat), CDN URL, доменов (DoH) и статических CIDR.
Расписание и ручной refresh — на странице
<Button variant="link" class="h-auto p-0" href={resolve('/schedule')}>Расписание</Button>.
Результаты обновления и ревизии — в
<Button variant="link" class="h-auto p-0" href={resolve('/operations')}>Операциях</Button>.
</AlertDescription>
</Alert>
<div class="grid gap-4 sm:grid-cols-3">
{#if initialLoading}
{#each Array(3) as _, i (i)}
<CardSkeleton />
{/each}
{:else}
{#each kpiCards as card (card.id)}
{@const Icon = card.icon}
{@const a = card.accent}
<Card class={cn('overflow-hidden border-l-4 shadow-sm', a.border, a.bg)}>
<CardHeader class="pb-2">
<CardDescription class="flex min-w-0 items-center gap-2">
<span
class={cn('flex size-9 shrink-0 items-center justify-center rounded-lg', a.iconBg)}
aria-hidden="true"
>
<Icon class={cn('size-4', a.iconText)} />
</span>
<span class="truncate">{card.label}</span>
</CardDescription>
<CardTitle class="text-3xl font-bold tabular-nums">{card.value}</CardTitle>
</CardHeader>
<CardContent class="space-y-2">
<Badge variant="outline">{card.badge}</Badge>
<p class="text-xs text-muted-foreground">{card.description}</p>
</CardContent>
</Card>
{/each}
{/if}
</div>
<Card>
<CardHeader class="flex flex-wrap items-center justify-between gap-2 border-b py-3">
<CardTitle class="text-base">Список модулей</CardTitle>
<div>
<CardTitle class="text-base">Список модулей</CardTitle>
<CardDescription>Клик по названию открывает карточку модуля и его записи.</CardDescription>
</div>
{#if selectedModulesCount > 0}
<div class="flex flex-wrap items-center gap-2">
<span class="text-sm text-muted-foreground">Выбрано: {selectedModulesCount}</span>
@@ -241,10 +274,22 @@
columns={[...moduleColumns]}
{rows}
rowKey={(m) => m.id}
{loading}
loading={initialLoading || loading}
emptyTitle="Нет модулей"
emptyDescription="Создайте первый модуль."
>
{#snippet toolbar()}
{#if rows.length > 0}
<div class="flex items-center gap-2">
<Checkbox
checked={allModulesSelected}
onCheckedChange={(v) => toggleAllModules(v === true)}
aria-label="Выбрать все модули"
/>
<span class="text-sm text-muted-foreground">Выбрать все</span>
</div>
{/if}
{/snippet}
{#snippet cell({ row: m, column })}
{#if column.id === 'select'}
<Checkbox
@@ -253,9 +298,14 @@
onCheckedChange={() => toggleModuleSelection(m.id)}
/>
{:else if column.id === 'name'}
<span class="font-medium">{m.name}</span>
<div class="flex items-center gap-2">
<span class="font-medium">{m.name}</span>
<Button variant="ghost" size="icon-sm" href={resolve(`/modules/${m.id}`)}>
<ExternalLink class="size-3.5" aria-hidden="true" />
</Button>
</div>
{:else if column.id === 'type'}
<Badge variant={typeBadgeVariant(m.type)}>{moduleTypeRu(m.type)}</Badge>
<Badge variant={moduleTypeBadgeVariant(m.type)}>{moduleTypeRu(m.type)}</Badge>
{:else if column.id === 'priority'}
<span class="text-muted-foreground">{m.priority}</span>
{:else if column.id === 'interval'}
@@ -265,11 +315,9 @@
>{formatDateTime(m.last_refreshed_at)}</span
>
{:else if column.id === 'status'}
{#if m.enabled}
<Badge variant="default" class="text-xs">вкл</Badge>
{:else}
<Badge variant="secondary" class="text-xs">выкл</Badge>
{/if}
<Badge variant={moduleEnabledBadgeVariant(!!m.enabled)} class="text-xs">
{moduleEnabledRu(!!m.enabled)}
</Badge>
{:else if column.id === 'actions'}
<Button variant="ghost" size="icon-sm" href={resolve(`/modules/${m.id}`)}>
<ExternalLink class="size-3.5" />
@@ -281,74 +329,4 @@
</Card>
</div>
<!-- Create Dialog -->
<Dialog bind:open={dialogOpen}>
<DialogContent class="sm:max-w-md">
<DialogHeader>
<DialogTitle>Новый модуль</DialogTitle>
<DialogDescription>Создание нового модуля префиксов.</DialogDescription>
</DialogHeader>
<div class="space-y-4 py-2">
<div class="space-y-1.5">
<Label for="m-name">Название</Label>
<Input id="m-name" bind:value={form.name} placeholder="my-asn-module" />
</div>
<div class="space-y-1.5">
<Label for="m-type">Тип</Label>
<Select
type="single"
value={form.type}
onValueChange={(v) => {
if (v) form.type = v as typeof form.type;
}}
>
<SelectTrigger id="m-type" class="w-full">
{moduleTypes.find((t) => t.value === form.type)?.label ?? 'Выберите тип'}
</SelectTrigger>
<SelectContent>
{#each moduleTypes as t (t.value)}
<SelectItem value={t.value}>{t.label}</SelectItem>
{/each}
</SelectContent>
</Select>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="space-y-1.5">
<Label for="m-priority">Приоритет</Label>
<Input id="m-priority" type="number" bind:value={form.priority} />
</div>
<div class="space-y-1.5">
<Label for="m-interval">Интервал (сек)</Label>
<Input
id="m-interval"
type="number"
placeholder="3600"
bind:value={form.refresh_interval_sec}
/>
</div>
</div>
<div
class="flex flex-row items-center justify-between gap-4 rounded-lg border border-border bg-muted/30 p-3"
>
<div class="grid min-w-0 flex-1 gap-1 pr-2">
<Label for="m-enabled" class="leading-snug text-foreground">Включён</Label>
<p class="text-xs leading-snug text-muted-foreground">
Модуль участвует в сборке ревизий, если включён.
</p>
</div>
<Switch
id="m-enabled"
class="shrink-0"
checked={form.enabled !== false}
onCheckedChange={(v) => {
form = { ...form, enabled: v };
}}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onclick={() => (dialogOpen = false)}>Отмена</Button>
<Button onclick={create} disabled={saving}>{saving ? 'Создание…' : 'Создать'}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<ModuleCreateDialog bind:open={dialogOpen} onClose={() => {}} onCreated={load} />
File diff suppressed because it is too large Load Diff
+620 -269
View File
@@ -1,16 +1,54 @@
<script lang="ts">
import { onMount } from 'svelte';
import { resolve } from '$app/paths';
import { apiJSON, apiFetch } from '$lib/api/client.js';
import type { BirdStatus, JobsResponse } from '$lib/api/types.js';
import { Badge } from '$lib/components/ui/badge/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import type { BirdStatus, JobRow, JobsResponse } from '$lib/api/types.js';
import {
formatVersionHeadline,
resolveVersionString,
type VersionInfo
} from '$lib/api/version-info.js';
import { jobKindTitle } from '$lib/operations/job-kind-label.js';
import { jobStatusRu } from '$lib/ui-labels.js';
import {
checkDisplayName,
checkStatusBadge,
deriveOverallStatus,
livenessBadge,
overallBadgeClass,
overallBadgeVariant,
overallStatusHint,
overallStatusLabel,
readinessBadge,
summarizeJobsStatuses,
toErrorMessage,
type HealthStatus,
type JobsKpi,
type ReadyStatus
} from '$lib/monitoring/status.js';
import { Badge } from '$lib/ui/core/badge/index.js';
import { Button } from '$lib/ui/core/button/index.js';
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription
} from '$lib/components/ui/card/index.js';
} from '$lib/ui/core/card/index.js';
import { Alert, AlertDescription, AlertTitle } from '$lib/ui/core/alert/index.js';
import { Separator } from '$lib/ui/core/separator/index.js';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow
} from '$lib/ui/core/table/index.js';
import CardSkeleton from '$lib/ui/patterns/feedback/card-skeleton.svelte';
import ScrollPreBlock from '$lib/components/app/scroll-pre-block.svelte';
import PageHeader from '$lib/ui/app/page-header/page-header.svelte';
import { cn } from '$lib/utils.js';
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
import Gauge from '@lucide/svelte/icons/gauge';
import Activity from '@lucide/svelte/icons/activity';
@@ -18,138 +56,248 @@
import Server from '@lucide/svelte/icons/server';
import Hash from '@lucide/svelte/icons/hash';
import AlertTriangle from '@lucide/svelte/icons/alert-triangle';
import PageHeader from '$lib/ui/app/page-header/page-header.svelte';
import CheckCircle from '@lucide/svelte/icons/check-circle';
import XCircle from '@lucide/svelte/icons/x-circle';
import ArrowRight from '@lucide/svelte/icons/arrow-right';
import HeartPulse from '@lucide/svelte/icons/heart-pulse';
import ShieldCheck from '@lucide/svelte/icons/shield-check';
import Database from '@lucide/svelte/icons/database';
import HardDrive from '@lucide/svelte/icons/hard-drive';
import ListTodo from '@lucide/svelte/icons/list-todo';
import type { Component } from 'svelte';
type ReadyStatus = { status: string; checks?: Record<string, unknown> };
type VersionInfo = {
version?: string;
git_sha?: string;
build_time?: string;
[k: string]: unknown;
};
type JobsKpi = { running: number; failed: number; total: number };
let health = $state<{ ok: boolean; status?: string; error?: string } | null>(null);
let health = $state<HealthStatus | null>(null);
let ready = $state<ReadyStatus | null>(null);
let version = $state<VersionInfo | null>(null);
let bird = $state<BirdStatus | null>(null);
let jobs = $state<JobsKpi | null>(null);
let jobItems = $state<JobRow[]>([]);
let readyError = $state<string | null>(null);
let versionError = $state<string | null>(null);
let birdError = $state<string | null>(null);
let jobsError = $state<string | null>(null);
let lastUpdated = $state<Date | null>(null);
let loading = $state(false);
let initialLoading = $state(true);
let refreshing = $state(false);
function toErrorMessage(error: unknown): string {
if (error instanceof Error && error.message.trim() !== '') return error.message;
return 'Ошибка запроса';
}
function summarizeJobsStatuses(items: Array<{ status?: string }>): JobsKpi {
let running = 0;
let failed = 0;
for (const row of items) {
const status = String(row.status ?? '').toLowerCase();
if (status === 'queued' || status === 'running' || status === 'cancel_requested') {
running += 1;
}
if (status === 'failed' || status === 'error' || status === 'canceled') {
failed += 1;
}
const statAccents = [
{
border: 'border-l-chart-1',
bg: 'bg-chart-1/5',
iconBg: 'bg-chart-1/15',
iconText: 'text-chart-1'
},
{
border: 'border-l-chart-2',
bg: 'bg-chart-2/5',
iconBg: 'bg-chart-2/15',
iconText: 'text-chart-2'
},
{
border: 'border-l-chart-4',
bg: 'bg-chart-4/5',
iconBg: 'bg-chart-4/15',
iconText: 'text-chart-4'
},
{
border: 'border-l-info',
bg: 'bg-info/10',
iconBg: 'bg-info/15',
iconText: 'text-info'
}
return { running, failed, total: items.length };
}
] as const;
function overallBadgeVariant(status: 'ok' | 'warn' | 'error' | 'unknown') {
if (status === 'ok') return 'default';
if (status === 'warn') return 'secondary';
if (status === 'error') return 'destructive';
return 'outline';
}
const overallStatus = $derived.by(() =>
deriveOverallStatus({
health,
ready,
jobs,
birdConfigured: bird?.birdc_configured ?? false,
birdHealthy: bird?.healthy ?? null
})
);
const overallStatus = $derived.by(() => {
if (health === null && ready === null && bird === null && jobs === null) return 'unknown';
if (!health?.ok) return 'error';
if (ready !== null && ready.status !== 'ready') return 'error';
if (jobs !== null && jobs.failed > 0) return 'warn';
if (bird !== null && bird.birdc_configured && bird.healthy === false) return 'warn';
return 'ok';
});
const overallHint = $derived.by(() => {
if (overallStatus === 'unknown') return 'Нет данных. Запустите обновление.';
if (overallStatus === 'error') {
if (!health?.ok) return 'Проверьте доступность API и логи сервиса.';
return 'Readiness не в норме: проверьте postgres/store/jobs.';
}
if (overallStatus === 'warn') {
if (jobs && jobs.failed > 0)
return 'Есть ошибки в задачах: откройте операции и последние jobs.';
return 'Проверьте BGP-сессии и вывод birdc.';
}
return 'Критичных отклонений не обнаружено.';
});
const overallHint = $derived.by(() =>
overallStatusHint(overallStatus, {
healthOk: health?.ok,
jobsFailed: jobs?.failed ?? 0
})
);
const bgpText = $derived.by(() => {
if (!bird) return '—';
if (!bird.birdc_configured) return 'birdc не настроен';
if (bird.error) return 'ошибка birdc';
if (!bird.birdc_configured) return '';
if (bird.error) return '';
return `${bird.bgp_established}/${bird.bgp_sessions_total}`;
});
const versionText = $derived.by(() => {
if (!version) return '—';
const ver =
(typeof version.version === 'string' && version.version) ||
(typeof version.api_version === 'string' && version.api_version) ||
'';
const sha =
typeof version.git_sha === 'string' && version.git_sha ? version.git_sha : 'unknown';
if (ver) return `${ver} (${sha.slice(0, 12)})`;
return sha.slice(0, 12);
const bgpRatio = $derived.by(() => {
if (!bird?.birdc_configured || bird.bgp_sessions_total <= 0) return null;
return Math.round((bird.bgp_established / bird.bgp_sessions_total) * 100);
});
const versionText = $derived(formatVersionHeadline(version));
const versionFooter = $derived.by(() => {
if (!version) return '';
const parts: string[] = [];
const ver = resolveVersionString(version);
if (ver) parts.push(`version: ${ver}`);
if (version.git_sha) parts.push(`git_sha: ${version.git_sha}`);
if (version.build_time) parts.push(`build: ${version.build_time}`);
return parts.join(' · ');
});
const failedJobs = $derived.by(() =>
jobItems
.filter((j) => {
const s = String(j.status ?? '').toLowerCase();
return s === 'failed' || s === 'error' || s === 'canceled';
})
.slice(0, 5)
);
const checkIconByKey: Record<string, Component> = {
postgres: Database,
store: HardDrive,
jobs: ListTodo
};
const kpiCards = $derived.by(() => [
{
id: 'overall',
label: 'Общий статус',
value: initialLoading ? '—' : overallStatusLabel(overallStatus),
description: overallHint,
icon: Server,
accent: statAccents[0],
badge: overallStatusLabel(overallStatus),
badgeVariant: overallBadgeVariant(overallStatus),
badgeClass: overallBadgeClass(overallStatus),
error: null as string | null,
href: undefined
},
{
id: 'bgp',
label: 'BGP сессии',
value: initialLoading ? '—' : bgpText,
description: bird?.birdc_configured
? 'Established / total на API-хосте'
: (bird?.message ?? 'birdc не настроен на API-хосте'),
icon: Bird,
accent: statAccents[1],
badge: !bird ? '—' : !bird.birdc_configured ? 'N/A' : bird.healthy ? 'В норме' : 'Деградация',
badgeVariant: !bird?.birdc_configured
? ('outline' as const)
: bird?.healthy
? ('default' as const)
: ('secondary' as const),
badgeClass:
bird?.birdc_configured && bird?.healthy
? 'border-success/30 bg-success/15 text-success'
: undefined,
error: birdError ?? bird?.error ?? null,
href: '/network' as const
},
{
id: 'jobs',
label: 'Задачи',
value: initialLoading ? '—' : String(jobs?.running ?? '—'),
description: `Активных из ${jobs?.total ?? '—'} последних`,
icon: Activity,
accent: statAccents[2],
badge: jobs && jobs.failed > 0 ? `ошибок ${jobs.failed}` : 'без ошибок',
badgeVariant: jobs && jobs.failed > 0 ? ('secondary' as const) : ('default' as const),
badgeClass:
jobs && jobs.failed === 0 ? 'border-success/30 bg-success/15 text-success' : undefined,
error: jobsError,
href: jobs && jobs.failed > 0 ? ('/operations' as const) : undefined
},
{
id: 'version',
label: 'Версия',
value: initialLoading ? '—' : versionText,
description: versionFooter || 'GET /v1/version',
icon: Hash,
accent: statAccents[3],
badge: version ? 'Загружена' : '—',
badgeVariant: version ? ('outline' as const) : ('secondary' as const),
badgeClass: undefined,
error: versionError,
href: undefined
}
]);
async function fetchHealth(): Promise<HealthStatus> {
const h = await apiFetch('/v1/health');
const hj = (await h.json().catch(() => ({}))) as { status?: string };
return { ok: h.ok, status: hj?.status, error: h.ok ? undefined : `HTTP ${h.status}` };
}
async function load() {
loading = true;
if (initialLoading) {
/* keep skeleton */
} else {
refreshing = true;
}
readyError = null;
versionError = null;
birdError = null;
jobsError = null;
try {
const h = await apiFetch('/v1/health');
const hj = await h.json().catch(() => ({}));
health = { ok: h.ok, status: hj?.status, error: h.ok ? undefined : `HTTP ${h.status}` };
} catch (error) {
health = { ok: false, error: toErrorMessage(error) };
const [healthRes, readyRes, versionRes, birdRes, jobsRes] = await Promise.allSettled([
fetchHealth(),
apiJSON<ReadyStatus>('/v1/ready'),
apiJSON<VersionInfo>('/v1/version'),
apiJSON<BirdStatus>('/v1/bird/status'),
apiJSON<JobsResponse>('/v1/jobs?limit=100')
]);
if (healthRes.status === 'fulfilled') {
health = healthRes.value;
} else {
health = { ok: false, error: toErrorMessage(healthRes.reason) };
}
try {
ready = await apiJSON<ReadyStatus>('/v1/ready');
} catch (error) {
if (readyRes.status === 'fulfilled') {
ready = readyRes.value;
} else {
ready = null;
readyError = toErrorMessage(error);
readyError = toErrorMessage(readyRes.reason);
}
try {
version = await apiJSON<VersionInfo>('/v1/version');
} catch (error) {
if (versionRes.status === 'fulfilled') {
version = versionRes.value;
} else {
version = null;
versionError = toErrorMessage(error);
versionError = toErrorMessage(versionRes.reason);
}
try {
bird = await apiJSON<BirdStatus>('/v1/bird/status');
} catch (error) {
if (birdRes.status === 'fulfilled') {
bird = birdRes.value;
} else {
bird = null;
birdError = toErrorMessage(error);
birdError = toErrorMessage(birdRes.reason);
}
try {
const jobsPage = await apiJSON<JobsResponse>('/v1/jobs?limit=100');
jobs = summarizeJobsStatuses(jobsPage.items ?? []);
} catch (error) {
if (jobsRes.status === 'fulfilled') {
const items = jobsRes.value.items ?? [];
jobItems = items;
jobs = summarizeJobsStatuses(items);
} else {
jobItems = [];
jobs = null;
jobsError = toErrorMessage(error);
jobsError = toErrorMessage(jobsRes.reason);
}
lastUpdated = new Date();
loading = false;
initialLoading = false;
refreshing = false;
}
function truncateError(error: string | null | undefined, max = 120): string {
if (!error) return '';
return error.length > max ? `${error.slice(0, max)}…` : error;
}
onMount(load);
@@ -165,191 +313,394 @@
iconClass="bg-info/15 text-info"
>
{#snippet actions()}
<Button variant="outline" size="sm" onclick={load} disabled={loading}>
<RefreshCw class={loading ? 'animate-spin' : ''} />
<Button variant="outline" size="sm" onclick={load} disabled={refreshing}>
<RefreshCw class={refreshing ? 'animate-spin' : ''} />
Обновить
</Button>
{/snippet}
</PageHeader>
<div class="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
<Card class="border-l-4 border-l-chart-1 bg-chart-1/5 shadow-sm">
<CardHeader>
<CardTitle class="flex items-center gap-2 text-base">
<Server class="size-4" />
Общий статус
</CardTitle>
<CardDescription>Liveness + readiness + jobs/BGP</CardDescription>
</CardHeader>
<CardContent>
<div class="flex items-center justify-between gap-2">
<p class="text-2xl font-semibold uppercase">{overallStatus}</p>
<Badge variant={overallBadgeVariant(overallStatus)}>{overallStatus}</Badge>
</div>
<p class="mt-2 text-xs text-muted-foreground">{overallHint}</p>
</CardContent>
</Card>
{#if !initialLoading}
{#if overallStatus === 'ok'}
<Alert class="border-success/30 bg-success/5">
<CheckCircle class="text-success" />
<AlertTitle>Система в норме</AlertTitle>
<AlertDescription>{overallHint}</AlertDescription>
</Alert>
{:else if overallStatus === 'warn'}
<Alert class="border-warning/30 bg-warning/5">
<AlertTriangle class="text-warning" />
<AlertTitle>Требуется внимание</AlertTitle>
<AlertDescription>{overallHint}</AlertDescription>
</Alert>
{:else if overallStatus === 'error'}
<Alert variant="destructive">
<XCircle />
<AlertTitle>Обнаружена проблема</AlertTitle>
<AlertDescription>{overallHint}</AlertDescription>
</Alert>
{/if}
{/if}
<Card class="border-l-4 border-l-chart-2 bg-chart-2/5 shadow-sm">
<CardHeader>
<CardTitle class="flex items-center gap-2 text-base">
<Bird class="size-4" />
BGP сессии
</CardTitle>
<CardDescription>GET /v1/bird/status</CardDescription>
</CardHeader>
<CardContent>
<div class="flex items-center justify-between gap-2">
<p class="text-2xl font-semibold">{bgpText}</p>
{#if bird?.birdc_configured}
<Badge variant={bird.healthy ? 'default' : 'secondary'}
>{bird.healthy ? 'healthy' : 'degraded'}</Badge
<div class="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
{#if initialLoading}
{#each Array(4) as _, i (i)}
<CardSkeleton />
{/each}
{:else}
{#each kpiCards as card (card.id)}
{@const Icon = card.icon}
{@const a = card.accent}
<Card
class={cn(
'overflow-hidden border-l-4 shadow-sm transition-colors',
a.border,
a.bg,
card.href ? 'hover:border-primary/35' : ''
)}
>
<CardHeader class="pb-2">
<div class="flex items-center justify-between gap-2">
<CardDescription class="flex min-w-0 items-center gap-2">
<span
class={cn(
'flex size-9 shrink-0 items-center justify-center rounded-lg',
a.iconBg
)}
aria-hidden="true"
>
<Icon class={cn('size-4', a.iconText)} />
</span>
<span class="truncate">{card.label}</span>
</CardDescription>
{#if card.href}
<Button variant="ghost" size="icon-sm" href={resolve(card.href)}>
<ArrowRight class="size-3.5" aria-hidden="true" />
</Button>
{/if}
</div>
<CardTitle
class={cn(
'font-bold tabular-nums',
card.id === 'version' ? 'font-mono text-xl' : 'text-3xl'
)}
>
{:else}
<Badge variant="outline">n/a</Badge>
{/if}
</div>
{#if birdError}
<p class="mt-2 text-xs text-destructive">{birdError}</p>
{:else if bird && !bird.birdc_configured}
<p class="mt-2 text-xs text-muted-foreground">
{bird.message ?? 'birdc не настроен на API-хосте.'}
</p>
{:else if bird?.error}
<p class="mt-2 text-xs text-destructive">{bird.error}</p>
{:else}
<p class="mt-2 text-xs text-muted-foreground">Established / total на API-хосте.</p>
{/if}
</CardContent>
</Card>
<Card class="border-l-4 border-l-chart-4 bg-chart-4/5 shadow-sm">
<CardHeader>
<CardTitle class="flex items-center gap-2 text-base">
<Activity class="size-4" />
Задачи
</CardTitle>
<CardDescription>GET /v1/jobs?limit=100</CardDescription>
</CardHeader>
<CardContent>
<div class="flex items-center justify-between gap-2">
<p class="text-2xl font-semibold">{jobs ? jobs.running : '—'}</p>
<Badge variant={jobs && jobs.failed > 0 ? 'secondary' : 'default'}>
{jobs && jobs.failed > 0 ? `ошибок ${jobs.failed}` : 'без ошибок'}
</Badge>
</div>
{#if jobsError}
<p class="mt-2 text-xs text-destructive">{jobsError}</p>
{:else}
<p class="mt-2 text-xs text-muted-foreground">
Активных: {jobs?.running ?? '—'} из {jobs?.total ?? '—'} последних.
</p>
{/if}
</CardContent>
</Card>
<Card class="border-l-4 border-l-info bg-info/10 shadow-sm">
<CardHeader>
<CardTitle class="flex items-center gap-2 text-base">
<Hash class="size-4" />
Версия
</CardTitle>
<CardDescription>GET /v1/version</CardDescription>
</CardHeader>
<CardContent>
<div class="flex items-center justify-between gap-2">
<p class="font-mono text-2xl font-semibold">{versionText}</p>
<Badge variant={version ? 'outline' : 'secondary'}>{version ? 'loaded' : '—'}</Badge>
</div>
{#if versionError}
<p class="mt-2 text-xs text-destructive">{versionError}</p>
{:else if version}
<p class="mt-2 truncate text-xs text-muted-foreground">
api_version: {String(version.api_version ?? 'unknown')}
</p>
{/if}
</CardContent>
</Card>
{card.value}
</CardTitle>
</CardHeader>
<CardContent class="space-y-2">
<div class="flex items-center justify-between gap-2">
<Badge variant={card.badgeVariant} class={card.badgeClass}>{card.badge}</Badge>
</div>
{#if card.error}
<p class="text-xs text-destructive">{card.error}</p>
{:else}
<p class="text-xs text-muted-foreground">{card.description}</p>
{/if}
</CardContent>
</Card>
{/each}
{/if}
</div>
<div class="grid gap-4 lg:grid-cols-2">
<Card>
<CardHeader>
<CardTitle class="text-base">Доступность и готовность</CardTitle>
<CardDescription>GET /v1/health и GET /v1/ready</CardDescription>
</CardHeader>
<CardContent class="space-y-3">
<div class="flex items-center gap-2 text-sm">
<span class="text-muted-foreground">Liveness:</span>
{#if health === null}
<Badge variant="outline"></Badge>
{:else if health.ok}
<Badge variant="default">ok</Badge>
{:else}
<Badge variant="destructive">failed</Badge>
{#if initialLoading}
<CardSkeleton />
<CardSkeleton />
{:else}
<Card>
<CardHeader>
<CardTitle class="text-base">Доступность и готовность</CardTitle>
<CardDescription>GET /v1/health · GET /v1/ready</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
{#if health?.error || readyError}
<Alert variant="destructive">
<XCircle />
<AlertTitle>Ошибка проверки</AlertTitle>
<AlertDescription>
{#if health?.error}{health.error}{/if}
{#if health?.error && readyError}<br />{/if}
{#if readyError}{readyError}{/if}
</AlertDescription>
</Alert>
{/if}
{#if health?.status}
<span class="text-muted-foreground">{health.status}</span>
{/if}
</div>
<div class="flex items-center gap-2 text-sm">
<span class="text-muted-foreground">Readiness:</span>
{#if ready === null}
<Badge variant="outline"></Badge>
{:else}
<Badge variant={ready.status === 'ready' ? 'default' : 'destructive'}
>{ready.status}</Badge
>
{/if}
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead class="w-[55%]">Проверка</TableHead>
<TableHead>Статус</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{@const liveBadge = livenessBadge(health)}
<TableRow>
<TableCell>
<div class="flex items-center gap-2">
<HeartPulse class="size-4 shrink-0 text-muted-foreground" aria-hidden="true" />
<div>
<p class="text-sm font-medium">Liveness</p>
<p class="text-xs text-muted-foreground">/v1/health</p>
</div>
</div>
</TableCell>
<TableCell>
<Badge variant={liveBadge.variant} class={liveBadge.class}
>{liveBadge.label}</Badge
>
</TableCell>
</TableRow>
{@const readyBadge = readinessBadge(ready)}
<TableRow>
<TableCell>
<div class="flex items-center gap-2">
<ShieldCheck class="size-4 shrink-0 text-muted-foreground" aria-hidden="true" />
<div>
<p class="text-sm font-medium">Readiness</p>
<p class="text-xs text-muted-foreground">/v1/ready</p>
</div>
</div>
</TableCell>
<TableCell>
<Badge variant={readyBadge.variant} class={readyBadge.class}
>{readyBadge.label}</Badge
>
</TableCell>
</TableRow>
{#if ready?.checks && Object.keys(ready.checks).length > 0}
<TableRow>
<TableCell colspan={2} class="bg-muted/30 py-2">
<p class="text-xs font-medium text-muted-foreground">Зависимости</p>
</TableCell>
</TableRow>
{#each Object.entries(ready.checks) as [key, value] (key)}
{@const badge = checkStatusBadge(value)}
{@const CheckIcon = checkIconByKey[key] ?? ListTodo}
<TableRow>
<TableCell>
<div class="flex items-center gap-2">
<CheckIcon
class="size-4 shrink-0 text-muted-foreground"
aria-hidden="true"
/>
<div>
<p class="text-sm font-medium">{checkDisplayName(key)}</p>
<p class="text-xs text-muted-foreground">{key}</p>
</div>
</div>
</TableCell>
<TableCell>
<div class="space-y-1">
<Badge variant={badge.variant} class={badge.class}>{badge.label}</Badge>
{#if badge.hint}
<p class="text-xs text-muted-foreground">{badge.hint}</p>
{/if}
</div>
</TableCell>
</TableRow>
{/each}
{/if}
</TableBody>
</Table>
{#if health?.error}
<p class="text-xs text-destructive">{health.error}</p>
{/if}
{#if readyError}
<p class="text-xs text-destructive">{readyError}</p>
{/if}
<p class="text-xs text-muted-foreground">
HTTP 503 на readiness означает недоступность одной из зависимостей в checks.
</p>
</CardContent>
</Card>
{#if ready?.checks}
<div class="space-y-1">
{#each Object.entries(ready.checks) as [k, v] (k)}
<div class="grid grid-cols-[auto_1fr] items-center gap-2 text-xs">
<span class="text-muted-foreground">{k}</span>
<Badge variant="outline" class="justify-start font-normal">{String(v)}</Badge>
</div>
{/each}
<Card>
<CardHeader>
<div class="flex items-start justify-between gap-2">
<div>
<CardTitle class="flex items-center gap-2 text-base">
<Bird class="size-4" />
BGP на API-хосте
</CardTitle>
<CardDescription>GET /v1/bird/status</CardDescription>
</div>
<Button variant="outline" size="sm" href={resolve('/network')}>Пиры и спикеры</Button>
</div>
{/if}
</CardContent>
</Card>
</CardHeader>
<CardContent class="space-y-4">
{#if birdError}
<Alert variant="destructive">
<XCircle />
<AlertTitle>Ошибка birdc</AlertTitle>
<AlertDescription>{birdError}</AlertDescription>
</Alert>
{:else if bird && !bird.birdc_configured}
<p class="text-sm text-muted-foreground">
{bird.message ?? 'birdc не настроен на API-хосте (EVOBGP_BIRDC_SOCKET).'}
</p>
{:else if bird}
<div class="space-y-2">
<div class="flex items-center justify-between text-sm">
<span class="text-muted-foreground">Established / total</span>
<span class="font-medium tabular-nums">
{bird.bgp_established} / {bird.bgp_sessions_total}
{#if bgpRatio !== null}
<span class="text-muted-foreground">({bgpRatio}%)</span>
{/if}
</span>
</div>
{#if bgpRatio !== null}
<div class="h-2 overflow-hidden rounded-full bg-muted">
<div
class={cn(
'h-full rounded-full transition-all',
bgpRatio >= 100
? 'bg-success'
: bgpRatio >= 50
? 'bg-warning'
: 'bg-destructive'
)}
style="width: {bgpRatio}%"
></div>
</div>
{/if}
{#if bird.error}
<p class="text-xs text-destructive">{bird.error}</p>
{/if}
</div>
{#if bird.protocols_excerpt}
<Separator />
<div class="space-y-2">
<p class="text-sm font-medium">Вывод birdc (protocols)</p>
<ScrollPreBlock variant="preserve" text={bird.protocols_excerpt} class="max-h-48" />
</div>
{/if}
{/if}
</CardContent>
</Card>
{/if}
</div>
<Card>
<CardHeader>
<CardTitle class="flex items-center gap-2 text-base">
<AlertTriangle class="size-4 text-muted-foreground" />
Что проверять при деградации
</CardTitle>
<CardDescription>Короткая шпаргалка для triage</CardDescription>
</CardHeader>
<CardContent class="space-y-2 text-sm">
<p>
<span class="text-muted-foreground">Если </span><code>health=failed</code> — проверьте доступность
процесса API и его логи.
</p>
<p>
<span class="text-muted-foreground">Если </span><code>ready!=ready</code> — сначала `postgres`,
затем `store/jobs` в checks.
</p>
<p>
<span class="text-muted-foreground">Если low BGP ratio</span> — проверьте `bird/status`, затем
BGP peer состояния.
</p>
<p>
<span class="text-muted-foreground">Если ошибки jobs</span> — откройте операции и проверьте
последние неуспешные задачи.
</p>
</CardContent>
</Card>
<div class="grid gap-4 lg:grid-cols-2">
{#if initialLoading}
<CardSkeleton />
<CardSkeleton />
{:else}
<Card>
<CardHeader>
<div class="flex items-start justify-between gap-2">
<div>
<CardTitle class="flex items-center gap-2 text-base">
<Activity class="size-4" />
Задачи
</CardTitle>
<CardDescription>Последние 100 задач · GET /v1/jobs</CardDescription>
</div>
<Button variant="outline" size="sm" href={resolve('/operations')}>Все операции</Button>
</div>
</CardHeader>
<CardContent class="space-y-4">
{#if jobsError}
<Alert variant="destructive">
<XCircle />
<AlertTitle>Не удалось загрузить задачи</AlertTitle>
<AlertDescription>{jobsError}</AlertDescription>
</Alert>
{:else if jobs}
<div class="flex flex-wrap gap-4 text-sm">
<div>
<p class="text-muted-foreground">Активных</p>
<p class="text-2xl font-bold tabular-nums">{jobs.running}</p>
</div>
<div>
<p class="text-muted-foreground">С ошибками</p>
<p
class={cn(
'text-2xl font-bold tabular-nums',
jobs.failed > 0 ? 'text-warning' : 'text-success'
)}
>
{jobs.failed}
</p>
</div>
<div>
<p class="text-muted-foreground">В выборке</p>
<p class="text-2xl font-bold tabular-nums">{jobs.total}</p>
</div>
</div>
<Separator />
{#if failedJobs.length > 0}
<div class="space-y-3">
<p class="text-sm font-medium">Последние ошибки</p>
<ul class="space-y-2">
{#each failedJobs as job (job.job_id)}
<li class="rounded-lg border px-3 py-2 text-sm">
<div class="flex items-start justify-between gap-2">
<p class="font-medium">{jobKindTitle(job)}</p>
<Badge variant="destructive">{jobStatusRu(job.status)}</Badge>
</div>
{#if job.error}
<p class="mt-1 text-xs text-muted-foreground">
{truncateError(job.error)}
</p>
{/if}
</li>
{/each}
</ul>
</div>
{:else}
<p class="text-sm text-muted-foreground">
Критичных сбоев в последних 100 задачах нет.
</p>
{/if}
{/if}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle class="flex items-center gap-2 text-base">
<AlertTriangle class="size-4 text-muted-foreground" />
Что проверять при деградации
</CardTitle>
<CardDescription>Короткая шпаргалка для triage</CardDescription>
</CardHeader>
<CardContent class="space-y-3">
<Alert>
<HeartPulse class="size-4" />
<AlertTitle>API недоступен</AlertTitle>
<AlertDescription>
Если <code class="text-xs">/v1/health</code> возвращает ошибку — проверьте процесс API и
его логи.
</AlertDescription>
</Alert>
<Alert>
<Database class="size-4" />
<AlertTitle>Readiness не «Готов»</AlertTitle>
<AlertDescription>
Сначала <code class="text-xs">postgres</code>, затем
<code class="text-xs">store</code> и <code class="text-xs">jobs</code> в checks.
</AlertDescription>
</Alert>
<Alert>
<Bird class="size-4" />
<AlertTitle>Низкий ratio BGP</AlertTitle>
<AlertDescription>
Проверьте <code class="text-xs">/v1/bird/status</code>, затем состояние пиров в
<Button variant="link" class="h-auto p-0" href={resolve('/network')}>Сети</Button>.
</AlertDescription>
</Alert>
<Alert>
<ListTodo class="size-4" />
<AlertTitle>Ошибки задач</AlertTitle>
<AlertDescription>
Откройте
<Button variant="link" class="h-auto p-0" href={resolve('/operations')}
>Операции</Button
>
и проверьте последние неуспешные jobs.
</AlertDescription>
</Alert>
</CardContent>
</Card>
{/if}
</div>
</div>
+314 -191
View File
@@ -1,5 +1,8 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { resolve } from '$app/paths';
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
import {
apiFetch,
@@ -23,28 +26,28 @@
ModulesResponse
} from '$lib/api/types.js';
import { jobKindTitle } from '$lib/operations/job-kind-label.js';
import { jobStatusRu } from '$lib/ui-labels.js';
import { Badge } from '$lib/components/ui/badge/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '$lib/components/ui/tabs/index.js';
import {
AlertDialog,
AlertDialogContent,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogCancel,
AlertDialogAction
} from '$lib/components/ui/alert-dialog/index.js';
import { jobStatusRu, jobStatusBadgeVariant } from '$lib/ui-labels.js';
import { formatDateTime } from '$lib/modules/display.js';
import { Badge } from '$lib/ui/core/badge/index.js';
import { Button } from '$lib/ui/core/button/index.js';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '$lib/ui/core/tabs/index.js';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription
} from '$lib/components/ui/dialog/index.js';
import { ScrollArea } from '$lib/components/ui/scroll-area/index.js';
} from '$lib/ui/core/dialog/index.js';
import { Select, SelectContent, SelectItem, SelectTrigger } from '$lib/ui/core/select/index.js';
import { Alert, AlertDescription, AlertTitle } from '$lib/ui/core/alert/index.js';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from '$lib/ui/core/card/index.js';
import { Skeleton } from '$lib/ui/core/skeleton/index.js';
import OperationsQuickActions from '$lib/components/operations/OperationsQuickActions.svelte';
import OperationsRevisionsTab from '$lib/components/operations/OperationsRevisionsTab.svelte';
import OperationsDiffTab from '$lib/components/operations/OperationsDiffTab.svelte';
@@ -56,6 +59,10 @@
ReportRow
} from '$lib/components/operations/types.js';
import ScrollPreBlock from '$lib/components/app/scroll-pre-block.svelte';
import CardSkeleton from '$lib/ui/patterns/feedback/card-skeleton.svelte';
import { confirm } from '$lib/ui/patterns/confirm/confirm-state.svelte.js';
import PageHeader from '$lib/ui/app/page-header/page-header.svelte';
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
import {
dialogBodyDocument,
dialogBodyPanel,
@@ -65,14 +72,23 @@
dialogHeaderPanel
} from '$lib/dialog-layout.js';
import { cn } from '$lib/utils.js';
import { toast } from 'svelte-sonner';
import Zap from '@lucide/svelte/icons/zap';
import PageHeader from '$lib/ui/app/page-header/page-header.svelte';
import Activity from '@lucide/svelte/icons/activity';
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
import Clock from '@lucide/svelte/icons/clock';
import AlertTriangle from '@lucide/svelte/icons/alert-triangle';
import Info from '@lucide/svelte/icons/info';
import ArrowRight from '@lucide/svelte/icons/arrow-right';
type OpsTab = 'revisions' | 'diff' | 'jobs';
function parseOpsTab(value: string | null): OpsTab {
if (value === 'diff' || value === 'jobs') return value;
return 'revisions';
}
// Revisions
let revisions = $state<RevisionRow[]>([]);
let revLoading = $state(false);
let rollbackTarget = $state<RevisionRow | null>(null);
let rollingBack = $state(false);
// Preview/Prefixes
@@ -115,8 +131,6 @@
let jobFilterModule = $state('');
let jobActiveOnly = $state(false);
let moduleNameById = $state(new Map<string, string>());
let cancelTarget = $state<JobRow | null>(null);
let cancelling = $state(false);
let jobDetailDialog = $state(false);
let jobDetail = $state<JobRow | null>(null);
let expandedJobIds = new SvelteSet<string>();
@@ -128,14 +142,18 @@
// Global apply / bird reload
let applying = $state(false);
let reloading = $state(false);
let applyConfirm = $state(false);
let reloadConfirm = $state(false);
// BIRD runtime status (birdc на хосте API, если настроен сокет)
let birdStatus = $state<BirdStatus | null>(null);
let birdLoading = $state(false);
let birdProtocolsOpen = $state(false);
let activeTab = $state<OpsTab>('revisions');
let initialLoading = $state(true);
let refreshing = $state(false);
let lastUpdated = $state<Date | null>(null);
let tabSyncReady = $state(false);
function summarizeJobBirdMeta(job: JobRow): string {
const check = job.meta?.bird_post_apply_check;
if (check === 'skipped_no_birdc_socket') {
@@ -156,7 +174,7 @@
birdStatus = await apiJSON<BirdStatus>('/v1/bird/status');
} catch (e) {
birdStatus = null;
toast.error(e instanceof Error ? e.message : String(e));
notifyApiError(e);
} finally {
birdLoading = false;
}
@@ -168,7 +186,7 @@
const r = await apiJSON<RevisionsResponse>('/v1/revisions?limit=100');
revisions = r.items;
} catch (e) {
toast.error(e instanceof Error ? e.message : String(e));
notifyApiError(e);
} finally {
revLoading = false;
}
@@ -211,7 +229,7 @@
link.remove();
URL.revokeObjectURL(objectUrl);
} catch (e) {
toast.error(e instanceof Error ? e.message : String(e));
notifyApiError(e);
}
}
@@ -225,7 +243,7 @@
const j = await apiJSON<JobsResponse>(`/v1/jobs?${params.toString()}`);
jobs = j.items;
} catch (e) {
toast.error(e instanceof Error ? e.message : String(e));
notifyApiError(e);
} finally {
jobsLoading = false;
}
@@ -314,17 +332,149 @@
}
moduleNameById = m;
} catch (e) {
toast.error(e instanceof Error ? e.message : String(e));
notifyApiError(e);
}
}
onMount(() => {
loadRevisions();
loadJobs();
loadBirdStatus();
loadModules();
activeTab = parseOpsTab(page.url.searchParams.get('tab'));
tabSyncReady = true;
void refreshAll(true);
});
const statAccents = [
{
border: 'border-l-chart-1',
bg: 'bg-chart-1/5',
iconBg: 'bg-chart-1/15',
iconText: 'text-chart-1'
},
{
border: 'border-l-chart-2',
bg: 'bg-chart-2/5',
iconBg: 'bg-chart-2/15',
iconText: 'text-chart-2'
},
{
border: 'border-l-chart-4',
bg: 'bg-chart-4/5',
iconBg: 'bg-chart-4/15',
iconText: 'text-chart-4'
}
] as const;
const runningJobsCount = $derived(
jobs.filter((j) => j.status === 'running' || j.status === 'queued').length
);
const failedJobsCount = $derived(
jobs.filter((j) => {
const s = String(j.status ?? '').toLowerCase();
return s === 'failed' || s === 'error' || s === 'canceled' || s === 'cancelled';
}).length
);
const kpiCards = $derived.by(() => [
{
id: 'revisions',
label: 'Ревизий',
value: initialLoading ? '—' : String(revisions.length),
description: 'в последней выборке',
icon: Activity,
accent: statAccents[0],
badge: 'история конфигов'
},
{
id: 'running',
label: 'Активных задач',
value: initialLoading ? '—' : String(runningJobsCount),
description: 'queued и running',
icon: Clock,
accent: statAccents[1],
badge: 'в работе'
},
{
id: 'failed',
label: 'Задач с ошибкой',
value: initialLoading ? '—' : String(failedJobsCount),
description: failedJobsCount > 0 ? 'требуют внимания' : 'критичных сбоев нет',
icon: AlertTriangle,
accent: statAccents[2],
badge: failedJobsCount > 0 ? 'есть ошибки' : 'без ошибок',
badgeClass:
failedJobsCount === 0 ? 'border-success/30 bg-success/15 text-success' : undefined,
href: failedJobsCount > 0 ? ('/schedule' as const) : undefined
}
]);
function syncTabToUrl(tab: OpsTab) {
if (!tabSyncReady) return;
const url = new URL(page.url);
if (tab === 'revisions') url.searchParams.delete('tab');
else url.searchParams.set('tab', tab);
const next = `${url.pathname}${url.search}${url.hash}`;
if (next !== `${page.url.pathname}${page.url.search}${page.url.hash}`) {
void goto(next, { replaceState: true, keepFocus: true, noScroll: true });
}
}
$effect(() => {
if (!tabSyncReady) return;
syncTabToUrl(activeTab);
});
async function refreshAll(isInitial = false) {
if (isInitial) initialLoading = true;
else refreshing = true;
await Promise.all([loadRevisions(), loadJobs(), loadBirdStatus(), loadModules()]);
lastUpdated = new Date();
initialLoading = false;
refreshing = false;
}
function requestApply() {
void confirm({
title: 'Применить конфигурацию на всех спикерах?',
description:
'Текущая конфигурация будет применена на всех BIRD-агентах. Требуется роль operator.',
confirmLabel: 'Применить',
onConfirm: doApply
});
}
function requestReload() {
void confirm({
title: 'Перезагрузить BIRD?',
description: 'BIRD перезагрузит конфигурацию. Требуется роль operator.',
confirmLabel: 'Перезагрузить',
onConfirm: doBirdReload
});
}
function requestRollback(rev: RevisionRow) {
void confirm({
title: `Откатиться к ревизии ${rev.id.slice(0, 8)}…?`,
description: 'Будет создана новая ревизия на основе выбранной. Требуется роль operator.',
confirmLabel: 'Откатить',
destructive: true,
onConfirm: async () => {
await rollback(rev);
}
});
}
function requestCancelJob(job: JobRow) {
void confirm({
title: 'Отменить задачу?',
description: `Задача: ${jobKindTitle(job, moduleNameById)} (${job.job_id.slice(0, 8)}…)`,
confirmLabel: 'Отменить',
destructive: true,
onConfirm: async () => {
await cancelJob(job);
}
});
}
async function openPreview(rev: RevisionRow) {
previewRevision = rev;
previewLoading = true;
@@ -343,7 +493,7 @@
const frags = asPreviewFragments(prev);
birdPreviewPath = defaultBirdPreviewPath(frags);
} catch (e) {
toast.error(e instanceof Error ? e.message : String(e));
notifyApiError(e);
} finally {
previewLoading = false;
}
@@ -351,29 +501,29 @@
async function loadDiff() {
if (!diffRevA || !diffRevB) {
toast.error('Выберите две ревизии');
notify.error('Выберите две ревизии');
return;
}
diffLoading = true;
try {
diffData = await apiJSON<RevisionDiff>(`/v1/revisions/${diffRevA}/diff/${diffRevB}`);
} catch (e) {
toast.error(e instanceof Error ? e.message : String(e));
notifyApiError(e);
} finally {
diffLoading = false;
}
}
async function rollback() {
if (!rollbackTarget) return;
async function rollback(rev: RevisionRow) {
rollingBack = true;
try {
await apiMutate(`/v1/revisions/${rollbackTarget.id}/rollback`, 'POST', {});
toast.success('Откат выполнен');
rollbackTarget = null;
await apiMutate(`/v1/revisions/${rev.id}/rollback`, 'POST', {});
notify.success('Откат выполнен');
await loadRevisions();
} catch (e) {
toast.error(e instanceof Error ? e.message : String(e));
notifyApiError(e);
throw e;
} finally {
rollingBack = false;
}
}
@@ -383,23 +533,22 @@
try {
const revId = revisions[0]?.id;
if (!revId) {
toast.error('Нет ревизий — сначала обновите модуль или дождитесь задачи render');
notify.error('Нет ревизий — сначала обновите модуль или дождитесь задачи render');
return;
}
const res = await apiMutate<{ job_id: string; status?: string }>('/v1/apply', 'POST', {
revision_id: revId
});
applyConfirm = false;
if (!res?.job_id) {
toast.error('Ответ API без job_id');
notify.error('Ответ API без job_id');
return;
}
const job = await waitForJob(res.job_id, { timeoutMs: 180000 });
const extra = summarizeJobBirdMeta(job);
if (job.status === 'succeeded') {
toast.success(extra ? `Применение успешно. ${extra}` : 'Конфигурация успешно применена');
notify.success(extra ? `Применение успешно. ${extra}` : 'Конфигурация успешно применена');
} else {
toast.error(
notify.error(
job.error
? `${jobStatusRu(job.status)}: ${job.error}`
: `Задача завершилась со статусом ${jobStatusRu(job.status)}`
@@ -408,7 +557,7 @@
await loadJobs();
await loadBirdStatus();
} catch (e) {
toast.error(e instanceof Error ? e.message : String(e));
notifyApiError(e);
} finally {
applying = false;
}
@@ -418,19 +567,18 @@
reloading = true;
try {
const res = await apiMutate<{ job_id: string }>('/v1/bird/reload', 'POST', {});
reloadConfirm = false;
if (!res?.job_id) {
toast.error('Ответ API без job_id');
notify.error('Ответ API без job_id');
return;
}
const job = await waitForJob(res.job_id, { timeoutMs: 120000 });
const extra = summarizeJobBirdMeta(job);
if (job.status === 'succeeded') {
toast.success(
notify.success(
extra ? `Перезагрузка успешна. ${extra}` : 'Команда birdc configure выполнена'
);
} else {
toast.error(
notify.error(
job.error
? `${jobStatusRu(job.status)}: ${job.error}`
: `Задача завершилась со статусом ${jobStatusRu(job.status)}`
@@ -439,24 +587,20 @@
await loadJobs();
await loadBirdStatus();
} catch (e) {
toast.error(e instanceof Error ? e.message : String(e));
notifyApiError(e);
} finally {
reloading = false;
}
}
async function cancelJob() {
if (!cancelTarget) return;
cancelling = true;
async function cancelJob(job: JobRow) {
try {
await apiMutate(`/v1/jobs/${cancelTarget.job_id}/cancel`, 'POST', {});
toast.success('Задача отменена');
cancelTarget = null;
await apiMutate(`/v1/jobs/${job.job_id}/cancel`, 'POST', {});
notify.success('Задача отменена');
await loadJobs();
} catch (e) {
toast.error(e instanceof Error ? e.message : String(e));
} finally {
cancelling = false;
notifyApiError(e);
throw e;
}
}
@@ -487,7 +631,7 @@
jobDetailsById.set(jobId, freshJob);
await ensureJobReport(freshJob);
} catch (e) {
toast.error(e instanceof Error ? e.message : String(e));
notifyApiError(e);
} finally {
jobDetailsLoading.delete(jobId);
}
@@ -625,7 +769,7 @@
ipRanges
});
} catch (e) {
toast.error(e instanceof Error ? e.message : String(e));
notifyApiError(e);
} finally {
jobReportsLoading.delete(jobId);
}
@@ -703,18 +847,6 @@
return safeEntries.reduce((acc, entry) => acc + entry.prefix_count, 0);
}
function jobStatusVariant(status: string): 'default' | 'secondary' | 'outline' | 'destructive' {
if (status === 'succeeded') return 'default';
if (status === 'running') return 'secondary';
if (status === 'failed') return 'destructive';
return 'outline';
}
function formatDate(d?: string | null) {
if (!d) return '—';
return new Date(d).toLocaleString('ru');
}
function birdHealthyBadgeVariant(
h: boolean | null | undefined
): 'default' | 'secondary' | 'outline' | 'destructive' {
@@ -724,7 +856,7 @@
}
function birdHealthyShortLabel(h: boolean | null | undefined): string {
if (h === true) return 'ОК';
if (h === true) return 'В норме';
if (h === false) return 'Проблема';
return 'Н/Д';
}
@@ -732,31 +864,100 @@
<div class="flex flex-col gap-6">
<PageHeader
title="Операции"
description="Деплой конфигурации, управление ревизиями и задачами."
icon={Zap}
title="Ревизии и операции"
description={lastUpdated
? `Деплой, сравнение конфигураций и задачи. Обновлено: ${lastUpdated.toLocaleTimeString('ru-RU')}`
: 'Деплой конфигурации, управление ревизиями и задачами.'}
icon={Activity}
iconClass="bg-chart-2/15 text-chart-2"
/>
>
{#snippet actions()}
<Button variant="outline" size="sm" onclick={() => refreshAll()} disabled={refreshing}>
<RefreshCw class={refreshing ? 'animate-spin' : ''} />
Обновить
</Button>
{/snippet}
</PageHeader>
<Alert class="border-info/30 bg-info/5">
<Info class="text-info" />
<AlertTitle>Три раздела на одной странице</AlertTitle>
<AlertDescription>
<strong>Ревизии</strong> — история конфигов и откат; <strong>Сравнение</strong> — diff
префиксов;
<strong>Задачи</strong> — ingest, apply, rollback. Apply и Reload требуют operator. Сводный
мониторинг BGP — на
<Button variant="link" class="h-auto p-0" href={resolve('/monitoring')}>Мониторинг</Button>.
</AlertDescription>
</Alert>
<div class="grid gap-4 sm:grid-cols-3">
{#if initialLoading}
{#each Array(3) as _, i (i)}
<CardSkeleton />
{/each}
{:else}
{#each kpiCards as card (card.id)}
{@const Icon = card.icon}
{@const a = card.accent}
<Card
class={cn(
'overflow-hidden border-l-4 shadow-sm transition-colors',
a.border,
a.bg,
card.href ? 'hover:border-primary/35' : ''
)}
>
<CardHeader class="pb-2">
<div class="flex items-center justify-between gap-2">
<CardDescription class="flex min-w-0 items-center gap-2">
<span
class={cn(
'flex size-9 shrink-0 items-center justify-center rounded-lg',
a.iconBg
)}
aria-hidden="true"
>
<Icon class={cn('size-4', a.iconText)} />
</span>
<span class="truncate">{card.label}</span>
</CardDescription>
{#if card.href}
<Button variant="ghost" size="icon-sm" href={resolve(card.href)}>
<ArrowRight class="size-3.5" aria-hidden="true" />
</Button>
{/if}
</div>
<CardTitle class="text-3xl font-bold tabular-nums">{card.value}</CardTitle>
</CardHeader>
<CardContent class="space-y-2">
<Badge variant="outline" class={card.badgeClass}>{card.badge}</Badge>
<p class="text-xs text-muted-foreground">{card.description}</p>
</CardContent>
</Card>
{/each}
{/if}
</div>
<OperationsQuickActions
{applying}
{reloading}
{birdLoading}
{birdStatus}
onApply={() => (applyConfirm = true)}
onReload={() => (reloadConfirm = true)}
onApply={requestApply}
onReload={requestReload}
onRefreshBirdStatus={loadBirdStatus}
onOpenBirdProtocols={() => (birdProtocolsOpen = true)}
{birdHealthyBadgeVariant}
{birdHealthyShortLabel}
/>
<Tabs value="revisions">
<Tabs bind:value={activeTab}>
<div class="overflow-x-auto pb-1 [scrollbar-gutter:stable]">
<TabsList class="inline-flex min-w-max">
<TabsTrigger value="revisions">Ревизии</TabsTrigger>
<TabsTrigger value="revisions">Ревизии ({revisions.length})</TabsTrigger>
<TabsTrigger value="diff">Сравнение</TabsTrigger>
<TabsTrigger value="jobs">Задачи</TabsTrigger>
<TabsTrigger value="jobs">Задачи ({jobs.length})</TabsTrigger>
</TabsList>
</div>
@@ -766,9 +967,8 @@
{revLoading}
onReload={loadRevisions}
onOpenPreview={openPreview}
onRollbackRequest={(rev) => (rollbackTarget = rev)}
onRollbackRequest={requestRollback}
onDownloadDiagnosticLog={downloadRevisionDiagnosticLog}
{formatDate}
/>
</TabsContent>
@@ -782,7 +982,6 @@
onDiffRevAChange={(value) => (diffRevA = value)}
onDiffRevBChange={(value) => (diffRevB = value)}
onLoadDiff={loadDiff}
{formatDate}
/>
</TabsContent>
@@ -815,103 +1014,17 @@
{jobReportsLoading}
onReloadJobs={loadJobs}
onOpenJobDetail={openJobDetail}
onRequestCancelJob={(job) => (cancelTarget = job)}
onRequestCancelJob={requestCancelJob}
onToggleJobExpanded={toggleJobExpanded}
{isJobExpanded}
{getJobLogEntries}
{getJobLogTotal}
{jobStatusVariant}
{formatDate}
jobStatusVariant={jobStatusBadgeVariant}
/>
</TabsContent>
</Tabs>
</div>
<!-- Apply confirm -->
<AlertDialog bind:open={applyConfirm}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Применить конфигурацию на всех спикерах?</AlertDialogTitle>
<AlertDialogDescription
>Текущая конфигурация будет применена на всех BIRD-агентах. Требуется роль operator.</AlertDialogDescription
>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Отмена</AlertDialogCancel>
<AlertDialogAction onclick={doApply} disabled={applying}
>{applying ? 'Применение…' : 'Применить'}</AlertDialogAction
>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<!-- BIRD reload confirm -->
<AlertDialog bind:open={reloadConfirm}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Перезагрузить BIRD?</AlertDialogTitle>
<AlertDialogDescription
>BIRD перезагрузит конфигурацию. Требуется роль operator.</AlertDialogDescription
>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Отмена</AlertDialogCancel>
<AlertDialogAction onclick={doBirdReload} disabled={reloading}
>{reloading ? 'Перезагрузка…' : 'Перезагрузить'}</AlertDialogAction
>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<!-- Rollback confirm -->
<AlertDialog
open={!!rollbackTarget}
onOpenChange={(v) => {
if (!v) rollbackTarget = null;
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Откатиться к ревизии {rollbackTarget?.id.slice(0, 8)}…?</AlertDialogTitle>
<AlertDialogDescription
>Будет создана новая ревизия на основе выбранной. Требуется роль operator.</AlertDialogDescription
>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onclick={() => (rollbackTarget = null)}>Отмена</AlertDialogCancel>
<AlertDialogAction onclick={rollback} disabled={rollingBack}
>{rollingBack ? 'Откат…' : 'Откатить'}</AlertDialogAction
>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<!-- Cancel job confirm -->
<AlertDialog
open={!!cancelTarget}
onOpenChange={(v) => {
if (!v) cancelTarget = null;
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Отменить задачу?</AlertDialogTitle>
<AlertDialogDescription>
Задача: {cancelTarget ? jobKindTitle(cancelTarget, moduleNameById) : ''} ({cancelTarget?.job_id?.slice(
0,
8
)}…)
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onclick={() => (cancelTarget = null)}>Нет</AlertDialogCancel>
<AlertDialogAction onclick={cancelJob} disabled={cancelling}
>{cancelling ? 'Отмена…' : 'Отменить'}</AlertDialogAction
>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<!-- Preview dialog -->
<Dialog bind:open={previewDialog}>
<DialogContent class={dialogContentDocument}>
@@ -922,7 +1035,10 @@
</DialogDescription>
</DialogHeader>
{#if previewLoading}
<div class="px-6 py-10 text-center text-sm text-muted-foreground">Загрузка…</div>
<div class="space-y-3 px-6 py-6">
<Skeleton class="h-9 w-full max-w-md" />
<Skeleton class="h-48 w-full" />
</div>
{:else}
<div class={cn(dialogBodyDocument, 'min-h-[min(44vh,400px)]')}>
<Tabs bind:value={previewSubTab} class="flex min-h-0 min-w-0 flex-1 flex-col gap-3">
@@ -941,16 +1057,23 @@
</p>
{:else}
<div class="flex shrink-0 flex-wrap items-center gap-2">
<label for="bird-frag" class="text-sm text-muted-foreground">Файл</label>
<select
id="bird-frag"
class="max-w-xl min-w-0 flex-1 rounded-md border border-input bg-background px-2 py-1.5 text-sm ring-offset-background focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
bind:value={birdPreviewPath}
<span class="text-sm text-muted-foreground">Файл</span>
<Select
type="single"
value={birdPreviewPath}
onValueChange={(v) => {
if (v) birdPreviewPath = v;
}}
>
{#each Object.keys(frags).sort() as path (path)}
<option value={path}>{path}</option>
{/each}
</select>
<SelectTrigger class="max-w-xl min-w-0 flex-1">
{birdPreviewPath || 'Выберите файл'}
</SelectTrigger>
<SelectContent>
{#each Object.keys(frags).sort() as path (path)}
<SelectItem value={path}>{path}</SelectItem>
{/each}
</SelectContent>
</Select>
</div>
<p class="shrink-0 text-xs text-muted-foreground">
Совет: откройте <code class="rounded bg-muted px-1">_bird_full_expanded.conf</code>
@@ -1021,18 +1144,18 @@
>{jobDetail.job_id}</span
>
<span class="text-muted-foreground">Статус</span><span
><Badge variant={jobStatusVariant(jobDetail.status)}
><Badge variant={jobStatusBadgeVariant(jobDetail.status)}
>{jobStatusRu(jobDetail.status)}</Badge
></span
>
<span class="text-muted-foreground">Создана</span><span
>{formatDate(jobDetail.created_at)}</span
>{formatDateTime(jobDetail.created_at)}</span
>
<span class="text-muted-foreground">Начата</span><span
>{formatDate(jobDetail.started_at)}</span
>{formatDateTime(jobDetail.started_at)}</span
>
<span class="text-muted-foreground">Завершена</span><span
>{formatDate(jobDetail.finished_at)}</span
>{formatDateTime(jobDetail.finished_at)}</span
>
{#if jobDetail.error}
<span class="text-muted-foreground">Ошибка</span><span
+2 -1
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
goto(resolve('/operations'));
goto(`${resolve('/operations')}?tab=revisions`, { replaceState: true });
</script>
+346 -206
View File
@@ -1,258 +1,398 @@
<script lang="ts">
import { onMount } from 'svelte';
import { apiFetch, apiJSON } from '$lib/api/client.js';
import { resolve } from '$app/paths';
import { apiJSON, apiMutate } from '$lib/api/client.js';
import type { JobRow, JobsResponse, ModuleRow, ModulesResponse } from '$lib/api/types.js';
import { Badge } from '$lib/components/ui/badge/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import {
formatDateTime,
moduleIntervalLabel,
moduleTypeBadgeVariant
} from '$lib/modules/display.js';
import { jobKindTitle } from '$lib/operations/job-kind-label.js';
import { jobStatusRu, jobStatusBadgeVariant, moduleTypeRu } from '$lib/ui-labels.js';
import { Badge } from '$lib/ui/core/badge/index.js';
import { Button } from '$lib/ui/core/button/index.js';
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription
} from '$lib/components/ui/card/index.js';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow
} from '$lib/components/ui/table/index.js';
import { toast } from 'svelte-sonner';
} from '$lib/ui/core/card/index.js';
import { Alert, AlertDescription, AlertTitle } from '$lib/ui/core/alert/index.js';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '$lib/ui/core/tabs/index.js';
import AppDataTable from '$lib/ui/patterns/data-table/app-data-table.svelte';
import CardSkeleton from '$lib/ui/patterns/feedback/card-skeleton.svelte';
import PageHeader from '$lib/ui/app/page-header/page-header.svelte';
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
import { cn } from '$lib/utils.js';
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
import CalendarClock from '@lucide/svelte/icons/calendar-clock';
import PageHeader from '$lib/ui/app/page-header/page-header.svelte';
import { jobStatusRu, moduleTypeRu } from '$lib/ui-labels.js';
import { jobKindTitle } from '$lib/operations/job-kind-label.js';
import ListTodo from '@lucide/svelte/icons/list-todo';
import Clock from '@lucide/svelte/icons/clock';
import AlertTriangle from '@lucide/svelte/icons/alert-triangle';
import ArrowRight from '@lucide/svelte/icons/arrow-right';
import ExternalLink from '@lucide/svelte/icons/external-link';
import Info from '@lucide/svelte/icons/info';
type JobsTab = 'all' | 'refresh' | 'failed';
let modules = $state<ModuleRow[]>([]);
let jobs = $state<JobRow[]>([]);
let refreshing = $state<Record<string, boolean>>({});
let loading = $state(false);
let moduleRefreshing = $state<Record<string, boolean>>({});
let loadError = $state<string | null>(null);
let lastUpdated = $state<Date | null>(null);
let initialLoading = $state(true);
let refreshing = $state(false);
let jobsTab = $state<JobsTab>('all');
const statAccents = [
{
border: 'border-l-chart-1',
bg: 'bg-chart-1/5',
iconBg: 'bg-chart-1/15',
iconText: 'text-chart-1'
},
{
border: 'border-l-chart-2',
bg: 'bg-chart-2/5',
iconBg: 'bg-chart-2/15',
iconText: 'text-chart-2'
},
{
border: 'border-l-chart-4',
bg: 'bg-chart-4/5',
iconBg: 'bg-chart-4/15',
iconText: 'text-chart-4'
}
] as const;
const moduleColumns = [
{ id: 'name', label: 'Модуль', sortable: true, sortValue: (m: ModuleRow) => m.name },
{ id: 'type', label: 'Тип', sortable: true, sortValue: (m: ModuleRow) => m.type },
{ id: 'schedule', label: 'Расписание' },
{
id: 'refreshed',
label: 'Последнее обновление',
sortable: true,
sortValue: (m: ModuleRow) => m.last_refreshed_at ?? ''
},
{ id: 'status', label: 'Статус' },
{ id: 'actions', label: '', class: 'w-32 text-right' }
] as const;
const jobColumns = [
{ id: 'kind', label: 'Вид' },
{ id: 'status', label: 'Статус', sortable: true, sortValue: (j: JobRow) => j.status },
{
id: 'created',
label: 'Создана',
sortable: true,
sortValue: (j: JobRow) => j.created_at ?? ''
},
{
id: 'finished',
label: 'Завершена',
sortable: true,
sortValue: (j: JobRow) => j.finished_at ?? ''
},
{ id: 'error', label: 'Ошибка', class: 'max-w-xs' }
] as const;
const moduleNameById = $derived(new Map(modules.map((m) => [m.id, m.name])));
const runningJobsCount = $derived(
jobs.filter((j) => j.status === 'running' || j.status === 'queued').length
);
const failedJobsCount = $derived(
jobs.filter((j) => {
const s = String(j.status ?? '').toLowerCase();
return s === 'failed' || s === 'error' || s === 'canceled';
}).length
);
const filteredJobs = $derived.by(() => {
if (jobsTab === 'refresh') return jobs.filter((j) => j.kind === 'module_refresh');
if (jobsTab === 'failed') {
return jobs.filter((j) => {
const s = String(j.status ?? '').toLowerCase();
return s === 'failed' || s === 'error' || s === 'canceled';
});
}
return jobs;
});
const kpiCards = $derived.by(() => [
{
id: 'total',
label: 'Всего задач',
value: initialLoading ? '—' : String(jobs.length),
description: 'в последней выборке',
icon: ListTodo,
accent: statAccents[0],
badge: 'в выборке',
href: '/operations' as const
},
{
id: 'running',
label: 'В работе',
value: initialLoading ? '—' : String(runningJobsCount),
description: 'queued и running',
icon: Clock,
accent: statAccents[1],
badge: 'активных',
href: undefined
},
{
id: 'failed',
label: 'С ошибкой',
value: initialLoading ? '—' : String(failedJobsCount),
description: failedJobsCount > 0 ? 'требуют внимания' : 'критичных сбоев нет',
icon: AlertTriangle,
accent: statAccents[2],
badge: failedJobsCount > 0 ? 'есть ошибки' : 'без ошибок',
badgeClass:
failedJobsCount === 0 ? 'border-success/30 bg-success/15 text-success' : undefined,
href: failedJobsCount > 0 ? ('/operations' as const) : undefined
}
]);
function truncateError(error: string | null | undefined, max = 120): string {
if (!error) return '';
return error.length > max ? `${error.slice(0, max)}…` : error;
}
async function load() {
loading = true;
if (!initialLoading) refreshing = true;
loadError = null;
try {
const [m, j] = await Promise.all([
apiJSON<ModulesResponse>('/v1/modules?limit=200'),
apiJSON<JobsResponse>('/v1/jobs?limit=50')
apiJSON<JobsResponse>('/v1/jobs?limit=100')
]);
modules = m.items ?? [];
jobs = j.items ?? [];
lastUpdated = new Date();
} catch (e) {
toast.error(e instanceof Error ? e.message : String(e));
loadError = e instanceof Error ? e.message : String(e);
notifyApiError(e);
} finally {
loading = false;
initialLoading = false;
refreshing = false;
}
}
async function refreshModule(id: string) {
moduleRefreshing = { ...moduleRefreshing, [id]: true };
try {
const result = await apiMutate<{ job_id?: string }>(`/v1/modules/${id}/refresh`, 'POST');
if (result === undefined) {
notify.message('Обновление не требуется (тип IP_RANGES)');
} else {
notify.success('Задача поставлена в очередь');
await load();
}
} catch (e) {
notifyApiError(e);
} finally {
moduleRefreshing = { ...moduleRefreshing, [id]: false };
}
}
onMount(load);
async function refreshModule(id: string) {
refreshing = { ...refreshing, [id]: true };
try {
const token = localStorage.getItem('evobgp_api_token') ?? '';
const res = await fetch(`/v1/modules/${id}/refresh`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` }
});
if (res.status === 204) toast.message('Обновление не требуется (тип IP_RANGES)');
else if (res.status === 202) {
toast.success('Задача поставлена в очередь');
await load();
} else toast.error(`HTTP ${res.status}`);
} catch (e) {
toast.error(e instanceof Error ? e.message : String(e));
} finally {
refreshing = { ...refreshing, [id]: false };
}
}
function intervalLabel(sec: number | null) {
if (!sec) return '—';
if (sec % 3600 === 0) return `${sec / 3600} ч`;
if (sec % 60 === 0) return `${sec / 60} мин`;
return `${sec} с`;
}
function jobStatusVariant(s: string): 'default' | 'secondary' | 'outline' | 'destructive' {
if (s === 'succeeded') return 'default';
if (s === 'running') return 'secondary';
if (s === 'failed') return 'destructive';
return 'outline';
}
const refreshJobs = $derived(jobs.filter((j) => j.kind === 'module_refresh'));
const moduleNameById = $derived(new Map(modules.map((m) => [m.id, m.name])));
const runningJobsCount = $derived(
jobs.filter((j) => j.status === 'running' || j.status === 'queued').length
);
const failedJobsCount = $derived(jobs.filter((j) => j.status === 'failed').length);
</script>
<div class="flex flex-col gap-6">
<PageHeader
title="Расписание и задачи"
description="Интервалы обновления модулей и ручной запуск обновления."
description={lastUpdated
? `Интервалы обновления модулей и ручной запуск. Обновлено: ${lastUpdated.toLocaleTimeString('ru-RU')}`
: 'Интервалы обновления модулей и ручной запуск обновления.'}
icon={CalendarClock}
iconClass="bg-chart-4/15 text-chart-4"
>
{#snippet actions()}
<Button variant="outline" size="sm" onclick={load} disabled={loading}>
<RefreshCw class={loading ? 'animate-spin' : ''} />
<Button variant="outline" size="sm" onclick={load} disabled={refreshing}>
<RefreshCw class={refreshing ? 'animate-spin' : ''} />
Обновить
</Button>
{/snippet}
</PageHeader>
<div class="grid gap-3 sm:grid-cols-3">
<Card class="p-4">
<p class="text-xs text-muted-foreground">Всего задач</p>
<p class="text-lg font-semibold">{jobs.length}</p>
</Card>
<Card class="p-4">
<p class="text-xs text-muted-foreground">В работе</p>
<p class="text-lg font-semibold">{runningJobsCount}</p>
</Card>
<Card class="p-4">
<p class="text-xs text-muted-foreground">С ошибкой</p>
<p class="text-lg font-semibold">{failedJobsCount}</p>
</Card>
<Alert class="border-info/30 bg-info/5">
<Info class="text-info" />
<AlertTitle>Как работает расписание</AlertTitle>
<AlertDescription>
Планировщик использует <code class="text-xs">refresh_interval_sec</code> и опционально
<code class="text-xs">cron_expr</code>. Ручной запуск —
<code class="text-xs">POST /v1/modules&#123;id&#125;/refresh</code>; для модулей
<code class="text-xs">IP_RANGES</code>
сервер может вернуть <strong>204</strong>
(no-op). Полный список задач и деплой — на странице
<Button variant="link" class="h-auto p-0" href={resolve('/operations')}>Операции</Button>.
</AlertDescription>
</Alert>
<div class="grid gap-4 sm:grid-cols-3">
{#if initialLoading}
{#each Array(3) as _, i (i)}
<CardSkeleton />
{/each}
{:else}
{#each kpiCards as card (card.id)}
{@const Icon = card.icon}
{@const a = card.accent}
<Card
class={cn(
'overflow-hidden border-l-4 shadow-sm transition-colors',
a.border,
a.bg,
card.href ? 'hover:border-primary/35' : ''
)}
>
<CardHeader class="pb-2">
<div class="flex items-center justify-between gap-2">
<CardDescription class="flex min-w-0 items-center gap-2">
<span
class={cn(
'flex size-9 shrink-0 items-center justify-center rounded-lg',
a.iconBg
)}
aria-hidden="true"
>
<Icon class={cn('size-4', a.iconText)} />
</span>
<span class="truncate">{card.label}</span>
</CardDescription>
{#if card.href}
<Button variant="ghost" size="icon-sm" href={resolve(card.href)}>
<ArrowRight class="size-3.5" aria-hidden="true" />
</Button>
{/if}
</div>
<CardTitle class="text-3xl font-bold tabular-nums">{card.value}</CardTitle>
</CardHeader>
<CardContent class="space-y-2">
<Badge variant="outline" class={card.badgeClass}>{card.badge}</Badge>
<p class="text-xs text-muted-foreground">{card.description}</p>
</CardContent>
</Card>
{/each}
{/if}
</div>
<Card>
<CardHeader>
<CardTitle class="text-base">Модули</CardTitle>
<CardDescription
>Запустить обновление вручную (CDN, домены, AS — в очередь; для IP_RANGES ответ 204)</CardDescription
>
<CardDescription>
Расписание обновления и ручной запуск ingest (CDN, домены, AS — в очередь; IP_RANGES — 204)
</CardDescription>
</CardHeader>
<CardContent class="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>Модуль</TableHead>
<TableHead>Тип</TableHead>
<TableHead>Интервал</TableHead>
<TableHead>Cron</TableHead>
<TableHead class="w-28 text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{#each modules as m (m.id)}
<TableRow>
<TableCell class="font-medium">{m.name}</TableCell>
<TableCell><Badge variant="outline">{moduleTypeRu(m.type)}</Badge></TableCell>
<TableCell>{intervalLabel(m.refresh_interval_sec)}</TableCell>
<TableCell class="font-mono text-xs">{m.cron_expr || '—'}</TableCell>
<TableCell class="text-right">
<Button
size="xs"
variant="secondary"
disabled={!!refreshing[m.id]}
onclick={() => refreshModule(m.id)}
<CardContent class="p-4 pt-0">
<AppDataTable
columns={[...moduleColumns]}
rows={modules}
rowKey={(m) => m.id}
loading={initialLoading}
error={loadError}
emptyTitle="Нет модулей"
emptyDescription="Создайте модуль на странице «Модули»."
>
{#snippet cell({ row: m, column })}
{#if column.id === 'name'}
<div class="flex items-center gap-2">
<span class="font-medium">{m.name}</span>
<Button variant="ghost" size="icon-sm" href={resolve(`/modules/${m.id}`)}>
<ExternalLink class="size-3.5" aria-hidden="true" />
</Button>
</div>
{:else if column.id === 'type'}
<Badge variant={moduleTypeBadgeVariant(m.type)}>{moduleTypeRu(m.type)}</Badge>
{:else if column.id === 'schedule'}
<span class="font-mono text-xs text-muted-foreground">{moduleIntervalLabel(m)}</span>
{:else if column.id === 'refreshed'}
<span class="text-sm whitespace-nowrap text-muted-foreground"
>{formatDateTime(m.last_refreshed_at)}</span
>
{:else if column.id === 'status'}
{#if m.enabled}
<Badge variant="default" class="text-xs">Вкл</Badge>
{:else}
<Badge variant="secondary" class="text-xs">Выкл</Badge>
{/if}
{:else if column.id === 'actions'}
<div class="text-right">
<Button
size="sm"
variant="secondary"
disabled={!!moduleRefreshing[m.id]}
onclick={() => refreshModule(m.id)}
>
<RefreshCw class={moduleRefreshing[m.id] ? 'animate-spin' : ''} />
{moduleRefreshing[m.id] ? '…' : 'Обновить'}
</Button>
</div>
{/if}
{/snippet}
</AppDataTable>
</CardContent>
</Card>
<Card>
<CardHeader>
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<CardTitle class="text-base">Задачи</CardTitle>
<CardDescription>Последние 100 задач из API</CardDescription>
</div>
<Button variant="outline" size="sm" href={resolve('/operations')}>Все операции</Button>
</div>
</CardHeader>
<CardContent class="space-y-4 p-4 pt-0">
<Tabs bind:value={jobsTab}>
<TabsList class="inline-flex min-w-max">
<TabsTrigger value="all">Все ({jobs.length})</TabsTrigger>
<TabsTrigger value="refresh">
Обновление модулей ({jobs.filter((j) => j.kind === 'module_refresh').length})
</TabsTrigger>
<TabsTrigger value="failed">С ошибкой ({failedJobsCount})</TabsTrigger>
</TabsList>
<TabsContent value={jobsTab} class="mt-4">
<AppDataTable
columns={[...jobColumns]}
rows={filteredJobs}
rowKey={(j) => j.job_id}
loading={initialLoading}
error={loadError}
emptyTitle="Нет задач"
emptyDescription={jobsTab === 'failed'
? 'В выборке нет задач с ошибкой.'
: jobsTab === 'refresh'
? 'Задач обновления модулей пока нет.'
: 'Задачи появятся после refresh или деплоя.'}
>
{#snippet cell({ row: j, column })}
{#if column.id === 'kind'}
<span class="font-medium">{jobKindTitle(j, moduleNameById)}</span>
{:else if column.id === 'status'}
<Badge variant={jobStatusBadgeVariant(j.status)}>{jobStatusRu(j.status)}</Badge>
{:else if column.id === 'created'}
<span class="text-xs whitespace-nowrap text-muted-foreground"
>{formatDateTime(j.created_at)}</span
>
<RefreshCw class={refreshing[m.id] ? 'animate-spin' : ''} />
{refreshing[m.id] ? '…' : 'Обновить'}
</Button>
</TableCell>
</TableRow>
{:else}
<TableRow>
<TableCell colspan={5} class="text-muted-foreground text-center py-6">
{loading ? 'Загрузка…' : 'Нет модулей'}
</TableCell>
</TableRow>
{/each}
</TableBody>
</Table>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle class="text-base">Последние задачи</CardTitle>
<CardDescription>Задачи разложены по статусу и времени запуска.</CardDescription>
</CardHeader>
<CardContent class="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>Вид</TableHead>
<TableHead>Статус</TableHead>
<TableHead>Создана</TableHead>
<TableHead>Ошибка</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{#each jobs as j (j.job_id)}
<TableRow>
<TableCell class="font-medium">{jobKindTitle(j, moduleNameById)}</TableCell>
<TableCell
><Badge variant={jobStatusVariant(j.status)}>{jobStatusRu(j.status)}</Badge
></TableCell
>
<TableCell class="text-xs text-muted-foreground"
>{j.created_at ? new Date(j.created_at).toLocaleString('ru') : '—'}</TableCell
>
<TableCell class="max-w-xs truncate text-xs text-destructive"
>{j.error ?? ''}</TableCell
>
</TableRow>
{:else}
<TableRow>
<TableCell colspan={4} class="text-muted-foreground text-center py-6">
{loading ? 'Загрузка…' : 'Нет задач'}
</TableCell>
</TableRow>
{/each}
</TableBody>
</Table>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle class="text-base">Операции обновления модулей</CardTitle>
<CardDescription
>Отдельная лента задач обновления модулей для контроля по модулям.</CardDescription
>
</CardHeader>
<CardContent class="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>Статус</TableHead>
<TableHead>Создана</TableHead>
<TableHead>Ревизия</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{#each refreshJobs as j (j.job_id)}
<TableRow>
<TableCell
><Badge variant={jobStatusVariant(j.status)}>{jobStatusRu(j.status)}</Badge
></TableCell
>
<TableCell class="text-xs"
>{j.created_at ? new Date(j.created_at).toLocaleString('ru') : '—'}</TableCell
>
<TableCell class="font-mono text-xs"
>{typeof j.meta?.revision_id === 'string'
? `${j.meta.revision_id.slice(0, 12)}…`
: '—'}</TableCell
>
</TableRow>
{:else}
<TableRow>
<TableCell colspan={3} class="text-muted-foreground text-center py-6"
>Нет задач обновления модулей</TableCell
>
</TableRow>
{/each}
</TableBody>
</Table>
{:else if column.id === 'finished'}
<span class="text-xs whitespace-nowrap text-muted-foreground"
>{formatDateTime(j.finished_at)}</span
>
{:else if column.id === 'error'}
<span class="text-xs text-destructive">{truncateError(j.error)}</span>
{/if}
{/snippet}
</AppDataTable>
</TabsContent>
</Tabs>
</CardContent>
</Card>
</div>