Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
27864fad58 | ||
|
|
dc5e777d07 | ||
|
|
23910c3393 | ||
|
|
0e90bbee4e | ||
|
|
55bc87cbc4 | ||
|
|
e68f034966 | ||
|
|
e06ee880b2 | ||
|
|
33fe8fdd18 | ||
|
|
6fa693156d | ||
|
|
5858d0889e |
@@ -15,7 +15,7 @@ alwaysApply: true
|
||||
|
||||
**ARCH-01** | MUST | Новая persistence-логика — метод `store.Backend` + реализации в `repository` и `store.Memory`; SQL не в `httpapi`.
|
||||
*Rationale:* единая абстракция данных.
|
||||
*Проверка:* grep SQL в `internal/httpapi` — отсутствие; review.
|
||||
*Проверка:* CI `scripts/lint-httpapi.sh`; grep SQL в `internal/httpapi` — отсутствие.
|
||||
|
||||
**ARCH-02** | MUST | HTTP-маршруты только в `internal/httpapi`; регистрация через `http.ServeMux` с паттернами `METHOD /v1/...`.
|
||||
*Rationale:* один слой REST.
|
||||
@@ -75,7 +75,7 @@ alwaysApply: true
|
||||
|
||||
**STYLE-05** | MUST | HTTP-ошибки — `writeProblem` / `writeJSON` (`application/problem+json` для 4xx/5xx).
|
||||
*Rationale:* RFC 9457, OpenAPI.
|
||||
*Проверка:* `problem.go`.
|
||||
*Проверка:* `problem.go`; CI `scripts/lint-httpapi.sh` (5xx и 4xx store/cdn/csv).
|
||||
|
||||
**STYLE-06** | MUST | JSON полей HTTP DTO согласованы с `docs/openapi.yaml`.
|
||||
*Rationale:* контракт API.
|
||||
@@ -123,7 +123,7 @@ alwaysApply: true
|
||||
**TEST-03** | MUST | Новые BIRD-сценарии в `internal/birdfmt/testdata/scenarios/*/bird.conf` + `bird -p`.
|
||||
*Проверка:* CI job `bird2`.
|
||||
|
||||
**TEST-04** | MUST | Изменения `web/` — локально `npm run check` и `npm run lint` (CI web пока не в scope).
|
||||
**TEST-04** | MUST | Изменения `web/` — локально `npm run check` и `npm run lint`; CI job `web` в `.gitea/workflows/ci.yaml`.
|
||||
*Проверка:* локальные команды.
|
||||
|
||||
**TEST-05** | MUST | Изменения OpenAPI — `npx @redocly/cli lint docs/openapi.yaml`.
|
||||
@@ -234,7 +234,9 @@ npx @redocly/cli lint docs/openapi.yaml
|
||||
# birdfmt: go test ./internal/birdfmt/... -count=1
|
||||
```
|
||||
|
||||
**Рекомендуется (частично внедрено):** CI job `web` (Gitea); `scripts/lint-httpapi.sh` в job `go`; `.golangci.yml` (локально); pre-commit gofmt/prettier.
|
||||
**CI (Gitea):** job `web` (check + lint); job `go`: `go vet`, `scripts/lint-httpapi.sh` (ARCH-01, ERR-01), `scripts/check-migrations-pair.sh` (DEP-03), `golangci-lint`, `go test -race`, build `cmd/*`.
|
||||
|
||||
**Рекомендуется локально:** `.golangci.yml`; `.pre-commit-config.yaml` (gofmt + prettier web).
|
||||
|
||||
**Только code review:** слои SQL; роли; idempotency; OpenAPI bodies; secrets в compose.
|
||||
|
||||
|
||||
@@ -88,6 +88,7 @@ jobs:
|
||||
deploy/docker/evobgp-web/*) docker_web=true ;;
|
||||
deploy/docker/bird2/*) docker_bird=true ;;
|
||||
go.mod|go.sum|go.work) go=true ;;
|
||||
migrations/*) go=true ;;
|
||||
*.go) go=true ;;
|
||||
cmd/*|internal/*) go=true ;;
|
||||
esac
|
||||
@@ -157,6 +158,12 @@ jobs:
|
||||
run: go vet ./...
|
||||
- name: Lint httpapi (ERR-01 / ARCH-01)
|
||||
run: sh scripts/lint-httpapi.sh
|
||||
- name: Check migration pairs (DEP-03)
|
||||
run: sh scripts/check-migrations-pair.sh
|
||||
- name: golangci-lint
|
||||
uses: golangci/golangci-lint-action@v6
|
||||
with:
|
||||
version: v1.62
|
||||
- name: Test
|
||||
run: go test ./... -race -count=1
|
||||
- name: Build all commands
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
# Local hooks (optional): install with `pre-commit install`
|
||||
repos:
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v5.0.0
|
||||
hooks:
|
||||
- id: trailing-whitespace
|
||||
- id: end-of-file-fixer
|
||||
- repo: https://github.com/dnephin/pre-commit-golang
|
||||
rev: v0.5.1
|
||||
hooks:
|
||||
- id: go-fmt
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: prettier-web
|
||||
name: prettier (web)
|
||||
entry: bash -c 'cd web && npx prettier --check .'
|
||||
language: system
|
||||
files: ^web/
|
||||
pass_filenames: false
|
||||
@@ -96,7 +96,7 @@ services:
|
||||
<<: *env-ref
|
||||
EVOBGP_HTTP_ADDR: ":8080"
|
||||
EVOBGP_SEED_DEMO: "1"
|
||||
# Local reference only: allows Bearer dev for scheduler HTTP client (EVOBGP_SCHEDULER_BEARER).
|
||||
# DEV ONLY — не для production. Bearer dev + слабые demo-секреты (см. docs/access.md).
|
||||
EVOBGP_DEV_INSECURE: "1"
|
||||
EVOBGP_BIRDC_SOCKET: /run/bird/bird.ctl
|
||||
EVOBGP_BIRDC_INTERVAL: 30s
|
||||
|
||||
@@ -135,6 +135,7 @@ services:
|
||||
EVOBGP_BIRDC_INTERVAL: 30s
|
||||
EVOBGP_BIRD_ACTIVE_DIR: /etc/bird
|
||||
EVOBGP_BIRD_STAGING_DIR: /tmp/evobgp-bird-staging
|
||||
# DEV ONLY — не для production (см. docs/access.md).
|
||||
EVOBGP_DEV_INSECURE: "1"
|
||||
volumes:
|
||||
- bird_etc:/etc/bird
|
||||
|
||||
+6
-1
@@ -37,7 +37,12 @@ opkey|01ARZ3NDEKTSV4RRFFQ69G5FAV|operator,nodekey|01ARZ3NDEKTSV4RRFFQ69G5FAV|nod
|
||||
|
||||
Если установлено `EVOBGP_DEV_INSECURE=1` и в store доступен демо-tenant (`DemoIDs`), то запрос с заголовком **`Authorization: Bearer dev`** получает контекст **`operator`** для этого tenant.
|
||||
|
||||
**Запрещено** в продакшене: любой, кто знает заголовок, получает полные права оператора на демо-данные.
|
||||
**Запрещено** в продакшене: любой, кто знает заголовок, получает полные права оператора на демо-данные. В reference Compose (`deploy/compose/docker-compose.yaml`) флаг включён только для локальной разработки.
|
||||
|
||||
### Синхронные «тяжёлые» GET (control plane)
|
||||
|
||||
- `POST /v1/modules/{module_id}/cdn-sources/preview` — загрузка CDN в том же HTTP-запросе (лимит тела ~8 MiB, см. OpenAPI).
|
||||
- `GET /v1/bird/status` (если маршрут включён в деплое) — опрос локального `birdc`, таймаут сервера ~12 с.
|
||||
|
||||
### Детерминированный ключ подписи бандлов (тесты)
|
||||
|
||||
|
||||
+6
-2
@@ -67,7 +67,9 @@ components:
|
||||
required: false
|
||||
schema:
|
||||
$ref: "#/components/schemas/ResourceId"
|
||||
description: Явный tenant (только супер-роли). Без заголовка tenant определяется по ключу.
|
||||
description: >
|
||||
Явный tenant (только супер-роли). Без заголовка tenant определяется по API-ключу.
|
||||
**Реализация v1:** заголовок в Go handlers не обрабатывается; tenant только из Bearer-токена (см. docs/access.md).
|
||||
IdempotencyKey:
|
||||
name: Idempotency-Key
|
||||
in: header
|
||||
@@ -1811,7 +1813,9 @@ paths:
|
||||
tags: [Modules]
|
||||
summary: Предпросмотр префиксов из CDN-источника
|
||||
description: >
|
||||
Загружает URL, парсит как plaintext или json и возвращает список извлечённых префиксов (до 100 записей).
|
||||
Синхронный запрос: conditional GET к URL (до 8 MiB тела ответа), парсинг plaintext или JSON,
|
||||
возврат до 100 префиксов в `items` (полный счётчик в `total`). Выполняется в HTTP worker;
|
||||
при таймауте клиента используйте короткий URL или меньший payload.
|
||||
operationId: previewCdnSource
|
||||
requestBody:
|
||||
required: true
|
||||
|
||||
@@ -3,6 +3,8 @@ package httpapi
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"evobgp/internal/store"
|
||||
)
|
||||
|
||||
func parseListLimit(r *http.Request) int {
|
||||
@@ -22,3 +24,15 @@ func strPtrOrNull(s string) any {
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// writePaginatedListJSON returns a cursor/limit page as OpenAPI list envelopes (items, next_cursor, has_more).
|
||||
func writePaginatedListJSON[T any](w http.ResponseWriter, r *http.Request, all []T, toItem func(T) map[string]any) {
|
||||
page, next, more := store.PaginateOffset(all, r.URL.Query().Get("cursor"), parseListLimit(r))
|
||||
items := make([]map[string]any, 0, len(page))
|
||||
for _, x := range page {
|
||||
items = append(items, toItem(x))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"items": items, "next_cursor": strPtrOrNull(next), "has_more": more,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -7,8 +7,12 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
internalErrorDetail = "an internal error occurred"
|
||||
badGatewayDetail = "upstream request failed"
|
||||
internalErrorDetail = "an internal error occurred"
|
||||
badGatewayDetail = "upstream request failed"
|
||||
notFoundDetail = "resource not found"
|
||||
invalidInputDetail = "invalid request data"
|
||||
cdnExtractDetail = "could not extract prefixes from source"
|
||||
csvInvalidRowDetail = "invalid row in csv file"
|
||||
)
|
||||
|
||||
// Problem is RFC 9457 application/problem+json.
|
||||
|
||||
@@ -84,12 +84,15 @@ func (s *Server) handleReady(w http.ResponseWriter, r *http.Request) {
|
||||
checks := map[string]string{"store": "ok", "jobs": "memory"}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
|
||||
defer cancel()
|
||||
if s.pgPool != nil {
|
||||
if err := s.pgPool.Ping(ctx); err != nil {
|
||||
if err := s.store.Ping(ctx); err != nil {
|
||||
checks["store"] = "unavailable"
|
||||
if s.pgPool != nil {
|
||||
checks["postgres"] = "unavailable"
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]any{"status": "not_ready", "checks": checks})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]any{"status": "not_ready", "checks": checks})
|
||||
return
|
||||
}
|
||||
if s.pgPool != nil {
|
||||
checks["postgres"] = "ok"
|
||||
} else {
|
||||
checks["store_backend"] = "memory"
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -172,12 +173,15 @@ func (s *Server) handleDeleteModule(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func writeStoreErr(w http.ResponseWriter, err error) {
|
||||
if err != nil {
|
||||
log.Printf("httpapi: store: %v", err)
|
||||
}
|
||||
if err == store.ErrNotFound || err == store.ErrTenantScope {
|
||||
writeProblem(w, http.StatusNotFound, "Not Found", err.Error())
|
||||
writeProblem(w, http.StatusNotFound, "Not Found", notFoundDetail)
|
||||
return
|
||||
}
|
||||
if err == store.ErrInvalidInput {
|
||||
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", err.Error())
|
||||
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", invalidInputDetail)
|
||||
return
|
||||
}
|
||||
writeInternalError(w, "store", err)
|
||||
@@ -193,11 +197,7 @@ func (s *Server) handleListCDNSources(w http.ResponseWriter, r *http.Request) {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
items := make([]map[string]any, 0, len(list))
|
||||
for _, x := range list {
|
||||
items = append(items, cdnSourceJSON(x))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"items": items, "next_cursor": nil, "has_more": false})
|
||||
writePaginatedListJSON(w, r, list, cdnSourceJSON)
|
||||
}
|
||||
|
||||
func cdnSourceJSON(x *store.CDNSource) map[string]any {
|
||||
@@ -271,7 +271,8 @@ func (s *Server) handlePreviewCDNSource(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
pfxs, err := pipeline.ExtractCIDRs(string(raw), body.SourceKind, body.PrefixPath)
|
||||
if err != nil {
|
||||
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", err.Error())
|
||||
log.Printf("httpapi: cdn preview extract: %v", err)
|
||||
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", cdnExtractDetail)
|
||||
return
|
||||
}
|
||||
items := make([]string, 0, len(pfxs))
|
||||
@@ -354,11 +355,7 @@ func (s *Server) handleListAS(w http.ResponseWriter, r *http.Request) {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
items := make([]map[string]any, 0, len(list))
|
||||
for _, x := range list {
|
||||
items = append(items, asEntryJSON(x))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"items": items})
|
||||
writePaginatedListJSON(w, r, list, asEntryJSON)
|
||||
}
|
||||
|
||||
func asEntryJSON(x *store.ASEntry) map[string]any {
|
||||
@@ -450,11 +447,7 @@ func (s *Server) handleListDomain(w http.ResponseWriter, r *http.Request) {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
items := make([]map[string]any, 0, len(list))
|
||||
for _, x := range list {
|
||||
items = append(items, domainEntryJSON(x))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"items": items})
|
||||
writePaginatedListJSON(w, r, list, domainEntryJSON)
|
||||
}
|
||||
|
||||
func domainEntryJSON(x *store.DomainEntry) map[string]any {
|
||||
@@ -531,11 +524,7 @@ func (s *Server) handleListIPRange(w http.ResponseWriter, r *http.Request) {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
items := make([]map[string]any, 0, len(list))
|
||||
for _, x := range list {
|
||||
items = append(items, ipRangeJSON(x))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"items": items})
|
||||
writePaginatedListJSON(w, r, list, ipRangeJSON)
|
||||
}
|
||||
|
||||
func ipRangeJSON(x *store.IPRangeEntry) map[string]any {
|
||||
@@ -702,8 +691,8 @@ func (s *Server) handleImportModuleEntriesCSV(w http.ResponseWriter, r *http.Req
|
||||
return
|
||||
}
|
||||
if strings.Contains(err.Error(), "importer: line") {
|
||||
detail := strings.TrimPrefix(err.Error(), "importer: ")
|
||||
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", detail)
|
||||
log.Printf("httpapi: csv import: %v", err)
|
||||
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", csvInvalidRowDetail)
|
||||
return
|
||||
}
|
||||
if strings.Contains(err.Error(), "importer: csv import/export") {
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNestedModuleListPagination(t *testing.T) {
|
||||
srv, err := New(Options{InsecureDev: true, SeedDemo: true, BundleSeedHex: testBundleSeed})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer srv.Close()
|
||||
tenant, _, modIP, _, _ := srv.Store().DemoIDs()
|
||||
srv.apiKeys = parseAPIKeysSpec("edkey|" + tenant + "|editor")
|
||||
|
||||
ts := httptest.NewServer(srv.Handler())
|
||||
defer ts.Close()
|
||||
client := ts.Client()
|
||||
base := ts.URL
|
||||
mid := modIP
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
body := strings.NewReader(fmt.Sprintf(`{"prefix":"10.%d.0.0/24"}`, 200+i))
|
||||
req, _ := http.NewRequest(http.MethodPost, base+"/v1/modules/"+mid+"/ip-range-entries", body)
|
||||
req.Header.Set("Authorization", "Bearer edkey")
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("create entry %d: status %d", i, resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
req, _ := http.NewRequest(http.MethodGet, base+"/v1/modules/"+mid+"/ip-range-entries?limit=2", nil)
|
||||
req.Header.Set("Authorization", "Bearer edkey")
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("list status %d: %s", resp.StatusCode, b)
|
||||
}
|
||||
var page1 struct {
|
||||
Items []map[string]any `json:"items"`
|
||||
NextCursor *string `json:"next_cursor"`
|
||||
HasMore bool `json:"has_more"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&page1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(page1.Items) != 2 {
|
||||
t.Fatalf("page1 items: got %d want 2", len(page1.Items))
|
||||
}
|
||||
if !page1.HasMore || page1.NextCursor == nil || *page1.NextCursor == "" {
|
||||
t.Fatalf("page1: has_more=%v next_cursor=%v", page1.HasMore, page1.NextCursor)
|
||||
}
|
||||
|
||||
req2, _ := http.NewRequest(http.MethodGet, base+"/v1/modules/"+mid+"/ip-range-entries?limit=2&cursor="+*page1.NextCursor, nil)
|
||||
req2.Header.Set("Authorization", "Bearer edkey")
|
||||
resp2, err := client.Do(req2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp2.Body.Close()
|
||||
var page2 struct {
|
||||
Items []map[string]any `json:"items"`
|
||||
HasMore bool `json:"has_more"`
|
||||
}
|
||||
if err := json.NewDecoder(resp2.Body).Decode(&page2); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(page1.Items)+len(page2.Items) < 3 {
|
||||
t.Fatalf("expected at least 3 entries across pages, got %d+%d", len(page1.Items), len(page2.Items))
|
||||
}
|
||||
}
|
||||
@@ -39,7 +39,7 @@ func ParseCIDRLines(body string) []netip.Prefix {
|
||||
return out
|
||||
}
|
||||
|
||||
// ExtractCIDRs parses CIDRs from either plaintext lines or JSON payload.
|
||||
// ExtractCIDRs parses CIDR prefixes from plaintext lines or a JSON payload (see sourceKind and prefixPath).
|
||||
// For sourceKind="json", prefixPath supports dotted traversal, with [] for arrays:
|
||||
// e.g. "prefixes[]", "data.items[].cidr".
|
||||
func ExtractCIDRs(body, sourceKind, prefixPath string) ([]netip.Prefix, error) {
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"evobgp/internal/db"
|
||||
"evobgp/internal/store"
|
||||
)
|
||||
|
||||
func TestPostgresImplementsBackend(t *testing.T) {
|
||||
var _ store.Backend = (*Postgres)(nil)
|
||||
}
|
||||
|
||||
func TestPostgresPingIntegration(t *testing.T) {
|
||||
dsn := os.Getenv("EVOBGP_TEST_DATABASE_URL")
|
||||
if dsn == "" {
|
||||
t.Skip("EVOBGP_TEST_DATABASE_URL not set")
|
||||
}
|
||||
ctx := context.Background()
|
||||
pool, err := db.OpenPostgresPool(ctx, dsn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer pool.Close()
|
||||
pg, err := NewPostgres(ctx, pool, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := pg.Ping(ctx); err != nil {
|
||||
t.Fatalf("ping: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostgresModuleCRUDIntegration(t *testing.T) {
|
||||
dsn := os.Getenv("EVOBGP_TEST_DATABASE_URL")
|
||||
if dsn == "" {
|
||||
t.Skip("EVOBGP_TEST_DATABASE_URL not set")
|
||||
}
|
||||
ctx := context.Background()
|
||||
pool, err := db.OpenPostgresPool(ctx, dsn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer pool.Close()
|
||||
pg, err := NewPostgres(ctx, pool, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tenant := "01TESTTENANT00000000000001"
|
||||
mod, err := pg.CreateModule(tenant, &store.Module{
|
||||
Name: "audit-test",
|
||||
Type: "IP_RANGES",
|
||||
Enabled: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := pg.GetModule(tenant, mod.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Name != "audit-test" {
|
||||
t.Fatalf("name: got %q", got.Name)
|
||||
}
|
||||
if err := pg.SoftDeleteModule(tenant, mod.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -71,6 +71,11 @@ func (p *Postgres) DemoIDs() (tenant, moduleCDN, moduleIP, revision, speaker str
|
||||
return p.demoTenant, p.demoCDN, p.demoIP, p.demoRev, p.demoSpk
|
||||
}
|
||||
|
||||
// Ping checks PostgreSQL connectivity.
|
||||
func (p *Postgres) Ping(ctx context.Context) error {
|
||||
return p.pool.Ping(ctx)
|
||||
}
|
||||
|
||||
func (p *Postgres) MaterializedPrefixStats() (max int, sum int) {
|
||||
ctx := context.Background()
|
||||
// Агрегация в БД — не тащим все строки config_revision в память.
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package store
|
||||
|
||||
import "time"
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Backend is the persistence abstraction for the control plane (memory, PostgreSQL, SQLite).
|
||||
type Backend interface {
|
||||
@@ -90,6 +93,9 @@ type Backend interface {
|
||||
// ASNPrefixCache stores RIPEstat announced-prefixes per ASN (global TTL cache).
|
||||
GetASNPrefixCache(asn int64) (*ASNPrefixCacheEntry, bool, error)
|
||||
SetASNPrefixCache(asn int64, holder string, prefixes []string) error
|
||||
|
||||
// Ping verifies backend connectivity (no-op for in-memory).
|
||||
Ping(ctx context.Context) error
|
||||
}
|
||||
|
||||
// ASNPrefixCacheEntry is a cached RIPEstat response for one ASN.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
@@ -291,6 +292,12 @@ func (m *Memory) DemoIDs() (tenant, moduleCDN, moduleIP, revision, speaker strin
|
||||
return m.demoTenantID, m.demoModuleCDN, m.demoModuleIP, m.demoRevisionID, m.demoSpeakerID
|
||||
}
|
||||
|
||||
// Ping is a no-op for the in-memory backend.
|
||||
func (m *Memory) Ping(ctx context.Context) error {
|
||||
_ = ctx
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListTenantIDs returns tenant ids sorted lexicographically.
|
||||
func (m *Memory) ListTenantIDs() ([]string, error) {
|
||||
m.mu.RLock()
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
#!/usr/bin/env sh
|
||||
# DEP-03: postgres and sqlite migration sets must have matching numbered pairs.
|
||||
set -eu
|
||||
|
||||
ROOT="$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)"
|
||||
PG="$ROOT/migrations/postgres"
|
||||
SQL="$ROOT/migrations/sqlite"
|
||||
|
||||
list_nums() {
|
||||
dir="$1"
|
||||
ls "$dir" 2>/dev/null | sed -n 's/^\([0-9]\{6\}\)_.*\.up\.sql$/\1/p' | sort -u
|
||||
}
|
||||
|
||||
migration_only_in() {
|
||||
# Prints numbers present in $1 but not in $2 (space-separated lists).
|
||||
haystack="$2 "
|
||||
for n in $1; do
|
||||
case "$haystack" in
|
||||
*" $n "*) ;;
|
||||
*) echo "$n" ;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
pg_nums="$(list_nums "$PG")"
|
||||
sql_nums="$(list_nums "$SQL")"
|
||||
|
||||
if [ "$pg_nums" != "$sql_nums" ]; then
|
||||
echo "check-migrations-pair: postgres and sqlite migration numbers differ" >&2
|
||||
only_pg="$(migration_only_in "$pg_nums" "$sql_nums")"
|
||||
only_sql="$(migration_only_in "$sql_nums" "$pg_nums")"
|
||||
if [ -n "$only_pg" ]; then
|
||||
echo "postgres only:" >&2
|
||||
for n in $only_pg; do
|
||||
echo " $n" >&2
|
||||
done
|
||||
fi
|
||||
if [ -n "$only_sql" ]; then
|
||||
echo "sqlite only:" >&2
|
||||
for n in $only_sql; do
|
||||
echo " $n" >&2
|
||||
done
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
|
||||
for n in $pg_nums; do
|
||||
pg_up=""
|
||||
sql_up=""
|
||||
for f in "$PG"/${n}_*.up.sql; do
|
||||
if [ -f "$f" ]; then
|
||||
pg_up="$f"
|
||||
break
|
||||
fi
|
||||
done
|
||||
for f in "$SQL"/${n}_*.up.sql; do
|
||||
if [ -f "$f" ]; then
|
||||
sql_up="$f"
|
||||
break
|
||||
fi
|
||||
done
|
||||
if [ -z "$pg_up" ] || [ -z "$sql_up" ]; then
|
||||
echo "check-migrations-pair: missing .up.sql for $n" >&2
|
||||
exit 1
|
||||
fi
|
||||
pg_base="$(basename "$pg_up" .up.sql)"
|
||||
sql_base="$(basename "$sql_up" .up.sql)"
|
||||
if [ "$pg_base" != "$sql_base" ]; then
|
||||
echo "check-migrations-pair: name mismatch for $n: $pg_base vs $sql_base" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
echo "check-migrations-pair: ok ($PG and $SQL)"
|
||||
@@ -16,6 +16,14 @@ if grep -rE 'writeProblem\(w, http\.StatusBadGateway.*err\.Error\(\)' "$HTTPAPI"
|
||||
FAIL=1
|
||||
fi
|
||||
|
||||
echo "==> ERR-01: no err.Error() in 4xx writeProblem (store/cdn/csv)"
|
||||
if grep -rE 'writeProblem\(w, http\.Status(NotFound|UnprocessableEntity|BadRequest).*, err\.Error\(\)' "$HTTPAPI" 2>/dev/null; then
|
||||
FAIL=1
|
||||
fi
|
||||
if grep -rE 'writeStoreErr.*err\.Error|writeProblem.*Unprocessable.*err\.Error' "$HTTPAPI" 2>/dev/null; then
|
||||
FAIL=1
|
||||
fi
|
||||
|
||||
echo "==> ARCH-01: no SQL/pgx queries in httpapi"
|
||||
if grep -rE 'pool\.(Query|Exec|QueryRow)|SELECT |INSERT INTO |UPDATE .* SET |DELETE FROM ' "$HTTPAPI" 2>/dev/null; then
|
||||
FAIL=1
|
||||
|
||||
Generated
+2
-3
@@ -14,7 +14,8 @@
|
||||
"svelte-sonner": "^1.1.0",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwind-variants": "^3.2.2",
|
||||
"tw-animate-css": "^1.4.0"
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@internationalized/date": "^3.12.0",
|
||||
@@ -3137,9 +3138,7 @@
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
|
||||
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
|
||||
+2
-1
@@ -39,6 +39,7 @@
|
||||
"svelte-sonner": "^1.1.0",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwind-variants": "^3.2.2",
|
||||
"tw-animate-css": "^1.4.0"
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"zod": "^4.4.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
function isValidIPv4(value: string): boolean {
|
||||
const parts = value.split('.');
|
||||
if (parts.length !== 4) return false;
|
||||
for (const part of parts) {
|
||||
if (!/^\d{1,3}$/.test(part)) return false;
|
||||
if (part.length > 1 && part.startsWith('0')) return false;
|
||||
const n = Number(part);
|
||||
if (!Number.isInteger(n) || n < 0 || n > 255) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function isValidIPv6(value: string): boolean {
|
||||
if (!/^[0-9A-Fa-f:.]+$/.test(value)) return false;
|
||||
if ((value.match(/::/g) ?? []).length > 1) return false;
|
||||
const hasCompression = value.includes('::');
|
||||
const [leftRaw, rightRaw = ''] = value.split('::');
|
||||
const left = leftRaw === '' ? [] : leftRaw.split(':');
|
||||
const right = rightRaw === '' ? [] : rightRaw.split(':');
|
||||
if (left.some((part) => part === '') || right.some((part) => part === '')) return false;
|
||||
let segments = [...left, ...right];
|
||||
let ipv4TailSegments = 0;
|
||||
const lastSegment = segments.at(-1);
|
||||
if (lastSegment && lastSegment.includes('.')) {
|
||||
if (!isValidIPv4(lastSegment)) return false;
|
||||
segments = segments.slice(0, -1);
|
||||
ipv4TailSegments = 2;
|
||||
}
|
||||
for (const segment of segments) {
|
||||
if (!/^[0-9A-Fa-f]{1,4}$/.test(segment)) return false;
|
||||
}
|
||||
const totalSegments = segments.length + ipv4TailSegments;
|
||||
if (hasCompression) return totalSegments < 8;
|
||||
return totalSegments === 8;
|
||||
}
|
||||
|
||||
const optionalIPv4 = (label: string) =>
|
||||
z.string().refine((v) => v.trim() === '' || isValidIPv4(v.trim()), {
|
||||
message: `Введите корректный IPv4 адрес (${label})`
|
||||
});
|
||||
|
||||
const optionalIPv6 = (label: string) =>
|
||||
z.string().refine((v) => v.trim() === '' || isValidIPv6(v.trim()), {
|
||||
message: `Введите корректный IPv6 адрес (${label})`
|
||||
});
|
||||
|
||||
export const settingsKnownSchema = z.object({
|
||||
bird_router_id: optionalIPv4('router id'),
|
||||
bird_local_ipv4: optionalIPv4('local IPv4'),
|
||||
bird_local_ipv6: optionalIPv6('local IPv6'),
|
||||
bird_local_asn: z.string().refine((v) => v.trim() === '' || /^[1-9]\d*$/.test(v.trim()), {
|
||||
message: 'ASN должен быть целым числом больше 0'
|
||||
}),
|
||||
bird_bgp_source_ipv4: optionalIPv4('BGP source IPv4'),
|
||||
bird_bgp_source_ipv6: optionalIPv6('BGP source IPv6'),
|
||||
revision_retention_minutes: z.string().refine(
|
||||
(v) => {
|
||||
const s = v.trim();
|
||||
if (s === '') return true;
|
||||
const ttl = Number(s);
|
||||
return /^\d+$/.test(s) && Number.isInteger(ttl) && ttl >= 15 && ttl <= 43200;
|
||||
},
|
||||
{ message: 'TTL ревизий должен быть целым числом от 15 до 43200 минут' }
|
||||
)
|
||||
});
|
||||
|
||||
export type SettingsKnownForm = z.infer<typeof settingsKnownSchema>;
|
||||
|
||||
export const emptySettingsKnownForm = (): SettingsKnownForm => ({
|
||||
bird_router_id: '',
|
||||
bird_local_ipv4: '',
|
||||
bird_local_ipv6: '',
|
||||
bird_local_asn: '',
|
||||
bird_bgp_source_ipv4: '',
|
||||
bird_bgp_source_ipv6: '',
|
||||
revision_retention_minutes: ''
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
<script lang="ts">
|
||||
import type { Component } from 'svelte';
|
||||
import { Badge } from '$lib/ui/core/badge/index.js';
|
||||
import { Button } from '$lib/ui/core/button/index.js';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription
|
||||
} from '$lib/ui/core/card/index.js';
|
||||
import CardSkeleton from '$lib/ui/patterns/feedback/card-skeleton.svelte';
|
||||
import { cn } from '$lib/utils.js';
|
||||
import ArrowRight from '@lucide/svelte/icons/arrow-right';
|
||||
|
||||
export type KpiAccent = {
|
||||
border: string;
|
||||
bg: string;
|
||||
iconBg: string;
|
||||
iconText: string;
|
||||
};
|
||||
|
||||
export type KpiCardItem = {
|
||||
id: string;
|
||||
label: string;
|
||||
value: string;
|
||||
description: string;
|
||||
icon: Component;
|
||||
accent: KpiAccent;
|
||||
badge: string;
|
||||
badgeClass?: string;
|
||||
badgeVariant?: 'default' | 'secondary' | 'destructive' | 'outline';
|
||||
valueClass?: string;
|
||||
error?: string | null;
|
||||
href?: string;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
cards: KpiCardItem[];
|
||||
loading?: boolean;
|
||||
skeletonCount?: number;
|
||||
class?: string;
|
||||
};
|
||||
|
||||
let { cards, loading = false, skeletonCount = 3, class: className }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class={cn('grid gap-4', className)}>
|
||||
{#if loading}
|
||||
{#each Array(skeletonCount) as _, i (i)}
|
||||
<CardSkeleton />
|
||||
{/each}
|
||||
{:else}
|
||||
{#each cards as card (card.id)}
|
||||
{@const Icon = card.icon}
|
||||
{@const a = card.accent}
|
||||
<Card
|
||||
class={cn(
|
||||
'overflow-hidden border-l-4 shadow-sm',
|
||||
card.href ? 'transition-colors hover:border-primary/35' : '',
|
||||
a.border,
|
||||
a.bg
|
||||
)}
|
||||
>
|
||||
<CardHeader class="pb-2">
|
||||
{#if card.href}
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<CardDescription class="flex min-w-0 items-center gap-2">
|
||||
<span
|
||||
class={cn(
|
||||
'flex size-9 shrink-0 items-center justify-center rounded-lg',
|
||||
a.iconBg
|
||||
)}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Icon class={cn('size-4', a.iconText)} />
|
||||
</span>
|
||||
<span class="truncate">{card.label}</span>
|
||||
</CardDescription>
|
||||
<Button variant="ghost" size="icon-sm" href={card.href}>
|
||||
<ArrowRight class="size-3.5" aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
{:else}
|
||||
<CardDescription class="flex min-w-0 items-center gap-2">
|
||||
<span
|
||||
class={cn('flex size-9 shrink-0 items-center justify-center rounded-lg', a.iconBg)}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Icon class={cn('size-4', a.iconText)} />
|
||||
</span>
|
||||
<span class="truncate">{card.label}</span>
|
||||
</CardDescription>
|
||||
{/if}
|
||||
<CardTitle class={cn('font-bold tabular-nums', card.valueClass ?? 'text-3xl')}
|
||||
>{card.value}</CardTitle
|
||||
>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-2">
|
||||
<Badge variant={card.badgeVariant ?? 'outline'} class={card.badgeClass}
|
||||
>{card.badge}</Badge
|
||||
>
|
||||
{#if card.error}
|
||||
<p class="text-xs text-destructive">{card.error}</p>
|
||||
{:else}
|
||||
<p class="text-xs text-muted-foreground">{card.description}</p>
|
||||
{/if}
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
@@ -26,6 +26,7 @@
|
||||
import { Alert, AlertDescription, AlertTitle } from '$lib/ui/core/alert/index.js';
|
||||
import { Skeleton } from '$lib/ui/core/skeleton/index.js';
|
||||
import CardSkeleton from '$lib/ui/patterns/feedback/card-skeleton.svelte';
|
||||
import KpiMetricsGrid from '$lib/ui/patterns/kpi/kpi-metrics-grid.svelte';
|
||||
import PageHeader from '$lib/ui/app/page-header/page-header.svelte';
|
||||
import { notifyApiError } from '$lib/ui/app/toast.js';
|
||||
import OverviewRecentJobsCard from '$lib/components/overview/OverviewRecentJobsCard.svelte';
|
||||
@@ -38,7 +39,6 @@
|
||||
import Radio from '@lucide/svelte/icons/radio';
|
||||
import Activity from '@lucide/svelte/icons/activity';
|
||||
import Clock from '@lucide/svelte/icons/clock';
|
||||
import ArrowRight from '@lucide/svelte/icons/arrow-right';
|
||||
import LayoutDashboard from '@lucide/svelte/icons/layout-dashboard';
|
||||
import Info from '@lucide/svelte/icons/info';
|
||||
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
|
||||
@@ -251,50 +251,12 @@
|
||||
</Alert>
|
||||
{/if}
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{#if initialLoading}
|
||||
{#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 transition-colors hover:border-primary/35',
|
||||
a.border,
|
||||
a.bg
|
||||
)}
|
||||
>
|
||||
<CardHeader class="pb-2">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<CardDescription class="flex min-w-0 items-center gap-2">
|
||||
<span
|
||||
class={cn(
|
||||
'flex size-9 shrink-0 items-center justify-center rounded-lg',
|
||||
a.iconBg
|
||||
)}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Icon class={cn('size-4', a.iconText)} />
|
||||
</span>
|
||||
<span class="truncate">{card.label}</span>
|
||||
</CardDescription>
|
||||
<Button variant="ghost" size="icon-sm" href={resolve(card.href)}>
|
||||
<ArrowRight class="size-3.5" aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
<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>
|
||||
<KpiMetricsGrid
|
||||
cards={kpiCards}
|
||||
loading={initialLoading}
|
||||
skeletonCount={5}
|
||||
class="sm:grid-cols-2 lg:grid-cols-3"
|
||||
/>
|
||||
|
||||
<div class="grid gap-4 lg:grid-cols-2">
|
||||
<OverviewRecentJobsCard
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
import { Alert, AlertDescription, AlertTitle } from '$lib/ui/core/alert/index.js';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '$lib/ui/core/tabs/index.js';
|
||||
import CardSkeleton from '$lib/ui/patterns/feedback/card-skeleton.svelte';
|
||||
import KpiMetricsGrid from '$lib/ui/patterns/kpi/kpi-metrics-grid.svelte';
|
||||
import PageHeader from '$lib/ui/app/page-header/page-header.svelte';
|
||||
import { notifyApiError } from '$lib/ui/app/toast.js';
|
||||
import DirectoriesCommunitiesCard from '$lib/components/directories/DirectoriesCommunitiesCard.svelte';
|
||||
@@ -169,53 +170,12 @@
|
||||
</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">{card.badge}</Badge>
|
||||
<p class="text-xs text-muted-foreground">{card.description}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
<KpiMetricsGrid
|
||||
cards={kpiCards}
|
||||
loading={initialLoading}
|
||||
skeletonCount={3}
|
||||
class="sm:grid-cols-3"
|
||||
/>
|
||||
|
||||
<Tabs value="communities">
|
||||
<TabsList>
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
import { Checkbox } from '$lib/ui/core/checkbox/index.js';
|
||||
import AppDataTable from '$lib/ui/patterns/data-table/app-data-table.svelte';
|
||||
import CardSkeleton from '$lib/ui/patterns/feedback/card-skeleton.svelte';
|
||||
import KpiMetricsGrid from '$lib/ui/patterns/kpi/kpi-metrics-grid.svelte';
|
||||
import PageHeader from '$lib/ui/app/page-header/page-header.svelte';
|
||||
import { confirm } from '$lib/ui/patterns/confirm/confirm-state.svelte.js';
|
||||
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
|
||||
@@ -217,36 +218,12 @@
|
||||
</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>
|
||||
<KpiMetricsGrid
|
||||
cards={kpiCards}
|
||||
loading={initialLoading}
|
||||
skeletonCount={3}
|
||||
class="sm:grid-cols-3"
|
||||
/>
|
||||
|
||||
<Card>
|
||||
<CardHeader class="flex flex-wrap items-center justify-between gap-2 border-b py-3">
|
||||
|
||||
@@ -46,6 +46,7 @@
|
||||
TableRow
|
||||
} from '$lib/ui/core/table/index.js';
|
||||
import CardSkeleton from '$lib/ui/patterns/feedback/card-skeleton.svelte';
|
||||
import KpiMetricsGrid from '$lib/ui/patterns/kpi/kpi-metrics-grid.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';
|
||||
@@ -223,6 +224,7 @@
|
||||
badge: version ? 'Загружена' : '—',
|
||||
badgeVariant: version ? ('outline' as const) : ('secondary' as const),
|
||||
badgeClass: undefined,
|
||||
valueClass: 'font-mono text-xl',
|
||||
error: versionError,
|
||||
href: undefined
|
||||
}
|
||||
@@ -342,66 +344,12 @@
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<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'
|
||||
)}
|
||||
>
|
||||
{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>
|
||||
<KpiMetricsGrid
|
||||
cards={kpiCards}
|
||||
loading={initialLoading}
|
||||
skeletonCount={4}
|
||||
class="sm:grid-cols-2 xl:grid-cols-4"
|
||||
/>
|
||||
|
||||
<div class="grid gap-4 lg:grid-cols-2">
|
||||
{#if initialLoading}
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
import { Alert, AlertDescription, AlertTitle } from '$lib/ui/core/alert/index.js';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '$lib/ui/core/tabs/index.js';
|
||||
import CardSkeleton from '$lib/ui/patterns/feedback/card-skeleton.svelte';
|
||||
import KpiMetricsGrid from '$lib/ui/patterns/kpi/kpi-metrics-grid.svelte';
|
||||
import PageHeader from '$lib/ui/app/page-header/page-header.svelte';
|
||||
import { notifyApiError } from '$lib/ui/app/toast.js';
|
||||
import NetworkPeersCard from '$lib/components/network/NetworkPeersCard.svelte';
|
||||
@@ -179,36 +180,12 @@
|
||||
</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" class={card.badgeClass}>{card.badge}</Badge>
|
||||
<p class="text-xs text-muted-foreground">{card.description}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
<KpiMetricsGrid
|
||||
cards={kpiCards}
|
||||
loading={initialLoading}
|
||||
skeletonCount={3}
|
||||
class="sm:grid-cols-3"
|
||||
/>
|
||||
|
||||
<Tabs value="peers">
|
||||
<TabsList>
|
||||
|
||||
@@ -60,6 +60,7 @@
|
||||
} 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 KpiMetricsGrid from '$lib/ui/patterns/kpi/kpi-metrics-grid.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';
|
||||
@@ -891,53 +892,12 @@
|
||||
</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>
|
||||
<KpiMetricsGrid
|
||||
cards={kpiCards}
|
||||
loading={initialLoading}
|
||||
skeletonCount={3}
|
||||
class="sm:grid-cols-3"
|
||||
/>
|
||||
|
||||
<OperationsQuickActions
|
||||
{applying}
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
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 KpiMetricsGrid from '$lib/ui/patterns/kpi/kpi-metrics-grid.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';
|
||||
@@ -234,53 +235,12 @@
|
||||
</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>
|
||||
<KpiMetricsGrid
|
||||
cards={kpiCards}
|
||||
loading={initialLoading}
|
||||
skeletonCount={3}
|
||||
class="sm:grid-cols-3"
|
||||
/>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
|
||||
@@ -1,14 +1,22 @@
|
||||
<script lang="ts">
|
||||
import { browser } from '$app/environment';
|
||||
import { onMount } from 'svelte';
|
||||
import { defaults, superForm } from 'sveltekit-superforms';
|
||||
import { zod4 } from 'sveltekit-superforms/adapters';
|
||||
import { TOKEN_STORAGE_KEY } from '$lib/api/client.js';
|
||||
import { apiJSON, apiMutate } from '$lib/api/client.js';
|
||||
import type { AppSettings } from '$lib/api/types.js';
|
||||
import { Button } from '$lib/components/ui/button/index.js';
|
||||
import { Card, CardContent, CardHeader, CardDescription } 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 { toast } from 'svelte-sonner';
|
||||
import {
|
||||
emptySettingsKnownForm,
|
||||
settingsKnownSchema,
|
||||
type SettingsKnownForm
|
||||
} from '$lib/settings/settings-known.schema.js';
|
||||
import { Button } from '$lib/ui/core/button/index.js';
|
||||
import { Card, CardContent, CardHeader, CardDescription } from '$lib/ui/core/card/index.js';
|
||||
import { Input } from '$lib/ui/core/input/index.js';
|
||||
import { Label } from '$lib/ui/core/label/index.js';
|
||||
import FormField from '$lib/ui/patterns/form/form-field.svelte';
|
||||
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
|
||||
import Save from '@lucide/svelte/icons/save';
|
||||
import Plus from '@lucide/svelte/icons/plus';
|
||||
import SettingsIcon from '@lucide/svelte/icons/settings';
|
||||
@@ -19,26 +27,10 @@
|
||||
let apiSettings = $state<AppSettings | null>(null);
|
||||
let loadingSettings = $state(false);
|
||||
let savingSettings = $state(false);
|
||||
let knownFields = $state({
|
||||
bird_router_id: '',
|
||||
bird_local_ipv4: '',
|
||||
bird_local_ipv6: '',
|
||||
bird_local_asn: '',
|
||||
bird_bgp_source_ipv4: '',
|
||||
bird_bgp_source_ipv6: '',
|
||||
revision_retention_minutes: ''
|
||||
});
|
||||
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'
|
||||
| 'revision_retention_minutes';
|
||||
type KnownFieldKey = keyof SettingsKnownForm;
|
||||
|
||||
type SettingsSection = 'token' | 'bird' | 'revisions' | 'additional';
|
||||
|
||||
@@ -52,6 +44,15 @@
|
||||
'revision_retention_minutes'
|
||||
];
|
||||
|
||||
const { form, errors, reset, validateForm } = superForm(
|
||||
defaults(emptySettingsKnownForm(), zod4(settingsKnownSchema)),
|
||||
{
|
||||
validators: zod4(settingsKnownSchema),
|
||||
SPA: true,
|
||||
dataType: 'json'
|
||||
}
|
||||
);
|
||||
|
||||
let activeSection = $state<SettingsSection>('token');
|
||||
|
||||
const sectionItems: Array<{ id: SettingsSection; label: string; description: string }> = [
|
||||
@@ -61,98 +62,7 @@
|
||||
{ id: 'additional', label: 'Дополнительно', description: 'Ключ-значение' }
|
||||
];
|
||||
|
||||
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: '',
|
||||
revision_retention_minutes: ''
|
||||
};
|
||||
|
||||
const routerId = String(knownFields.bird_router_id ?? '').trim();
|
||||
if (routerId && !isValidIPv4(routerId)) errors.bird_router_id = 'Введите корректный IPv4 адрес';
|
||||
|
||||
const localV4 = String(knownFields.bird_local_ipv4 ?? '').trim();
|
||||
if (localV4 && !isValidIPv4(localV4)) errors.bird_local_ipv4 = 'Введите корректный IPv4 адрес';
|
||||
|
||||
const localV6 = String(knownFields.bird_local_ipv6 ?? '').trim();
|
||||
if (localV6 && !isValidIPv6(localV6)) errors.bird_local_ipv6 = 'Введите корректный IPv6 адрес';
|
||||
|
||||
const asn = String(knownFields.bird_local_asn ?? '').trim();
|
||||
if (asn && !isPositiveInt(asn)) errors.bird_local_asn = 'ASN должен быть целым числом больше 0';
|
||||
|
||||
const bgpV4 = String(knownFields.bird_bgp_source_ipv4 ?? '').trim();
|
||||
if (bgpV4 && !isValidIPv4(bgpV4)) errors.bird_bgp_source_ipv4 = 'Введите корректный IPv4 адрес';
|
||||
|
||||
const bgpV6 = String(knownFields.bird_bgp_source_ipv6 ?? '').trim();
|
||||
if (bgpV6 && !isValidIPv6(bgpV6)) errors.bird_bgp_source_ipv6 = 'Введите корректный IPv6 адрес';
|
||||
|
||||
const revisionRetentionMinutes = String(knownFields.revision_retention_minutes ?? '').trim();
|
||||
if (revisionRetentionMinutes) {
|
||||
const ttl = Number(revisionRetentionMinutes);
|
||||
if (
|
||||
!/^\d+$/.test(revisionRetentionMinutes) ||
|
||||
!Number.isInteger(ttl) ||
|
||||
ttl < 15 ||
|
||||
ttl > 43200
|
||||
) {
|
||||
errors.revision_retention_minutes =
|
||||
'TTL ревизий должен быть целым числом от 15 до 43200 минут';
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
});
|
||||
|
||||
let hasValidationErrors = $derived(knownFieldKeys.some((key) => Boolean(knownFieldErrors[key])));
|
||||
let hasValidationErrors = $derived(knownFieldKeys.some((key) => Boolean($errors[key]?.length)));
|
||||
|
||||
function addAdditionalSetting() {
|
||||
additionalSettings.push({ id: additionalIdCounter++, key: '', value: '' });
|
||||
@@ -191,7 +101,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
knownFields = parsedKnown;
|
||||
reset({ data: parsedKnown });
|
||||
additionalSettings = parsedAdditional;
|
||||
}
|
||||
|
||||
@@ -200,7 +110,7 @@
|
||||
const t = token.trim();
|
||||
if (t) localStorage.setItem(TOKEN_STORAGE_KEY, t);
|
||||
else localStorage.removeItem(TOKEN_STORAGE_KEY);
|
||||
toast.success('Токен сохранён');
|
||||
notify.success('Токен сохранён');
|
||||
}
|
||||
|
||||
async function loadApiSettings() {
|
||||
@@ -210,7 +120,7 @@
|
||||
apiSettings = s;
|
||||
resetFormFromApi(s);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : String(e));
|
||||
notifyApiError(e);
|
||||
} finally {
|
||||
loadingSettings = false;
|
||||
}
|
||||
@@ -220,8 +130,8 @@
|
||||
if (loadingSettings || savingSettings || hasValidationErrors) return false;
|
||||
|
||||
const hasKnownValues = knownFieldKeys.some((key) => {
|
||||
const value = String(knownFields[key] ?? '').trim();
|
||||
return value !== '' && !knownFieldErrors[key];
|
||||
const value = String($form[key] ?? '').trim();
|
||||
return value !== '' && !$errors[key]?.length;
|
||||
});
|
||||
const hasAdditionalValues = additionalSettings.some((entry) => entry.key.trim() !== '');
|
||||
|
||||
@@ -229,15 +139,20 @@
|
||||
});
|
||||
|
||||
async function saveApiSettings() {
|
||||
const validation = await validateForm({ update: true });
|
||||
if (!validation.valid) {
|
||||
notify.error('Исправьте ошибки в полях настроек');
|
||||
return;
|
||||
}
|
||||
if (!canSaveSettings) {
|
||||
toast.error('Нечего сохранять или есть ошибки в полях');
|
||||
notify.error('Нечего сохранять или есть ошибки в полях');
|
||||
return;
|
||||
}
|
||||
|
||||
const payload: Record<string, string | number> = {};
|
||||
for (const key of knownFieldKeys) {
|
||||
const value = String(knownFields[key] ?? '').trim();
|
||||
if (!value || knownFieldErrors[key]) continue;
|
||||
const value = String($form[key] ?? '').trim();
|
||||
if (!value || $errors[key]?.length) continue;
|
||||
if (key === 'bird_local_asn' || key === 'revision_retention_minutes')
|
||||
payload[key] = Number(value);
|
||||
else payload[key] = value;
|
||||
@@ -251,10 +166,10 @@
|
||||
savingSettings = true;
|
||||
try {
|
||||
await apiMutate('/v1/settings', 'PATCH', payload);
|
||||
toast.success('Настройки сохранены');
|
||||
notify.success('Настройки сохранены');
|
||||
await loadApiSettings();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : String(e));
|
||||
notifyApiError(e);
|
||||
} finally {
|
||||
savingSettings = false;
|
||||
}
|
||||
@@ -335,106 +250,99 @@
|
||||
<div class="space-y-3">
|
||||
<h2 class="text-base font-semibold">Параметры BIRD</h2>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="bird-router-id">Router ID (bird_router_id)</Label>
|
||||
<FormField
|
||||
id="bird-router-id"
|
||||
label="Router ID (bird_router_id)"
|
||||
error={$errors.bird_router_id?.[0]}
|
||||
>
|
||||
<Input
|
||||
id="bird-router-id"
|
||||
bind:value={knownFields.bird_router_id}
|
||||
bind:value={$form.bird_router_id}
|
||||
placeholder="203.0.113.1"
|
||||
/>
|
||||
{#if knownFieldErrors.bird_router_id}
|
||||
<p class="text-sm text-red-600">{knownFieldErrors.bird_router_id}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</FormField>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="bird-local-ipv4">Локальный IPv4 (bird_local_ipv4)</Label>
|
||||
<FormField
|
||||
id="bird-local-ipv4"
|
||||
label="Локальный IPv4 (bird_local_ipv4)"
|
||||
error={$errors.bird_local_ipv4?.[0]}
|
||||
>
|
||||
<Input
|
||||
id="bird-local-ipv4"
|
||||
bind:value={knownFields.bird_local_ipv4}
|
||||
bind:value={$form.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>
|
||||
</FormField>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="bird-local-ipv6">Локальный IPv6 (bird_local_ipv6)</Label>
|
||||
<FormField
|
||||
id="bird-local-ipv6"
|
||||
label="Локальный IPv6 (bird_local_ipv6)"
|
||||
error={$errors.bird_local_ipv6?.[0]}
|
||||
>
|
||||
<Input
|
||||
id="bird-local-ipv6"
|
||||
bind:value={knownFields.bird_local_ipv6}
|
||||
bind:value={$form.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>
|
||||
</FormField>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="bird-local-asn">Локальный ASN (bird_local_asn)</Label>
|
||||
<FormField
|
||||
id="bird-local-asn"
|
||||
label="Локальный ASN (bird_local_asn)"
|
||||
error={$errors.bird_local_asn?.[0]}
|
||||
>
|
||||
<Input
|
||||
id="bird-local-asn"
|
||||
type="number"
|
||||
min="1"
|
||||
bind:value={knownFields.bird_local_asn}
|
||||
bind:value={$form.bird_local_asn}
|
||||
placeholder="65001"
|
||||
/>
|
||||
{#if knownFieldErrors.bird_local_asn}
|
||||
<p class="text-sm text-red-600">{knownFieldErrors.bird_local_asn}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</FormField>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="bird-bgp-source-ipv4">BGP source IPv4 (bird_bgp_source_ipv4)</Label>
|
||||
<FormField
|
||||
id="bird-bgp-source-ipv4"
|
||||
label="BGP source IPv4 (bird_bgp_source_ipv4)"
|
||||
error={$errors.bird_bgp_source_ipv4?.[0]}
|
||||
>
|
||||
<Input
|
||||
id="bird-bgp-source-ipv4"
|
||||
bind:value={knownFields.bird_bgp_source_ipv4}
|
||||
bind:value={$form.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>
|
||||
</FormField>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="bird-bgp-source-ipv6">BGP source IPv6 (bird_bgp_source_ipv6)</Label>
|
||||
<FormField
|
||||
id="bird-bgp-source-ipv6"
|
||||
label="BGP source IPv6 (bird_bgp_source_ipv6)"
|
||||
error={$errors.bird_bgp_source_ipv6?.[0]}
|
||||
>
|
||||
<Input
|
||||
id="bird-bgp-source-ipv6"
|
||||
bind:value={knownFields.bird_bgp_source_ipv6}
|
||||
bind:value={$form.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>
|
||||
</FormField>
|
||||
</div>
|
||||
{:else if activeSection === 'revisions'}
|
||||
<div class="space-y-3">
|
||||
<h2 class="text-base font-semibold">Управление ревизиями</h2>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="revision-retention-minutes">
|
||||
Время жизни ревизий, мин (revision_retention_minutes)
|
||||
</Label>
|
||||
<FormField
|
||||
id="revision-retention-minutes"
|
||||
label="Время жизни ревизий, мин (revision_retention_minutes)"
|
||||
error={$errors.revision_retention_minutes?.[0]}
|
||||
description="Старые ревизии удаляются автоматически. Последняя раскатанная ревизия не удаляется."
|
||||
>
|
||||
<Input
|
||||
id="revision-retention-minutes"
|
||||
type="number"
|
||||
min="15"
|
||||
max="43200"
|
||||
bind:value={knownFields.revision_retention_minutes}
|
||||
bind:value={$form.revision_retention_minutes}
|
||||
placeholder="43200"
|
||||
/>
|
||||
{#if knownFieldErrors.revision_retention_minutes}
|
||||
<p class="text-sm text-red-600">
|
||||
{knownFieldErrors.revision_retention_minutes}
|
||||
</p>
|
||||
{/if}
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Старые ревизии удаляются автоматически. Последняя раскатанная ревизия не
|
||||
удаляется.
|
||||
</p>
|
||||
</div>
|
||||
</FormField>
|
||||
</div>
|
||||
{:else if activeSection === 'additional'}
|
||||
<div class="space-y-3">
|
||||
|
||||
Reference in New Issue
Block a user