Compare commits

..
10 Commits
Author SHA1 Message Date
DenozordecandCursor 27864fad58 fix(ci): make check-migrations-pair.sh POSIX sh compatible
CI / changes (push) Successful in 8s
CI / commitlint (push) Has been skipped
CI / openapi (push) Has been skipped
CI / web (push) Has been skipped
CI / go (push) Has been skipped
CI / bird2 (push) Has been skipped
CI / release (push) Has been cancelled
Убрана bash process substitution; скрипт работает под sh в Gitea runner (DEP-03).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-20 14:57:18 +07:00
DenozordecandCursor dc5e777d07 style(web): format settings-known schema for prettier
CI / changes (push) Successful in 7s
CI / openapi (push) Has been skipped
CI / commitlint (push) Has been skipped
CI / go (push) Has been cancelled
CI / bird2 (push) Has been cancelled
CI / release (push) Has been cancelled
CI / web (push) Has been cancelled
Исправление падения CI job web: prettier --check на settings-known.schema.ts.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-20 14:56:20 +07:00
DenozordecandCursor 23910c3393 docs(go): add godoc for pipeline and store exports
CI / changes (push) Successful in 7s
CI / commitlint (push) Has been skipped
CI / openapi (push) Successful in 23s
CI / web (push) Failing after 34s
CI / go (push) Failing after 41s
CI / bird2 (push) Has been skipped
CI / release (push) Has been skipped
Уточнение godoc ExtractCIDRs; тесты repository (interface + optional integration).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-20 14:53:50 +07:00
DenozordecandCursor 0e90bbee4e docs: clarify dev-only flags and openapi implementation gaps
DOC-03: X-Tenant-Id не реализован в handlers; лимиты CDN preview; SEC-02 пометки в compose; sync endpoints в access.md.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-20 14:53:20 +07:00
DenozordecandCursor 55bc87cbc4 ci: add golangci, migration checks, and pre-commit
DEP-03: check-migrations-pair.sh; migrations/* в path filter; golangci-lint в CI; pre-commit; обновление engineering.mdc (TEST-04, enforcement).
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-20 14:53:01 +07:00
DenozordecandCursor e68f034966 refactor(web): extract shared kpi metrics grid pattern
WEB-18: KpiMetricsGrid в ui/patterns; рефакторинг overview, network, modules, directories, operations, schedule, monitoring.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-20 14:52:44 +07:00
DenozordecandCursor e06ee880b2 fix(web): align settings with shadcn notify and superforms
WEB-07: notify вместо svelte-sonner; ui/core imports; WEB-05: zod + superforms + FormField для полей настроек.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-20 14:50:58 +07:00
DenozordecandCursor 33fe8fdd18 refactor(httpapi): readiness ping via store backend
ARCH-09: Ping на store.Backend; readiness без прямого pgxpool.Ping в handler.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-20 14:49:26 +07:00
DenozordecandCursor 6fa693156d fix(httpapi): stable problem details for client errors
Стабильные detail для 4xx (store, CDN preview, CSV); логирование на сервере. Расширен lint-httpapi для ERR-01 в 4xx.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-20 14:48:57 +07:00
DenozordecandCursor 5858d0889e fix(httpapi): paginate module nested list endpoints
Пагинация cursor/limit для CDN, AS, domain и IP range list; ответы с next_cursor и has_more по OpenAPI. Добавлен writePaginatedListJSON и тест.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-20 14:48:32 +07:00
31 changed files with 680 additions and 532 deletions
+6 -4
View File
@@ -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.
+7
View File
@@ -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
+19
View File
@@ -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
+1 -1
View File
@@ -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
+1
View File
@@ -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
View File
@@ -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
View File
@@ -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
+14
View File
@@ -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,
})
}
+6 -2
View File
@@ -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.
+7 -4
View File
@@ -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"
+14 -25
View File
@@ -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") {
+87
View File
@@ -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))
}
}
+1 -1
View File
@@ -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) {
+70
View File
@@ -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)
}
}
+5
View File
@@ -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 в память.
+7 -1
View File
@@ -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.
+7
View File
@@ -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()
+74
View File
@@ -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)"
+8
View File
@@ -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
+2 -3
View File
@@ -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
View File
@@ -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>
+7 -45
View File
@@ -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
+7 -47
View File
@@ -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>
+7 -30
View File
@@ -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">
+8 -60
View File
@@ -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}
+7 -30
View File
@@ -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>
+7 -47
View File
@@ -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}
+7 -47
View File
@@ -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>
+89 -181
View File
@@ -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">