Compare commits
3
Commits
53fd527a6d
...
1e77e1a2b8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1e77e1a2b8 | ||
|
|
f877ec89a7 | ||
|
|
2da6d49368 |
No files matched your search
@@ -16,6 +16,7 @@
|
|||||||
| [overview.md](overview.md) | Ключевые возможности системы |
|
| [overview.md](overview.md) | Ключевые возможности системы |
|
||||||
| [quickstart.md](quickstart.md) | Быстрый запуск: готовые образы из `git.shts.su`, Docker Compose, локальный Go, веб |
|
| [quickstart.md](quickstart.md) | Быстрый запуск: готовые образы из `git.shts.su`, Docker Compose, локальный Go, веб |
|
||||||
| [architecture.md](architecture.md) | Компоненты, потоки данных, пакеты |
|
| [architecture.md](architecture.md) | Компоненты, потоки данных, пакеты |
|
||||||
|
| [manual.md](manual.md) | Подробное руководство по модулям, процессам и API |
|
||||||
| [api.md](api.md) | REST: префикс `/v1`, публичные маршруты, ссылки на OpenAPI |
|
| [api.md](api.md) | REST: префикс `/v1`, публичные маршруты, ссылки на OpenAPI |
|
||||||
| [access.md](access.md) | Выдача доступа: API-ключи, роли, нода, CORS |
|
| [access.md](access.md) | Выдача доступа: API-ключи, роли, нода, CORS |
|
||||||
| [openapi.yaml](openapi.yaml) | Источник правды по контракту API |
|
| [openapi.yaml](openapi.yaml) | Источник правды по контракту API |
|
||||||
|
|||||||
+142
@@ -0,0 +1,142 @@
|
|||||||
|
# Полное руководство по EvoBGP
|
||||||
|
|
||||||
|
Этот документ объединяет эксплуатационное и разработческое описание системы: что делает каждый процесс, как двигаются данные, какие API использовать и где искать причины инцидентов.
|
||||||
|
|
||||||
|
## 1. Назначение системы
|
||||||
|
|
||||||
|
EvoBGP управляет генерацией и применением BGP-конфигураций на основе модулей источников префиксов (`AS_PREFIXES`, `CDN_CIDRS`, `DOMAINS`, `IP_RANGES`).
|
||||||
|
|
||||||
|
Система разделена на:
|
||||||
|
- **control plane**: API, БД, jobs, рендер ревизий, публикация и подписание бандлов;
|
||||||
|
- **data plane**: BIRD и связанный агент/нода для применения ревизий.
|
||||||
|
|
||||||
|
## 2. Компоненты и роли бинарников (`cmd/*`)
|
||||||
|
|
||||||
|
### `evobgp-api`
|
||||||
|
- Основной HTTP API.
|
||||||
|
- Поднимает маршруты из `internal/httpapi`.
|
||||||
|
- Работает с `store`/`repository`, jobs и аутентификацией.
|
||||||
|
|
||||||
|
### `evobgp-all`
|
||||||
|
- Монолитный режим: API + scheduler + ingest + render + deploy в одном процессе.
|
||||||
|
- Удобен для компактных окружений (`microvps`).
|
||||||
|
|
||||||
|
### `evobgp-scheduler`
|
||||||
|
- Периодически запускает `module_refresh` по расписанию/интервалам.
|
||||||
|
- В reference-профиле может стучаться в API и/или работать через store.
|
||||||
|
|
||||||
|
### `evobgp-ingest`
|
||||||
|
- Периодически делает prefetch внешних CDN-источников (ETag/доступность).
|
||||||
|
|
||||||
|
### `evobgp-render`
|
||||||
|
- Ведёт рендер-цикл; в режиме autopublish может назначать последнюю ревизию на спикеры.
|
||||||
|
|
||||||
|
### `evobgp-deploy`
|
||||||
|
- Диагностирует drift: различия между опубликованной и применённой ревизией.
|
||||||
|
|
||||||
|
### `evobgp-node`
|
||||||
|
- CLI-нода для edge: загрузка бандла, верификация подписи, применение.
|
||||||
|
|
||||||
|
### `evobgp-agent`
|
||||||
|
- Локальный агент рядом с BIRD (наблюдение и служебные операции).
|
||||||
|
|
||||||
|
## 3. Карта внутренних модулей (`internal/*`)
|
||||||
|
|
||||||
|
### API и доступ
|
||||||
|
- `internal/httpapi`: маршруты, auth, CORS, problem+json, CRUD, jobs endpoints.
|
||||||
|
|
||||||
|
### Данные
|
||||||
|
- `internal/store`: бизнес-контракты бэкенда.
|
||||||
|
- `internal/repository`: PostgreSQL-реализация.
|
||||||
|
- `internal/db`: коннект и миграции.
|
||||||
|
|
||||||
|
### Jobs/pipeline
|
||||||
|
- `internal/jobs`: очередь задач и worker.
|
||||||
|
- `internal/pipeline`: `module_refresh`, сбор источников, материализация, рендер-превью.
|
||||||
|
- `internal/scheduler`, `internal/ingest`, `internal/render`, `internal/deploy`: фоновые циклы.
|
||||||
|
|
||||||
|
### BIRD и бандлы
|
||||||
|
- `internal/birdfmt`: генерация конфигурации BIRD.
|
||||||
|
- `internal/birddeploy`: применение конфигурации и интеграция с `birdc`.
|
||||||
|
- `internal/bundle`, `internal/signing`: упаковка и криптографическая проверка.
|
||||||
|
|
||||||
|
### Наблюдаемость и служебные
|
||||||
|
- `internal/observability`: метрики/middleware.
|
||||||
|
- `internal/asnresolve`: внешние резолвы ASN.
|
||||||
|
- `internal/broker`: задел под внешний брокер.
|
||||||
|
- `internal/config`, `internal/platform`: параметры среды и платформенные адаптеры.
|
||||||
|
|
||||||
|
## 4. Сквозной поток данных
|
||||||
|
|
||||||
|
1. Оператор меняет данные модуля (CRUD источников, peers/speakers, настройки).
|
||||||
|
2. Включённый модуль триггерит `module_refresh`.
|
||||||
|
3. `jobs.Worker` вызывает `pipeline.RefreshModule`.
|
||||||
|
4. Pipeline собирает префиксы всех enabled-модулей tenant, строит materialized snapshot.
|
||||||
|
5. Создаётся ревизия и BIRD preview.
|
||||||
|
6. По операциям deploy/apply ревизия применяется на спикере.
|
||||||
|
7. Нода получает бандл, проверяет подпись, применяет локально.
|
||||||
|
|
||||||
|
## 5. API: ключевые группы и сценарии
|
||||||
|
|
||||||
|
Источник истины контракта: `docs/openapi.yaml`.
|
||||||
|
|
||||||
|
### Основные группы endpoint-ов
|
||||||
|
- `Modules`: модули и их общие параметры.
|
||||||
|
- `AS Entries`, `CDN Sources`, `Domain Entries`, `IP Range Entries`: источники префиксов.
|
||||||
|
- `Peers`, `Speakers`: сетевая топология применения.
|
||||||
|
- `Revisions`, `Deploy`, `Jobs`: жизненный цикл ревизий и фоновых задач.
|
||||||
|
- `Settings`: глобальные KV-настройки.
|
||||||
|
- `Node`: edge-флоу бандлов/enrollment.
|
||||||
|
|
||||||
|
### Типовой сценарий оператора
|
||||||
|
1. Создать/обновить модуль и его источники.
|
||||||
|
2. Дождаться или инициировать refresh.
|
||||||
|
3. Проверить ревизию и diff.
|
||||||
|
4. Выполнить apply на целевой спикер.
|
||||||
|
5. Проверить health/monitoring/jobs.
|
||||||
|
|
||||||
|
## 6. Настройки и доступ
|
||||||
|
|
||||||
|
### Аутентификация/роли
|
||||||
|
- Роли и правила доступа: `docs/access.md`.
|
||||||
|
- Для мутаций критичных сущностей требуется `editor`/`operator`.
|
||||||
|
|
||||||
|
### Настройки (`/v1/settings`)
|
||||||
|
- KV c ключами BIRD и дополнительными feature flags.
|
||||||
|
- Ключевые параметры BIRD: `bird_router_id`, `bird_local_ipv4`, `bird_local_ipv6`, `bird_local_asn`, `bird_bgp_source_ipv4`, `bird_bgp_source_ipv6`.
|
||||||
|
|
||||||
|
## 7. Эксплуатация и runbook
|
||||||
|
|
||||||
|
### Что проверять при инцидентах
|
||||||
|
1. Статус API и БД.
|
||||||
|
2. Состояние jobs (`module_refresh`, `deploy_apply`), ошибки в job meta.
|
||||||
|
3. Состояние внешних источников (CDN/DoH/ASN).
|
||||||
|
4. Состояние BIRD и применённой ревизии на спикере.
|
||||||
|
|
||||||
|
### Частые причины проблем
|
||||||
|
- Невалидные данные источников (непарсящийся JSON/plaintext для CDN).
|
||||||
|
- Неполные настройки BIRD.
|
||||||
|
- Ошибки внешних upstream (RIPEstat/DoH/CDN).
|
||||||
|
- Расхождение published/applied ревизии.
|
||||||
|
|
||||||
|
## 8. Разработка и расширение
|
||||||
|
|
||||||
|
### Где вносить изменения
|
||||||
|
- Новый endpoint: `internal/httpapi` + обновление `docs/openapi.yaml`.
|
||||||
|
- Новая логика источника: `internal/pipeline` + соответствующие CRUD/store/repository.
|
||||||
|
- Новая операция UI: `web/src/routes/*` и `web/src/lib/components/*`.
|
||||||
|
|
||||||
|
### Рекомендации по качеству
|
||||||
|
- Для изменений API всегда обновлять `docs/openapi.yaml`.
|
||||||
|
- Для изменений UI держаться единого набора компонентов shadcn-svelte и Lucide.
|
||||||
|
- Для pipeline-изменений добавлять метрики стадии и явные trigger-метки job.
|
||||||
|
|
||||||
|
## 9. Индекс исходников (быстрый вход)
|
||||||
|
|
||||||
|
- Архитектура: `docs/architecture.md`
|
||||||
|
- API обзор: `docs/api.md`
|
||||||
|
- Контракт API: `docs/openapi.yaml`
|
||||||
|
- Доступ/роли: `docs/access.md`
|
||||||
|
- Web запуск: `web/README.md`
|
||||||
|
- Compose: `deploy/compose/docker-compose.yaml`
|
||||||
|
|
||||||
@@ -254,11 +254,13 @@ func (s *Server) handlePostCDNSource(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid json")
|
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid json")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
x, err := s.store.CreateCDNSource(a.TenantID, r.PathValue("module_id"), &body)
|
mid := r.PathValue("module_id")
|
||||||
|
x, err := s.store.CreateCDNSource(a.TenantID, mid, &body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeStoreErr(w, err)
|
writeStoreErr(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
s.enqueueModuleRefreshIfEnabled(a.TenantID, mid, "cdn_source_create")
|
||||||
writeJSON(w, http.StatusCreated, cdnSourceJSON(x))
|
writeJSON(w, http.StatusCreated, cdnSourceJSON(x))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -272,11 +274,13 @@ func (s *Server) handlePatchCDNSource(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid json")
|
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid json")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
x, err := s.store.UpdateCDNSource(a.TenantID, r.PathValue("module_id"), r.PathValue("source_id"), &body)
|
mid := r.PathValue("module_id")
|
||||||
|
x, err := s.store.UpdateCDNSource(a.TenantID, mid, r.PathValue("source_id"), &body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeStoreErr(w, err)
|
writeStoreErr(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
s.enqueueModuleRefreshIfEnabled(a.TenantID, mid, "cdn_source_patch")
|
||||||
writeJSON(w, http.StatusOK, cdnSourceJSON(x))
|
writeJSON(w, http.StatusOK, cdnSourceJSON(x))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -285,10 +289,12 @@ func (s *Server) handleDeleteCDNSource(w http.ResponseWriter, r *http.Request) {
|
|||||||
if !ok || !s.requireAtLeast(w, a, "editor") {
|
if !ok || !s.requireAtLeast(w, a, "editor") {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := s.store.DeleteCDNSource(a.TenantID, r.PathValue("module_id"), r.PathValue("source_id")); err != nil {
|
mid := r.PathValue("module_id")
|
||||||
|
if err := s.store.DeleteCDNSource(a.TenantID, mid, r.PathValue("source_id")); err != nil {
|
||||||
writeStoreErr(w, err)
|
writeStoreErr(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
s.enqueueModuleRefreshIfEnabled(a.TenantID, mid, "cdn_source_delete")
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -344,11 +350,13 @@ func (s *Server) handlePostAS(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid json")
|
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid json")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
x, err := s.store.CreateASEntry(a.TenantID, r.PathValue("module_id"), &body)
|
mid := r.PathValue("module_id")
|
||||||
|
x, err := s.store.CreateASEntry(a.TenantID, mid, &body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeStoreErr(w, err)
|
writeStoreErr(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
s.enqueueModuleRefreshIfEnabled(a.TenantID, mid, "as_entry_create")
|
||||||
writeJSON(w, http.StatusCreated, asEntryJSON(x))
|
writeJSON(w, http.StatusCreated, asEntryJSON(x))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -362,11 +370,13 @@ func (s *Server) handlePatchAS(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid json")
|
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid json")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
x, err := s.store.UpdateASEntry(a.TenantID, r.PathValue("module_id"), r.PathValue("entry_id"), &body)
|
mid := r.PathValue("module_id")
|
||||||
|
x, err := s.store.UpdateASEntry(a.TenantID, mid, r.PathValue("entry_id"), &body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeStoreErr(w, err)
|
writeStoreErr(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
s.enqueueModuleRefreshIfEnabled(a.TenantID, mid, "as_entry_patch")
|
||||||
writeJSON(w, http.StatusOK, asEntryJSON(x))
|
writeJSON(w, http.StatusOK, asEntryJSON(x))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -375,10 +385,12 @@ func (s *Server) handleDeleteAS(w http.ResponseWriter, r *http.Request) {
|
|||||||
if !ok || !s.requireAtLeast(w, a, "editor") {
|
if !ok || !s.requireAtLeast(w, a, "editor") {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := s.store.DeleteASEntry(a.TenantID, r.PathValue("module_id"), r.PathValue("entry_id")); err != nil {
|
mid := r.PathValue("module_id")
|
||||||
|
if err := s.store.DeleteASEntry(a.TenantID, mid, r.PathValue("entry_id")); err != nil {
|
||||||
writeStoreErr(w, err)
|
writeStoreErr(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
s.enqueueModuleRefreshIfEnabled(a.TenantID, mid, "as_entry_delete")
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -419,11 +431,13 @@ func (s *Server) handlePostDomain(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid json")
|
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid json")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
x, err := s.store.CreateDomainEntry(a.TenantID, r.PathValue("module_id"), &body)
|
mid := r.PathValue("module_id")
|
||||||
|
x, err := s.store.CreateDomainEntry(a.TenantID, mid, &body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeStoreErr(w, err)
|
writeStoreErr(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
s.enqueueModuleRefreshIfEnabled(a.TenantID, mid, "domain_entry_create")
|
||||||
writeJSON(w, http.StatusCreated, domainEntryJSON(x))
|
writeJSON(w, http.StatusCreated, domainEntryJSON(x))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -437,11 +451,13 @@ func (s *Server) handlePatchDomain(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid json")
|
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid json")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
x, err := s.store.UpdateDomainEntry(a.TenantID, r.PathValue("module_id"), r.PathValue("entry_id"), &body)
|
mid := r.PathValue("module_id")
|
||||||
|
x, err := s.store.UpdateDomainEntry(a.TenantID, mid, r.PathValue("entry_id"), &body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeStoreErr(w, err)
|
writeStoreErr(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
s.enqueueModuleRefreshIfEnabled(a.TenantID, mid, "domain_entry_patch")
|
||||||
writeJSON(w, http.StatusOK, domainEntryJSON(x))
|
writeJSON(w, http.StatusOK, domainEntryJSON(x))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -450,10 +466,12 @@ func (s *Server) handleDeleteDomain(w http.ResponseWriter, r *http.Request) {
|
|||||||
if !ok || !s.requireAtLeast(w, a, "editor") {
|
if !ok || !s.requireAtLeast(w, a, "editor") {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := s.store.DeleteDomainEntry(a.TenantID, r.PathValue("module_id"), r.PathValue("entry_id")); err != nil {
|
mid := r.PathValue("module_id")
|
||||||
|
if err := s.store.DeleteDomainEntry(a.TenantID, mid, r.PathValue("entry_id")); err != nil {
|
||||||
writeStoreErr(w, err)
|
writeStoreErr(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
s.enqueueModuleRefreshIfEnabled(a.TenantID, mid, "domain_entry_delete")
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -713,6 +731,9 @@ func (s *Server) handleImportModuleEntriesCSV(w http.ResponseWriter, r *http.Req
|
|||||||
}
|
}
|
||||||
imported++
|
imported++
|
||||||
}
|
}
|
||||||
|
if imported > 0 {
|
||||||
|
s.enqueueModuleRefreshIfEnabled(a.TenantID, moduleID, "as_entry_import_csv")
|
||||||
|
}
|
||||||
case "DOMAINS":
|
case "DOMAINS":
|
||||||
for i := start; i < len(rows); i++ {
|
for i := start; i < len(rows); i++ {
|
||||||
rec := rows[i]
|
rec := rows[i]
|
||||||
@@ -740,6 +761,9 @@ func (s *Server) handleImportModuleEntriesCSV(w http.ResponseWriter, r *http.Req
|
|||||||
}
|
}
|
||||||
imported++
|
imported++
|
||||||
}
|
}
|
||||||
|
if imported > 0 {
|
||||||
|
s.enqueueModuleRefreshIfEnabled(a.TenantID, moduleID, "domain_entry_import_csv")
|
||||||
|
}
|
||||||
case "IP_RANGES":
|
case "IP_RANGES":
|
||||||
for i := start; i < len(rows); i++ {
|
for i := start; i < len(rows); i++ {
|
||||||
rec := rows[i]
|
rec := rows[i]
|
||||||
|
|||||||
@@ -14,6 +14,12 @@
|
|||||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '$lib/components/ui/card/index.js';
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '$lib/components/ui/card/index.js';
|
||||||
import { Input } from '$lib/components/ui/input/index.js';
|
import { Input } from '$lib/components/ui/input/index.js';
|
||||||
import { Label } from '$lib/components/ui/label/index.js';
|
import { Label } from '$lib/components/ui/label/index.js';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger
|
||||||
|
} from '$lib/components/ui/select/index.js';
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '$lib/components/ui/tabs/index.js';
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '$lib/components/ui/tabs/index.js';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -73,6 +79,12 @@
|
|||||||
let speakerForm = $state<BgpSpeakerCreate>({ endpoint: '', role: 'operator' });
|
let speakerForm = $state<BgpSpeakerCreate>({ endpoint: '', role: 'operator' });
|
||||||
let speakerSaving = $state(false);
|
let speakerSaving = $state(false);
|
||||||
let applyingId = $state<string | null>(null);
|
let applyingId = $state<string | null>(null);
|
||||||
|
const speakerById = $derived.by(() => new Map(speakers.map((s) => [s.id, s])));
|
||||||
|
|
||||||
|
function speakerLabelById(id: string | null | undefined) {
|
||||||
|
if (!id) return '—';
|
||||||
|
return speakerById.get(id)?.endpoint ?? id;
|
||||||
|
}
|
||||||
|
|
||||||
async function loadAll() {
|
async function loadAll() {
|
||||||
peersLoading = true;
|
peersLoading = true;
|
||||||
@@ -123,8 +135,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
async function savePeer() {
|
async function savePeer() {
|
||||||
if (!peerForm.neighbor || !peerForm.remote_asn) {
|
if (!peerForm.neighbor.trim()) {
|
||||||
toast.error('Укажите адрес и ASN');
|
toast.error('Укажите адрес соседа');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!peerForm.remote_asn || peerForm.remote_asn <= 0) {
|
||||||
|
toast.error('Remote ASN должен быть больше 0');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
peerSaving = true;
|
peerSaving = true;
|
||||||
@@ -271,7 +287,7 @@
|
|||||||
<TableCell>
|
<TableCell>
|
||||||
<Badge variant={sessionBadge(p.session_state)}>{p.session_state || '—'}</Badge>
|
<Badge variant={sessionBadge(p.session_state)}>{p.session_state || '—'}</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell class="text-muted-foreground font-mono text-xs">{p.bgp_speaker_id ? p.bgp_speaker_id.slice(0, 8) + '…' : '—'}</TableCell>
|
<TableCell class="text-muted-foreground text-xs">{speakerLabelById(p.bgp_speaker_id)}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div class="flex gap-1">
|
<div class="flex gap-1">
|
||||||
<Button variant="ghost" size="icon-sm" onclick={() => openPeerEdit(p)}><Pencil class="size-3.5" /></Button>
|
<Button variant="ghost" size="icon-sm" onclick={() => openPeerEdit(p)}><Pencil class="size-3.5" /></Button>
|
||||||
@@ -325,7 +341,13 @@
|
|||||||
<TableCell class="font-mono text-xs text-muted-foreground">{s.last_applied_revision_id ? s.last_applied_revision_id.slice(0, 8) + '…' : '—'}</TableCell>
|
<TableCell class="font-mono text-xs text-muted-foreground">{s.last_applied_revision_id ? s.last_applied_revision_id.slice(0, 8) + '…' : '—'}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div class="flex gap-1">
|
<div class="flex gap-1">
|
||||||
<Button variant="outline" size="xs" onclick={() => applySpeaker(s.id)} disabled={applyingId === s.id}>
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="xs"
|
||||||
|
title="Запустить применение ревизии на спикере"
|
||||||
|
onclick={() => applySpeaker(s.id)}
|
||||||
|
disabled={applyingId === s.id}
|
||||||
|
>
|
||||||
<Play class="size-3" />
|
<Play class="size-3" />
|
||||||
{applyingId === s.id ? 'Apply…' : 'Apply'}
|
{applyingId === s.id ? 'Apply…' : 'Apply'}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -365,8 +387,26 @@
|
|||||||
<Input id="p-asn" type="number" placeholder="65000" bind:value={peerForm.remote_asn} />
|
<Input id="p-asn" type="number" placeholder="65000" bind:value={peerForm.remote_asn} />
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-1.5">
|
<div class="space-y-1.5">
|
||||||
<Label for="p-speaker">Спикер (ID, опционально)</Label>
|
<Label for="p-speaker">Спикер (опционально)</Label>
|
||||||
<Input id="p-speaker" placeholder="speaker-id" bind:value={peerForm.bgp_speaker_id} />
|
<Select
|
||||||
|
type="single"
|
||||||
|
value={peerForm.bgp_speaker_id ?? ''}
|
||||||
|
onValueChange={(v) => {
|
||||||
|
peerForm = { ...peerForm, bgp_speaker_id: v || null };
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="p-speaker" class="w-full">
|
||||||
|
{peerForm.bgp_speaker_id
|
||||||
|
? speakerLabelById(peerForm.bgp_speaker_id)
|
||||||
|
: 'Не выбрано'}
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="">Не выбрано</SelectItem>
|
||||||
|
{#each speakers as s (s.id)}
|
||||||
|
<SelectItem value={s.id}>{s.endpoint} ({s.id.slice(0, 8)}…)</SelectItem>
|
||||||
|
{/each}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="border-border bg-muted/30 flex flex-row items-center justify-between gap-4 rounded-lg border p-3"
|
class="border-border bg-muted/30 flex flex-row items-center justify-between gap-4 rounded-lg border p-3"
|
||||||
|
|||||||
@@ -8,22 +8,163 @@
|
|||||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '$lib/components/ui/card/index.js';
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '$lib/components/ui/card/index.js';
|
||||||
import { Input } from '$lib/components/ui/input/index.js';
|
import { Input } from '$lib/components/ui/input/index.js';
|
||||||
import { Label } from '$lib/components/ui/label/index.js';
|
import { Label } from '$lib/components/ui/label/index.js';
|
||||||
import { Textarea } from '$lib/components/ui/textarea/index.js';
|
|
||||||
import { toast } from 'svelte-sonner';
|
import { toast } from 'svelte-sonner';
|
||||||
import Save from '@lucide/svelte/icons/save';
|
import Save from '@lucide/svelte/icons/save';
|
||||||
|
import Plus from '@lucide/svelte/icons/plus';
|
||||||
import SettingsIcon from '@lucide/svelte/icons/settings';
|
import SettingsIcon from '@lucide/svelte/icons/settings';
|
||||||
|
import Trash2 from '@lucide/svelte/icons/trash-2';
|
||||||
|
|
||||||
let token = $state('');
|
let token = $state('');
|
||||||
let apiSettings = $state<AppSettings | null>(null);
|
let apiSettings = $state<AppSettings | null>(null);
|
||||||
let settingsJson = $state('');
|
|
||||||
let loadingSettings = $state(false);
|
let loadingSettings = $state(false);
|
||||||
let savingSettings = $state(false);
|
let savingSettings = $state(false);
|
||||||
|
let knownFields = $state({
|
||||||
$effect(() => {
|
bird_router_id: '',
|
||||||
if (browser) {
|
bird_local_ipv4: '',
|
||||||
token = localStorage.getItem(TOKEN_STORAGE_KEY) ?? '';
|
bird_local_ipv6: '',
|
||||||
}
|
bird_local_asn: '',
|
||||||
|
bird_bgp_source_ipv4: '',
|
||||||
|
bird_bgp_source_ipv6: ''
|
||||||
});
|
});
|
||||||
|
let additionalSettings = $state<Array<{ id: number; key: string; value: string }>>([]);
|
||||||
|
let additionalIdCounter = $state(1);
|
||||||
|
|
||||||
|
type KnownFieldKey =
|
||||||
|
| 'bird_router_id'
|
||||||
|
| 'bird_local_ipv4'
|
||||||
|
| 'bird_local_ipv6'
|
||||||
|
| 'bird_local_asn'
|
||||||
|
| 'bird_bgp_source_ipv4'
|
||||||
|
| 'bird_bgp_source_ipv6';
|
||||||
|
|
||||||
|
const knownFieldKeys: KnownFieldKey[] = [
|
||||||
|
'bird_router_id',
|
||||||
|
'bird_local_ipv4',
|
||||||
|
'bird_local_ipv6',
|
||||||
|
'bird_local_asn',
|
||||||
|
'bird_bgp_source_ipv4',
|
||||||
|
'bird_bgp_source_ipv6'
|
||||||
|
];
|
||||||
|
|
||||||
|
function isValidIPv4(value: string): boolean {
|
||||||
|
const parts = value.split('.');
|
||||||
|
if (parts.length !== 4) return false;
|
||||||
|
for (const part of parts) {
|
||||||
|
if (!/^\d{1,3}$/.test(part)) return false;
|
||||||
|
if (part.length > 1 && part.startsWith('0')) return false;
|
||||||
|
const n = Number(part);
|
||||||
|
if (!Number.isInteger(n) || n < 0 || n > 255) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidIPv6(value: string): boolean {
|
||||||
|
if (!/^[0-9A-Fa-f:.]+$/.test(value)) return false;
|
||||||
|
if ((value.match(/::/g) ?? []).length > 1) return false;
|
||||||
|
|
||||||
|
const hasCompression = value.includes('::');
|
||||||
|
const [leftRaw, rightRaw = ''] = value.split('::');
|
||||||
|
const left = leftRaw === '' ? [] : leftRaw.split(':');
|
||||||
|
const right = rightRaw === '' ? [] : rightRaw.split(':');
|
||||||
|
|
||||||
|
if (left.some((part) => part === '') || right.some((part) => part === '')) return false;
|
||||||
|
|
||||||
|
let segments = [...left, ...right];
|
||||||
|
let ipv4TailSegments = 0;
|
||||||
|
const lastSegment = segments.at(-1);
|
||||||
|
if (lastSegment && lastSegment.includes('.')) {
|
||||||
|
if (!isValidIPv4(lastSegment)) return false;
|
||||||
|
segments = segments.slice(0, -1);
|
||||||
|
ipv4TailSegments = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const segment of segments) {
|
||||||
|
if (!/^[0-9A-Fa-f]{1,4}$/.test(segment)) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalSegments = segments.length + ipv4TailSegments;
|
||||||
|
if (hasCompression) return totalSegments < 8;
|
||||||
|
return totalSegments === 8;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPositiveInt(value: string): boolean {
|
||||||
|
return /^[1-9]\d*$/.test(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
let knownFieldErrors = $derived.by(() => {
|
||||||
|
const errors: Record<KnownFieldKey, string> = {
|
||||||
|
bird_router_id: '',
|
||||||
|
bird_local_ipv4: '',
|
||||||
|
bird_local_ipv6: '',
|
||||||
|
bird_local_asn: '',
|
||||||
|
bird_bgp_source_ipv4: '',
|
||||||
|
bird_bgp_source_ipv6: ''
|
||||||
|
};
|
||||||
|
|
||||||
|
const routerId = knownFields.bird_router_id.trim();
|
||||||
|
if (routerId && !isValidIPv4(routerId)) errors.bird_router_id = 'Введите корректный IPv4 адрес';
|
||||||
|
|
||||||
|
const localV4 = knownFields.bird_local_ipv4.trim();
|
||||||
|
if (localV4 && !isValidIPv4(localV4)) errors.bird_local_ipv4 = 'Введите корректный IPv4 адрес';
|
||||||
|
|
||||||
|
const localV6 = knownFields.bird_local_ipv6.trim();
|
||||||
|
if (localV6 && !isValidIPv6(localV6)) errors.bird_local_ipv6 = 'Введите корректный IPv6 адрес';
|
||||||
|
|
||||||
|
const asn = knownFields.bird_local_asn.trim();
|
||||||
|
if (asn && !isPositiveInt(asn)) errors.bird_local_asn = 'ASN должен быть целым числом больше 0';
|
||||||
|
|
||||||
|
const bgpV4 = knownFields.bird_bgp_source_ipv4.trim();
|
||||||
|
if (bgpV4 && !isValidIPv4(bgpV4)) errors.bird_bgp_source_ipv4 = 'Введите корректный IPv4 адрес';
|
||||||
|
|
||||||
|
const bgpV6 = knownFields.bird_bgp_source_ipv6.trim();
|
||||||
|
if (bgpV6 && !isValidIPv6(bgpV6)) errors.bird_bgp_source_ipv6 = 'Введите корректный IPv6 адрес';
|
||||||
|
|
||||||
|
return errors;
|
||||||
|
});
|
||||||
|
|
||||||
|
let hasValidationErrors = $derived(
|
||||||
|
knownFieldKeys.some((key) => Boolean(knownFieldErrors[key]))
|
||||||
|
);
|
||||||
|
|
||||||
|
function addAdditionalSetting() {
|
||||||
|
additionalSettings.push({ id: additionalIdCounter++, key: '', value: '' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeAdditionalSetting(id: number) {
|
||||||
|
additionalSettings = additionalSettings.filter((entry) => entry.id !== id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetFormFromApi(settings: AppSettings) {
|
||||||
|
const parsedKnown: Record<KnownFieldKey, string> = {
|
||||||
|
bird_router_id: '',
|
||||||
|
bird_local_ipv4: '',
|
||||||
|
bird_local_ipv6: '',
|
||||||
|
bird_local_asn: '',
|
||||||
|
bird_bgp_source_ipv4: '',
|
||||||
|
bird_bgp_source_ipv6: ''
|
||||||
|
};
|
||||||
|
const parsedAdditional: Array<{ id: number; key: string; value: string }> = [];
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(settings as Record<string, unknown>)) {
|
||||||
|
if (knownFieldKeys.includes(key as KnownFieldKey)) {
|
||||||
|
if (key === 'bird_local_asn') {
|
||||||
|
if (typeof value === 'number' && Number.isFinite(value)) parsedKnown.bird_local_asn = String(value);
|
||||||
|
else if (typeof value === 'string') parsedKnown.bird_local_asn = value;
|
||||||
|
} else if (typeof value === 'string') {
|
||||||
|
parsedKnown[key as KnownFieldKey] = value;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
parsedAdditional.push({
|
||||||
|
id: additionalIdCounter++,
|
||||||
|
key,
|
||||||
|
value: typeof value === 'string' ? value : String(value)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
knownFields = parsedKnown;
|
||||||
|
additionalSettings = parsedAdditional;
|
||||||
|
}
|
||||||
|
|
||||||
function saveToken() {
|
function saveToken() {
|
||||||
if (!browser) return;
|
if (!browser) return;
|
||||||
@@ -38,7 +179,7 @@
|
|||||||
try {
|
try {
|
||||||
const s = await apiJSON<AppSettings>('/v1/settings');
|
const s = await apiJSON<AppSettings>('/v1/settings');
|
||||||
apiSettings = s;
|
apiSettings = s;
|
||||||
settingsJson = JSON.stringify(s, null, 2);
|
resetFormFromApi(s);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error(e instanceof Error ? e.message : String(e));
|
toast.error(e instanceof Error ? e.message : String(e));
|
||||||
} finally {
|
} finally {
|
||||||
@@ -46,11 +187,37 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let canSaveSettings = $derived.by(() => {
|
||||||
|
if (loadingSettings || savingSettings || hasValidationErrors) return false;
|
||||||
|
|
||||||
|
const hasKnownValues = knownFieldKeys.some((key) => {
|
||||||
|
const value = knownFields[key].trim();
|
||||||
|
return value !== '' && !knownFieldErrors[key];
|
||||||
|
});
|
||||||
|
const hasAdditionalValues = additionalSettings.some((entry) => entry.key.trim() !== '');
|
||||||
|
|
||||||
|
return hasKnownValues || hasAdditionalValues;
|
||||||
|
});
|
||||||
|
|
||||||
async function saveApiSettings() {
|
async function saveApiSettings() {
|
||||||
|
if (!canSaveSettings) return;
|
||||||
|
|
||||||
|
const payload: Record<string, string | number> = {};
|
||||||
|
for (const key of knownFieldKeys) {
|
||||||
|
const value = knownFields[key].trim();
|
||||||
|
if (!value || knownFieldErrors[key]) continue;
|
||||||
|
if (key === 'bird_local_asn') payload[key] = Number(value);
|
||||||
|
else payload[key] = value;
|
||||||
|
}
|
||||||
|
for (const entry of additionalSettings) {
|
||||||
|
const key = entry.key.trim();
|
||||||
|
if (!key) continue;
|
||||||
|
payload[key] = entry.value;
|
||||||
|
}
|
||||||
|
|
||||||
savingSettings = true;
|
savingSettings = true;
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(settingsJson);
|
await apiMutate('/v1/settings', 'PATCH', payload);
|
||||||
await apiMutate('/v1/settings', 'PATCH', parsed);
|
|
||||||
toast.success('Настройки сохранены');
|
toast.success('Настройки сохранены');
|
||||||
await loadApiSettings();
|
await loadApiSettings();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -60,7 +227,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(loadApiSettings);
|
onMount(() => {
|
||||||
|
if (browser) {
|
||||||
|
token = localStorage.getItem(TOKEN_STORAGE_KEY) ?? '';
|
||||||
|
}
|
||||||
|
void loadApiSettings();
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="mx-auto max-w-2xl space-y-6">
|
<div class="mx-auto max-w-2xl space-y-6">
|
||||||
@@ -105,33 +277,113 @@
|
|||||||
<CardTitle class="text-base">Настройки системы (API)</CardTitle>
|
<CardTitle class="text-base">Настройки системы (API)</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
<code class="text-xs">GET/PATCH /v1/settings</code> — глобальные параметры control plane (хранятся в БД).
|
<code class="text-xs">GET/PATCH /v1/settings</code> — глобальные параметры control plane (хранятся в БД).
|
||||||
Требуется роль operator. Для BIRD, например:
|
Требуется роль operator.
|
||||||
<code class="bg-muted rounded px-1 py-0.5 text-xs">bird_local_ipv4</code>,
|
|
||||||
<code class="bg-muted rounded px-1 py-0.5 text-xs">bird_bgp_source_ipv4</code> (опционально — задаёт BIRD
|
|
||||||
<code class="text-xs">router id</code>).
|
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent class="space-y-4">
|
<CardContent class="space-y-4">
|
||||||
{#if loadingSettings}
|
{#if loadingSettings}
|
||||||
<p class="text-muted-foreground text-sm">Загрузка…</p>
|
<p class="text-muted-foreground text-sm">Загрузка…</p>
|
||||||
{:else if apiSettings !== null}
|
{:else if apiSettings !== null}
|
||||||
<div class="space-y-2">
|
<div class="space-y-5">
|
||||||
<Label for="settings-json">JSON настроек</Label>
|
<div class="space-y-3">
|
||||||
<p class="text-muted-foreground text-xs leading-relaxed">
|
<h3 class="text-sm font-medium">Параметры BIRD</h3>
|
||||||
Должен быть строго валидный JSON: ключи и строки в двойных кавычках, без точки с запятой. Пример:
|
|
||||||
<code class="bg-muted mt-1 block rounded px-2 py-1 font-mono"
|
<div class="space-y-2">
|
||||||
>{`{"bird_router_id": "203.0.113.1", "bird_local_asn": 65001}`}</code
|
<Label for="bird-router-id">Router ID (bird_router_id)</Label>
|
||||||
>
|
<Input id="bird-router-id" bind:value={knownFields.bird_router_id} placeholder="203.0.113.1" />
|
||||||
</p>
|
{#if knownFieldErrors.bird_router_id}
|
||||||
<Textarea
|
<p class="text-sm text-red-600">{knownFieldErrors.bird_router_id}</p>
|
||||||
id="settings-json"
|
{/if}
|
||||||
bind:value={settingsJson}
|
</div>
|
||||||
rows={12}
|
|
||||||
class="font-mono text-xs"
|
<div class="space-y-2">
|
||||||
spellcheck={false}
|
<Label for="bird-local-ipv4">Локальный IPv4 (bird_local_ipv4)</Label>
|
||||||
/>
|
<Input id="bird-local-ipv4" bind:value={knownFields.bird_local_ipv4} placeholder="198.51.100.10" />
|
||||||
|
{#if knownFieldErrors.bird_local_ipv4}
|
||||||
|
<p class="text-sm text-red-600">{knownFieldErrors.bird_local_ipv4}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="bird-local-ipv6">Локальный IPv6 (bird_local_ipv6)</Label>
|
||||||
|
<Input id="bird-local-ipv6" bind:value={knownFields.bird_local_ipv6} placeholder="2001:db8::10" />
|
||||||
|
{#if knownFieldErrors.bird_local_ipv6}
|
||||||
|
<p class="text-sm text-red-600">{knownFieldErrors.bird_local_ipv6}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="bird-local-asn">Локальный ASN (bird_local_asn)</Label>
|
||||||
|
<Input id="bird-local-asn" type="number" min="1" bind:value={knownFields.bird_local_asn} placeholder="65001" />
|
||||||
|
{#if knownFieldErrors.bird_local_asn}
|
||||||
|
<p class="text-sm text-red-600">{knownFieldErrors.bird_local_asn}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="bird-bgp-source-ipv4">BGP source IPv4 (bird_bgp_source_ipv4)</Label>
|
||||||
|
<Input
|
||||||
|
id="bird-bgp-source-ipv4"
|
||||||
|
bind:value={knownFields.bird_bgp_source_ipv4}
|
||||||
|
placeholder="198.51.100.11"
|
||||||
|
/>
|
||||||
|
{#if knownFieldErrors.bird_bgp_source_ipv4}
|
||||||
|
<p class="text-sm text-red-600">{knownFieldErrors.bird_bgp_source_ipv4}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="bird-bgp-source-ipv6">BGP source IPv6 (bird_bgp_source_ipv6)</Label>
|
||||||
|
<Input
|
||||||
|
id="bird-bgp-source-ipv6"
|
||||||
|
bind:value={knownFields.bird_bgp_source_ipv6}
|
||||||
|
placeholder="2001:db8::11"
|
||||||
|
/>
|
||||||
|
{#if knownFieldErrors.bird_bgp_source_ipv6}
|
||||||
|
<p class="text-sm text-red-600">{knownFieldErrors.bird_bgp_source_ipv6}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h3 class="text-sm font-medium">Дополнительные настройки (KV)</h3>
|
||||||
|
<Button variant="outline" size="sm" onclick={addAdditionalSetting}>
|
||||||
|
<Plus class="size-4" />
|
||||||
|
Добавить строку
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if additionalSettings.length === 0}
|
||||||
|
<p class="text-muted-foreground text-sm">Нет дополнительных параметров.</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
{#each additionalSettings as entry (entry.id)}
|
||||||
|
<div class="grid grid-cols-1 gap-2 md:grid-cols-[1fr_1fr_auto]">
|
||||||
|
<Input bind:value={entry.key} placeholder="Ключ (например, bird_log_level)" />
|
||||||
|
<Input bind:value={entry.value} placeholder="Значение (строка)" />
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
aria-label="Удалить строку"
|
||||||
|
onclick={() => removeAdditionalSetting(entry.id)}
|
||||||
|
>
|
||||||
|
<Trash2 class="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button onclick={saveApiSettings} disabled={savingSettings}>
|
|
||||||
|
{#if hasValidationErrors}
|
||||||
|
<p class="text-sm text-red-600">
|
||||||
|
Есть ошибки в полях. Исправьте их, чтобы сохранить изменения.
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<Button onclick={saveApiSettings} disabled={!canSaveSettings}>
|
||||||
<Save />
|
<Save />
|
||||||
{savingSettings ? 'Сохранение…' : 'Применить настройки'}
|
{savingSettings ? 'Сохранение…' : 'Применить настройки'}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
Reference in new issue
Block a user