feat: update BGP community structure and related documentation. Refactor BGP community handling by renaming fields from 'name' and 'kind' to 'community' and 'title' across the codebase. Update OpenAPI specifications, database interactions, and UI components to reflect these changes, enhancing clarity and consistency in community management.
CI / changes (push) Successful in 5s
CI / openapi (push) Successful in 25s
CI / go (push) Successful in 24s
CI / docker-web (deploy/docker/evobgp-web/Dockerfile, , evobgp-web) (push) Successful in 1m1s
CI / docker-web (deploy/docker/evobgp-web/Dockerfile, evobgp-all, evobgp-web-all) (push) Successful in 1m1s
CI / docker-bird (push) Has been skipped
CI / bird2 (push) Successful in 15s
CI / docker-go (evobgp-all, 1, deploy/docker/gobinary/Dockerfile, , evobgp-all) (push) Has been cancelled
CI / docker-go (evobgp-api, 1, deploy/docker/gobinary/Dockerfile, , evobgp-api) (push) Has been cancelled
CI / docker-go (evobgp-deploy, 0, deploy/docker/gobinary/Dockerfile, , evobgp-deploy) (push) Has been cancelled
CI / docker-go (evobgp-ingest, 0, deploy/docker/gobinary/Dockerfile, , evobgp-ingest) (push) Has been cancelled
CI / docker-go (evobgp-node, 0, deploy/docker/gobinary/Dockerfile, , evobgp-node) (push) Has been cancelled
CI / docker-go (evobgp-render, 0, deploy/docker/gobinary/Dockerfile, , evobgp-render) (push) Has been cancelled
CI / docker-go (evobgp-scheduler, 0, deploy/docker/gobinary/Dockerfile, , evobgp-scheduler) (push) Has been cancelled
CI / docker-go (deploy/docker/evobgp-agent/Dockerfile, , evobgp-agent) (push) Successful in 1m1s
CI / changes (push) Successful in 5s
CI / openapi (push) Successful in 25s
CI / go (push) Successful in 24s
CI / docker-web (deploy/docker/evobgp-web/Dockerfile, , evobgp-web) (push) Successful in 1m1s
CI / docker-web (deploy/docker/evobgp-web/Dockerfile, evobgp-all, evobgp-web-all) (push) Successful in 1m1s
CI / docker-bird (push) Has been skipped
CI / bird2 (push) Successful in 15s
CI / docker-go (evobgp-all, 1, deploy/docker/gobinary/Dockerfile, , evobgp-all) (push) Has been cancelled
CI / docker-go (evobgp-api, 1, deploy/docker/gobinary/Dockerfile, , evobgp-api) (push) Has been cancelled
CI / docker-go (evobgp-deploy, 0, deploy/docker/gobinary/Dockerfile, , evobgp-deploy) (push) Has been cancelled
CI / docker-go (evobgp-ingest, 0, deploy/docker/gobinary/Dockerfile, , evobgp-ingest) (push) Has been cancelled
CI / docker-go (evobgp-node, 0, deploy/docker/gobinary/Dockerfile, , evobgp-node) (push) Has been cancelled
CI / docker-go (evobgp-render, 0, deploy/docker/gobinary/Dockerfile, , evobgp-render) (push) Has been cancelled
CI / docker-go (evobgp-scheduler, 0, deploy/docker/gobinary/Dockerfile, , evobgp-scheduler) (push) Has been cancelled
CI / docker-go (deploy/docker/evobgp-agent/Dockerfile, , evobgp-agent) (push) Successful in 1m1s
This commit is contained in:
@@ -0,0 +1,49 @@
|
|||||||
|
# Руководство для ИИ-агентов (экономия контекста)
|
||||||
|
|
||||||
|
Краткие ориентиры по репозиторию **EvoBGP**, чтобы не тратить токены на полное сканирование дерева и повторное чтение одних и тех же файлов.
|
||||||
|
|
||||||
|
## С чего начать (минимум чтения)
|
||||||
|
|
||||||
|
1. **[docs/README.md](docs/README.md)** — оглавление и роли читателя.
|
||||||
|
2. **[docs/architecture.md](docs/architecture.md)** — компоненты `cmd/`, карта `internal/`, потоки данных (одного этого файла обычно достаточно для ориентации).
|
||||||
|
3. Задача-специфично: [docs/api.md](docs/api.md), [docs/access.md](docs/access.md), [web/README.md](web/README.md) — только если меняете API, доступ или фронт.
|
||||||
|
|
||||||
|
Источник правды по HTTP-контракту: **[docs/openapi.yaml](docs/openapi.yaml)**. Не дублируйте длинные фрагменты спецификации в ответах — ссылайтесь на путь и тег/операцию.
|
||||||
|
|
||||||
|
## Карта кода (куда смотреть)
|
||||||
|
|
||||||
|
| Область | Где искать |
|
||||||
|
|---------|------------|
|
||||||
|
| REST, auth, CORS | `internal/httpapi/` |
|
||||||
|
| Бизнес-слой и абстракция хранилища | `internal/store/` |
|
||||||
|
| PostgreSQL | `internal/repository/`, `internal/db/`, `migrations/` |
|
||||||
|
| Фоновые задачи | `internal/jobs/` |
|
||||||
|
| Цепочка refresh модуля (ingest+render, BIRD preview) | `internal/pipeline/` |
|
||||||
|
| Конфиг BIRD, `birdc` | `internal/birdfmt/`, `internal/birddeploy/` |
|
||||||
|
| Бандлы и подписи | `internal/bundle/`, `internal/signing/` |
|
||||||
|
| Точки входа процессов | `cmd/*/` |
|
||||||
|
| Веб (SvelteKit) | `web/` |
|
||||||
|
| Compose, деплой | `deploy/compose/` |
|
||||||
|
|
||||||
|
Точки входа бинарников и их роли — в таблице в начале [docs/architecture.md](docs/architecture.md).
|
||||||
|
|
||||||
|
## Как не раздувать контекст
|
||||||
|
|
||||||
|
- **Сначала узкий поиск:** `grep`/поиск по символу или короткий семантический запрос по одной папке (`internal/httpapi/`, `internal/pipeline/`, …), а не чтение всех `.go` подряд.
|
||||||
|
- **Читайте файлы целиком только при необходимости:** большие файлы — с `offset`/`limit` или по найденным строкам.
|
||||||
|
- **Не подтягивайте в контекст:** `web/node_modules/`, сгенерированные артефакты сборки, бинарники, полный `openapi.html`, если достаточно `openapi.yaml`.
|
||||||
|
- **Повторное использование:** если [docs/architecture.md](docs/architecture.md) уже описывает поток — не пересказывайте его длинно; укажите документ и конкретный подпункт задачи.
|
||||||
|
- **Длинные планы:** `.cursor/plans/*.plan.md` — для истории решений; для навигации пользователю достаточно `docs/`; не читайте план целиком без причины.
|
||||||
|
|
||||||
|
## Команды и среда
|
||||||
|
|
||||||
|
- Консоль пользователя: **PowerShell**; пути в стиле `deploy\compose`.
|
||||||
|
- Быстрый старт и переменные: [docs/quickstart.md](docs/quickstart.md), [README.md](README.md).
|
||||||
|
|
||||||
|
## Язык документации проекта
|
||||||
|
|
||||||
|
Пользовательская документация в `docs/` — преимущественно на русском. Комментарии и имена в коде — в существующем стиле репозитория.
|
||||||
|
|
||||||
|
## Svelte / фронтенд
|
||||||
|
|
||||||
|
При правках `web/**/*.svelte` или Svelte-модулей следуйте навыкам/инструментам проекта (официальный Svelte MCP и скиллы Cursor, если подключены).
|
||||||
@@ -6,6 +6,7 @@ Control plane для управления префиксами, модулями
|
|||||||
|
|
||||||
| Документ | Содержание |
|
| Документ | Содержание |
|
||||||
|----------|------------|
|
|----------|------------|
|
||||||
|
| [AGENTS.md](AGENTS.md) | Краткая карта репозитория и советы для ИИ-агентов (экономия контекста) |
|
||||||
| [docs/README.md](docs/README.md) | Оглавление и навигация по разделам |
|
| [docs/README.md](docs/README.md) | Оглавление и навигация по разделам |
|
||||||
| [docs/overview.md](docs/overview.md) | Ключевые возможности продукта |
|
| [docs/overview.md](docs/overview.md) | Ключевые возможности продукта |
|
||||||
| [docs/quickstart.md](docs/quickstart.md) | Быстрый запуск (Docker, локально, фронтенд) |
|
| [docs/quickstart.md](docs/quickstart.md) | Быстрый запуск (Docker, локально, фронтенд) |
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
## По роли читателя
|
## По роли читателя
|
||||||
|
|
||||||
|
- **ИИ-агент / ассистент в репозитории** — [../AGENTS.md](../AGENTS.md): с чего начать чтение, карта `internal/` и `cmd/`, что не тащить в контекст.
|
||||||
- **Оператор / DevOps** — [quickstart.md](quickstart.md), [architecture.md](architecture.md), [access.md](access.md), [deploy/compose/docker-compose.yaml](../deploy/compose/docker-compose.yaml).
|
- **Оператор / DevOps** — [quickstart.md](quickstart.md), [architecture.md](architecture.md), [access.md](access.md), [deploy/compose/docker-compose.yaml](../deploy/compose/docker-compose.yaml).
|
||||||
- **Разработчик бэкенда или интегратор API** — [api.md](api.md), [access.md](access.md), [openapi.yaml](openapi.yaml), исходники маршрутов в `internal/httpapi/`.
|
- **Разработчик бэкенда или интегратор API** — [api.md](api.md), [access.md](access.md), [openapi.yaml](openapi.yaml), исходники маршрутов в `internal/httpapi/`.
|
||||||
- **Разработчик фронтенда** — [quickstart.md](quickstart.md) (раздел про `web/` и CORS), [api.md](api.md), [../web/README.md](../web/README.md).
|
- **Разработчик фронтенда** — [quickstart.md](quickstart.md) (раздел про `web/` и CORS), [api.md](api.md), [../web/README.md](../web/README.md).
|
||||||
|
|||||||
+9
-7
@@ -556,10 +556,12 @@ components:
|
|||||||
properties:
|
properties:
|
||||||
id:
|
id:
|
||||||
$ref: "#/components/schemas/ResourceId"
|
$ref: "#/components/schemas/ResourceId"
|
||||||
name:
|
community:
|
||||||
type: string
|
type: string
|
||||||
kind:
|
description: Техническое значение BGP community (строка для BIRD, например 65001:120 или large JSON в value_json).
|
||||||
|
title:
|
||||||
type: string
|
type: string
|
||||||
|
description: Человекочитаемое название для UI и фильтров.
|
||||||
additionalProperties: true
|
additionalProperties: true
|
||||||
|
|
||||||
BgpPeer:
|
BgpPeer:
|
||||||
@@ -768,20 +770,20 @@ components:
|
|||||||
|
|
||||||
BgpCommunityCreate:
|
BgpCommunityCreate:
|
||||||
type: object
|
type: object
|
||||||
required: [name]
|
required: [community]
|
||||||
properties:
|
properties:
|
||||||
name:
|
community:
|
||||||
type: string
|
type: string
|
||||||
kind:
|
title:
|
||||||
type: string
|
type: string
|
||||||
additionalProperties: true
|
additionalProperties: true
|
||||||
|
|
||||||
BgpCommunityPatch:
|
BgpCommunityPatch:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
name:
|
community:
|
||||||
type: string
|
type: string
|
||||||
kind:
|
title:
|
||||||
type: string
|
type: string
|
||||||
additionalProperties: true
|
additionalProperties: true
|
||||||
|
|
||||||
|
|||||||
@@ -576,7 +576,7 @@ func commJSON(x *store.Community) map[string]any {
|
|||||||
if err := json.Unmarshal([]byte(x.ValueJSON), &v); err != nil {
|
if err := json.Unmarshal([]byte(x.ValueJSON), &v); err != nil {
|
||||||
v = x.ValueJSON
|
v = x.ValueJSON
|
||||||
}
|
}
|
||||||
return map[string]any{"id": x.ID, "name": x.Name, "kind": x.Kind, "value_json": v}
|
return map[string]any{"id": x.ID, "community": x.Community, "title": x.Title, "value_json": v}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleGetComm(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleGetComm(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
@@ -90,5 +90,5 @@ func communityRouteBody(st store.Backend, tenantID string, cid *string) (string,
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("community %s: %w", id, err)
|
return "", fmt.Errorf("community %s: %w", id, err)
|
||||||
}
|
}
|
||||||
return birdfmt.RouteCommunityAttrs(c.Kind, c.Name, c.ValueJSON)
|
return birdfmt.RouteCommunityAttrs("", c.Community, c.ValueJSON)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ func MaterializedASPrefixKey(asn int64) string {
|
|||||||
|
|
||||||
// RefreshModule runs ingest (where applicable) for one module, then renders a new revision whose
|
// RefreshModule runs ingest (where applicable) for one module, then renders a new revision whose
|
||||||
// BIRD materialization includes prefixes from all enabled modules of the tenant (others via live collect).
|
// BIRD materialization includes prefixes from all enabled modules of the tenant (others via live collect).
|
||||||
|
// If the tenant-wide materialized prefix set is unchanged from the latest revision, returns that
|
||||||
|
// revision id and does not insert a duplicate config_revision.
|
||||||
func RefreshModule(ctx context.Context, st store.Backend, hc *http.Client, tenantID, moduleID string) (revisionID string, err error) {
|
func RefreshModule(ctx context.Context, st store.Backend, hc *http.Client, tenantID, moduleID string) (revisionID string, err error) {
|
||||||
if hc == nil {
|
if hc == nil {
|
||||||
hc = http.DefaultClient
|
hc = http.DefaultClient
|
||||||
@@ -50,13 +52,17 @@ func RefreshModule(ctx context.Context, st store.Backend, hc *http.Client, tenan
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
revisionID = uuid.NewString()
|
|
||||||
parent := parentRevision(st, tenantID, moduleID)
|
|
||||||
agg, err := aggregateTenantPrefixRows(ctx, st, hc, tenantID, moduleID, rows)
|
agg, err := aggregateTenantPrefixRows(ctx, st, hc, tenantID, moduleID, rows)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
hash := hashAggregatedMaterialization(tenantID, agg)
|
hash := hashAggregatedMaterialization(tenantID, agg)
|
||||||
|
if prev := latestTenantRevision(st, tenantID); prev != nil && prev.ContentHash == hash {
|
||||||
|
return prev.ID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
revisionID = uuid.NewString()
|
||||||
|
parent := parentRevision(st, tenantID, moduleID)
|
||||||
preview, err := buildPreviewFragments(st, tenantID, moduleID, revisionID, agg)
|
preview, err := buildPreviewFragments(st, tenantID, moduleID, revisionID, agg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
@@ -233,6 +239,15 @@ func parentRevision(st store.Backend, tenantID, moduleID string) *string {
|
|||||||
return &id
|
return &id
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// latestTenantRevision is the newest config_revision for the tenant (any module), or nil.
|
||||||
|
func latestTenantRevision(st store.Backend, tenantID string) *store.Revision {
|
||||||
|
items, _, _ := st.ListRevisions(tenantID, "", "", 1)
|
||||||
|
if len(items) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return items[0]
|
||||||
|
}
|
||||||
|
|
||||||
// hashAggregatedMaterialization hashes the full tenant-wide prefix set used for BIRD (all enabled modules).
|
// hashAggregatedMaterialization hashes the full tenant-wide prefix set used for BIRD (all enabled modules).
|
||||||
func hashAggregatedMaterialization(tenantID string, rows []store.PrefixRow) string {
|
func hashAggregatedMaterialization(tenantID string, rows []store.PrefixRow) string {
|
||||||
type line struct{ p, c, s string }
|
type line struct{ p, c, s string }
|
||||||
|
|||||||
@@ -25,27 +25,50 @@ func TestRefreshModule_AggregatesAllEnabledModules(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
if _, err := RefreshModule(ctx, m, http.DefaultClient, tenant, modIP); err != nil {
|
before, _, _ := m.ListRevisions(tenant, "", "", 200)
|
||||||
|
beforeN := len(before)
|
||||||
|
|
||||||
|
rev1, err := RefreshModule(ctx, m, http.DefaultClient, tenant, modIP)
|
||||||
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
revs, _, _ := m.ListRevisions(tenant, modIP, "", 1)
|
after1, _, _ := m.ListRevisions(tenant, "", "", 200)
|
||||||
if len(revs) == 0 {
|
if len(after1) != beforeN+1 {
|
||||||
t.Fatal("no revision")
|
t.Fatalf("first refresh: want one new tenant revision, got %d -> %d", beforeN, len(after1))
|
||||||
}
|
}
|
||||||
px, _, _ := m.ListRevisionPrefixes(tenant, revs[0].ID, "", 1000)
|
px, _, _ := m.ListRevisionPrefixes(tenant, rev1, "", 1000)
|
||||||
if len(px) != 2 {
|
if len(px) != 2 {
|
||||||
t.Fatalf("first refresh: want 2 aggregated prefixes, got %d: %+v", len(px), px)
|
t.Fatalf("first refresh: want 2 aggregated prefixes, got %d: %+v", len(px), px)
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := RefreshModule(ctx, m, http.DefaultClient, tenant, mod2.ID); err != nil {
|
rev2, err := RefreshModule(ctx, m, http.DefaultClient, tenant, mod2.ID)
|
||||||
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
revs2, _, _ := m.ListRevisions(tenant, mod2.ID, "", 1)
|
if rev2 != rev1 {
|
||||||
if len(revs2) == 0 {
|
t.Fatalf("second refresh: same materialization, want same revision id, got %s vs %s", rev2, rev1)
|
||||||
t.Fatal("no revision for mod2")
|
|
||||||
}
|
}
|
||||||
px2, _, _ := m.ListRevisionPrefixes(tenant, revs2[0].ID, "", 1000)
|
after2, _, _ := m.ListRevisions(tenant, "", "", 200)
|
||||||
if len(px2) != 2 {
|
if len(after2) != len(after1) {
|
||||||
t.Fatalf("second refresh: want 2 aggregated prefixes, got %d: %+v", len(px2), px2)
|
t.Fatalf("second refresh: want no extra revision, had %d now %d", len(after1), len(after2))
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := m.CreateIPRangeEntry(tenant, mod2.ID, &store.IPRangeEntry{Prefix: "10.0.1.0/24"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
rev3, err := RefreshModule(ctx, m, http.DefaultClient, tenant, mod2.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if rev3 == rev1 {
|
||||||
|
t.Fatal("after prefix change, expected a new revision")
|
||||||
|
}
|
||||||
|
after3, _, _ := m.ListRevisions(tenant, "", "", 200)
|
||||||
|
if len(after3) != len(after2)+1 {
|
||||||
|
t.Fatalf("third refresh: want one new revision, had %d now %d", len(after2), len(after3))
|
||||||
|
}
|
||||||
|
px3, _, _ := m.ListRevisionPrefixes(tenant, rev3, "", 1000)
|
||||||
|
if len(px3) != 3 {
|
||||||
|
t.Fatalf("third refresh: want 3 prefixes, got %d", len(px3))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -954,7 +954,7 @@ func (p *Postgres) DeleteDohProfile(tenantID, id string) error {
|
|||||||
|
|
||||||
func (p *Postgres) ListCommunities(tenantID string) ([]*store.Community, error) {
|
func (p *Postgres) ListCommunities(tenantID string) ([]*store.Community, error) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
rows, err := p.pool.Query(ctx, `SELECT id::text, name, kind, value_json::text FROM bgp_community WHERE tenant_id=$1`, tenantID)
|
rows, err := p.pool.Query(ctx, `SELECT id::text, community, title, value_json::text FROM bgp_community WHERE tenant_id=$1 ORDER BY COALESCE(NULLIF(trim(title), ''), community)`, tenantID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -963,7 +963,7 @@ func (p *Postgres) ListCommunities(tenantID string) ([]*store.Community, error)
|
|||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var c store.Community
|
var c store.Community
|
||||||
c.TenantID = tenantID
|
c.TenantID = tenantID
|
||||||
if err := rows.Scan(&c.ID, &c.Name, &c.Kind, &c.ValueJSON); err != nil {
|
if err := rows.Scan(&c.ID, &c.Community, &c.Title, &c.ValueJSON); err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
out = append(out, &c)
|
out = append(out, &c)
|
||||||
@@ -975,8 +975,8 @@ func (p *Postgres) GetCommunity(tenantID, id string) (*store.Community, error) {
|
|||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
var c store.Community
|
var c store.Community
|
||||||
c.TenantID = tenantID
|
c.TenantID = tenantID
|
||||||
err := p.pool.QueryRow(ctx, `SELECT id::text, name, kind, value_json::text FROM bgp_community WHERE id=$1 AND tenant_id=$2`, id, tenantID).Scan(
|
err := p.pool.QueryRow(ctx, `SELECT id::text, community, title, value_json::text FROM bgp_community WHERE id=$1 AND tenant_id=$2`, id, tenantID).Scan(
|
||||||
&c.ID, &c.Name, &c.Kind, &c.ValueJSON)
|
&c.ID, &c.Community, &c.Title, &c.ValueJSON)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return nil, store.ErrNotFound
|
return nil, store.ErrNotFound
|
||||||
@@ -990,14 +990,19 @@ func (p *Postgres) CreateCommunity(tenantID string, in *store.Community) (*store
|
|||||||
if in == nil {
|
if in == nil {
|
||||||
return nil, store.ErrInvalidInput
|
return nil, store.ErrInvalidInput
|
||||||
}
|
}
|
||||||
|
if strings.TrimSpace(in.Community) == "" {
|
||||||
|
return nil, store.ErrInvalidInput
|
||||||
|
}
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
id := uuid.NewString()
|
id := uuid.NewString()
|
||||||
vj := in.ValueJSON
|
vj := in.ValueJSON
|
||||||
if strings.TrimSpace(vj) == "" {
|
if strings.TrimSpace(vj) == "" {
|
||||||
vj = "{}"
|
vj = "{}"
|
||||||
}
|
}
|
||||||
_, err := p.pool.Exec(ctx, `INSERT INTO bgp_community (id, tenant_id, name, kind, value_json) VALUES ($1,$2,$3,$4,$5::jsonb)`,
|
comm := strings.TrimSpace(in.Community)
|
||||||
id, tenantID, in.Name, in.Kind, vj)
|
title := strings.TrimSpace(in.Title)
|
||||||
|
_, err := p.pool.Exec(ctx, `INSERT INTO bgp_community (id, tenant_id, community, title, value_json) VALUES ($1,$2,$3,$4,$5::jsonb)`,
|
||||||
|
id, tenantID, comm, title, vj)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -1009,18 +1014,21 @@ func (p *Postgres) UpdateCommunity(tenantID, id string, patch *store.CommunityPa
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if patch.Name != nil {
|
if patch.Community != nil {
|
||||||
cur.Name = *patch.Name
|
cur.Community = strings.TrimSpace(*patch.Community)
|
||||||
}
|
}
|
||||||
if patch.Kind != nil {
|
if patch.Title != nil {
|
||||||
cur.Kind = *patch.Kind
|
cur.Title = strings.TrimSpace(*patch.Title)
|
||||||
}
|
}
|
||||||
if patch.ValueJSON != nil {
|
if patch.ValueJSON != nil {
|
||||||
cur.ValueJSON = *patch.ValueJSON
|
cur.ValueJSON = *patch.ValueJSON
|
||||||
}
|
}
|
||||||
|
if strings.TrimSpace(cur.Community) == "" {
|
||||||
|
return nil, store.ErrInvalidInput
|
||||||
|
}
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
_, err = p.pool.Exec(ctx, `UPDATE bgp_community SET name=$3, kind=$4, value_json=$5::jsonb, updated_at=now() WHERE id=$1 AND tenant_id=$2`,
|
_, err = p.pool.Exec(ctx, `UPDATE bgp_community SET community=$3, title=$4, value_json=$5::jsonb, updated_at=now() WHERE id=$1 AND tenant_id=$2`,
|
||||||
id, tenantID, cur.Name, cur.Kind, cur.ValueJSON)
|
id, tenantID, cur.Community, cur.Title, cur.ValueJSON)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ protocol direct {
|
|||||||
if _, err := tx.Exec(ctx, `INSERT INTO tenant (id, name, slug) VALUES ($1,'Demo','demo')`, tid); err != nil {
|
if _, err := tx.Exec(ctx, `INSERT INTO tenant (id, name, slug) VALUES ($1,'Demo','demo')`, tid); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if _, err := tx.Exec(ctx, `INSERT INTO bgp_community (id, tenant_id, name, kind, value_json) VALUES ($1,$2,'demo-comm','large','{}')`, cid, tid); err != nil {
|
if _, err := tx.Exec(ctx, `INSERT INTO bgp_community (id, tenant_id, community, title, value_json) VALUES ($1,$2,'demo-comm','Demo','{}')`, cid, tid); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if _, err := tx.Exec(ctx, `
|
if _, err := tx.Exec(ctx, `
|
||||||
|
|||||||
@@ -173,14 +173,14 @@ type DohProfilePatch struct {
|
|||||||
type Community struct {
|
type Community struct {
|
||||||
ID string `json:"id,omitempty"`
|
ID string `json:"id,omitempty"`
|
||||||
TenantID string `json:"tenant_id,omitempty"`
|
TenantID string `json:"tenant_id,omitempty"`
|
||||||
Name string `json:"name"`
|
Community string `json:"community"`
|
||||||
Kind string `json:"kind"`
|
Title string `json:"title"`
|
||||||
ValueJSON string `json:"value_json"`
|
ValueJSON string `json:"value_json"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type CommunityPatch struct {
|
type CommunityPatch struct {
|
||||||
Name *string `json:"name,omitempty"`
|
Community *string `json:"community,omitempty"`
|
||||||
Kind *string `json:"kind,omitempty"`
|
Title *string `json:"title,omitempty"`
|
||||||
ValueJSON *string `json:"value_json,omitempty"`
|
ValueJSON *string `json:"value_json,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -235,7 +235,7 @@ protocol direct {
|
|||||||
|
|
||||||
// Demo materialized prefixes for /revisions/{id}/prefixes
|
// Demo materialized prefixes for /revisions/{id}/prefixes
|
||||||
cid := uuid.NewString()
|
cid := uuid.NewString()
|
||||||
m.communities[cid] = &Community{ID: cid, TenantID: tid, Name: "demo-comm", Kind: "large", ValueJSON: "{}"}
|
m.communities[cid] = &Community{ID: cid, TenantID: tid, Community: "demo-comm", Title: "Demo", ValueJSON: "{}"}
|
||||||
m.revPrefixes[rid] = []PrefixRow{
|
m.revPrefixes[rid] = []PrefixRow{
|
||||||
{Prefix: "203.0.113.0/24", CommunityID: &cid, Source: "demo"},
|
{Prefix: "203.0.113.0/24", CommunityID: &cid, Source: "demo"},
|
||||||
{Prefix: "2001:db8::/32", CommunityID: &cid, Source: "demo"},
|
{Prefix: "2001:db8::/32", CommunityID: &cid, Source: "demo"},
|
||||||
|
|||||||
@@ -569,7 +569,7 @@ func (m *Memory) GetCommunity(tenantID, id string) (*Community, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *Memory) CreateCommunity(tenantID string, in *Community) (*Community, error) {
|
func (m *Memory) CreateCommunity(tenantID string, in *Community) (*Community, error) {
|
||||||
if in == nil || strings.TrimSpace(in.Kind) == "" {
|
if in == nil || strings.TrimSpace(in.Community) == "" {
|
||||||
return nil, ErrInvalidInput
|
return nil, ErrInvalidInput
|
||||||
}
|
}
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
@@ -582,7 +582,7 @@ func (m *Memory) CreateCommunity(tenantID string, in *Community) (*Community, er
|
|||||||
if strings.TrimSpace(vj) == "" {
|
if strings.TrimSpace(vj) == "" {
|
||||||
vj = "{}"
|
vj = "{}"
|
||||||
}
|
}
|
||||||
c := &Community{ID: id, TenantID: tenantID, Name: in.Name, Kind: strings.TrimSpace(in.Kind), ValueJSON: vj}
|
c := &Community{ID: id, TenantID: tenantID, Community: strings.TrimSpace(in.Community), Title: strings.TrimSpace(in.Title), ValueJSON: vj}
|
||||||
m.communities[id] = c
|
m.communities[id] = c
|
||||||
return c, nil
|
return c, nil
|
||||||
}
|
}
|
||||||
@@ -597,11 +597,11 @@ func (m *Memory) UpdateCommunity(tenantID, id string, patch *CommunityPatch) (*C
|
|||||||
if !ok || c.TenantID != tenantID {
|
if !ok || c.TenantID != tenantID {
|
||||||
return nil, ErrNotFound
|
return nil, ErrNotFound
|
||||||
}
|
}
|
||||||
if patch.Name != nil {
|
if patch.Community != nil {
|
||||||
c.Name = *patch.Name
|
c.Community = strings.TrimSpace(*patch.Community)
|
||||||
}
|
}
|
||||||
if patch.Kind != nil {
|
if patch.Title != nil {
|
||||||
c.Kind = strings.TrimSpace(*patch.Kind)
|
c.Title = strings.TrimSpace(*patch.Title)
|
||||||
}
|
}
|
||||||
if patch.ValueJSON != nil {
|
if patch.ValueJSON != nil {
|
||||||
c.ValueJSON = *patch.ValueJSON
|
c.ValueJSON = *patch.ValueJSON
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
ALTER TABLE bgp_community RENAME COLUMN community TO name;
|
||||||
|
|
||||||
|
ALTER TABLE bgp_community ADD COLUMN kind TEXT NOT NULL DEFAULT 'large';
|
||||||
|
|
||||||
|
UPDATE bgp_community SET kind = 'large' WHERE trim(kind) = '';
|
||||||
|
|
||||||
|
ALTER TABLE bgp_community DROP COLUMN title;
|
||||||
|
|
||||||
|
ALTER TABLE bgp_community ADD CONSTRAINT bgp_community_kind_chk CHECK (length(trim(kind)) > 0);
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
-- Техническое значение BGP в колонке community; человекочитаемое имя в title; поле kind удалено.
|
||||||
|
ALTER TABLE bgp_community ADD COLUMN title TEXT NOT NULL DEFAULT '';
|
||||||
|
|
||||||
|
UPDATE bgp_community SET title = trim(name) WHERE title = '';
|
||||||
|
|
||||||
|
ALTER TABLE bgp_community DROP CONSTRAINT bgp_community_kind_chk;
|
||||||
|
|
||||||
|
ALTER TABLE bgp_community DROP COLUMN kind;
|
||||||
|
|
||||||
|
ALTER TABLE bgp_community RENAME COLUMN name TO community;
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
ALTER TABLE bgp_community RENAME COLUMN community TO name;
|
||||||
|
|
||||||
|
ALTER TABLE bgp_community ADD COLUMN kind TEXT NOT NULL DEFAULT 'large';
|
||||||
|
|
||||||
|
ALTER TABLE bgp_community DROP COLUMN title;
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
-- Параллельно PostgreSQL: community + title, без kind (SQLite 3.35+).
|
||||||
|
ALTER TABLE bgp_community ADD COLUMN title TEXT NOT NULL DEFAULT '';
|
||||||
|
|
||||||
|
UPDATE bgp_community SET title = trim(name) WHERE title = '';
|
||||||
|
|
||||||
|
ALTER TABLE bgp_community DROP COLUMN kind;
|
||||||
|
|
||||||
|
ALTER TABLE bgp_community RENAME COLUMN name TO community;
|
||||||
@@ -113,12 +113,12 @@ export type DohProfilesResponse = Page<DohProfile>;
|
|||||||
// ---- Communities ----
|
// ---- Communities ----
|
||||||
export type BgpCommunity = {
|
export type BgpCommunity = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
community: string;
|
||||||
kind: string | null;
|
title: string;
|
||||||
};
|
};
|
||||||
export type BgpCommunityCreate = {
|
export type BgpCommunityCreate = {
|
||||||
name: string;
|
community: string;
|
||||||
kind?: string;
|
title?: string;
|
||||||
};
|
};
|
||||||
export type BgpCommunityPatch = Partial<BgpCommunityCreate>;
|
export type BgpCommunityPatch = Partial<BgpCommunityCreate>;
|
||||||
export type CommunitiesResponse = Page<BgpCommunity>;
|
export type CommunitiesResponse = Page<BgpCommunity>;
|
||||||
|
|||||||
@@ -9,7 +9,6 @@
|
|||||||
DohProfileCreate,
|
DohProfileCreate,
|
||||||
DohProfilesResponse
|
DohProfilesResponse
|
||||||
} from '$lib/api/types.js';
|
} from '$lib/api/types.js';
|
||||||
import { Badge } from '$lib/components/ui/badge/index.js';
|
|
||||||
import { Button } from '$lib/components/ui/button/index.js';
|
import { Button } from '$lib/components/ui/button/index.js';
|
||||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '$lib/components/ui/card/index.js';
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '$lib/components/ui/card/index.js';
|
||||||
import { Input } from '$lib/components/ui/input/index.js';
|
import { Input } from '$lib/components/ui/input/index.js';
|
||||||
@@ -52,7 +51,7 @@
|
|||||||
let commLoading = $state(false);
|
let commLoading = $state(false);
|
||||||
let commDialog = $state(false);
|
let commDialog = $state(false);
|
||||||
let commEdit = $state<BgpCommunity | null>(null);
|
let commEdit = $state<BgpCommunity | null>(null);
|
||||||
let commForm = $state<BgpCommunityCreate>({ name: '', kind: '' });
|
let commForm = $state<BgpCommunityCreate>({ community: '', title: '' });
|
||||||
let commSaving = $state(false);
|
let commSaving = $state(false);
|
||||||
let commDeleteTarget = $state<BgpCommunity | null>(null);
|
let commDeleteTarget = $state<BgpCommunity | null>(null);
|
||||||
|
|
||||||
@@ -92,21 +91,27 @@
|
|||||||
onMount(() => { loadComm(); loadDoh(); });
|
onMount(() => { loadComm(); loadDoh(); });
|
||||||
|
|
||||||
// --- Community actions ---
|
// --- Community actions ---
|
||||||
|
function commDisplay(c: BgpCommunity | null) {
|
||||||
|
if (!c) return '';
|
||||||
|
const t = c.title?.trim();
|
||||||
|
return t || c.community;
|
||||||
|
}
|
||||||
|
|
||||||
function openCommCreate() {
|
function openCommCreate() {
|
||||||
commEdit = null;
|
commEdit = null;
|
||||||
commForm = { name: '', kind: '' };
|
commForm = { community: '', title: '' };
|
||||||
commDialog = true;
|
commDialog = true;
|
||||||
}
|
}
|
||||||
function openCommEdit(c: BgpCommunity) {
|
function openCommEdit(c: BgpCommunity) {
|
||||||
commEdit = c;
|
commEdit = c;
|
||||||
commForm = { name: c.name, kind: c.kind ?? '' };
|
commForm = { community: c.community, title: c.title ?? '' };
|
||||||
commDialog = true;
|
commDialog = true;
|
||||||
}
|
}
|
||||||
async function saveComm() {
|
async function saveComm() {
|
||||||
if (!commForm.name.trim()) { toast.error('Укажите название'); return; }
|
if (!commForm.community.trim()) { toast.error('Укажите community'); return; }
|
||||||
commSaving = true;
|
commSaving = true;
|
||||||
try {
|
try {
|
||||||
const body = { ...commForm, kind: commForm.kind || undefined };
|
const body = { ...commForm, title: commForm.title?.trim() || undefined };
|
||||||
if (commEdit) {
|
if (commEdit) {
|
||||||
await apiMutate(`/v1/communities/${commEdit.id}`, 'PATCH', body);
|
await apiMutate(`/v1/communities/${commEdit.id}`, 'PATCH', body);
|
||||||
toast.success('Community обновлена');
|
toast.success('Community обновлена');
|
||||||
@@ -208,8 +213,8 @@
|
|||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
|
<TableHead>Community</TableHead>
|
||||||
<TableHead>Название</TableHead>
|
<TableHead>Название</TableHead>
|
||||||
<TableHead>Вид</TableHead>
|
|
||||||
<TableHead>ID</TableHead>
|
<TableHead>ID</TableHead>
|
||||||
<TableHead class="w-20"></TableHead>
|
<TableHead class="w-20"></TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@@ -217,14 +222,8 @@
|
|||||||
<TableBody>
|
<TableBody>
|
||||||
{#each communities as c (c.id)}
|
{#each communities as c (c.id)}
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell class="font-medium">{c.name}</TableCell>
|
<TableCell class="font-mono text-sm font-medium">{c.community}</TableCell>
|
||||||
<TableCell>
|
<TableCell>{c.title?.trim() || '—'}</TableCell>
|
||||||
{#if c.kind}
|
|
||||||
<Badge variant="outline">{c.kind}</Badge>
|
|
||||||
{:else}
|
|
||||||
<span class="text-muted-foreground text-sm">—</span>
|
|
||||||
{/if}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell class="font-mono text-xs text-muted-foreground">{c.id}</TableCell>
|
<TableCell class="font-mono text-xs text-muted-foreground">{c.id}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div class="flex gap-1">
|
<div class="flex gap-1">
|
||||||
@@ -307,12 +306,12 @@
|
|||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div class="space-y-4 py-2">
|
<div class="space-y-4 py-2">
|
||||||
<div class="space-y-1.5">
|
<div class="space-y-1.5">
|
||||||
<Label for="c-name">Название</Label>
|
<Label for="c-community">Community</Label>
|
||||||
<Input id="c-name" bind:value={commForm.name} placeholder="rt:65000:100" />
|
<Input id="c-community" bind:value={commForm.community} placeholder="65001:120" />
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-1.5">
|
<div class="space-y-1.5">
|
||||||
<Label for="c-kind">Вид (опционально)</Label>
|
<Label for="c-title">Название</Label>
|
||||||
<Input id="c-kind" bind:value={commForm.kind} placeholder="large / standard" />
|
<Input id="c-title" bind:value={commForm.title} placeholder="Человекочитаемое имя для списков" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
@@ -325,7 +324,7 @@
|
|||||||
<AlertDialog open={!!commDeleteTarget} onOpenChange={(v) => { if (!v) commDeleteTarget = null; }}>
|
<AlertDialog open={!!commDeleteTarget} onOpenChange={(v) => { if (!v) commDeleteTarget = null; }}>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>Удалить community «{commDeleteTarget?.name}»?</AlertDialogTitle>
|
<AlertDialogTitle>Удалить community «{commDisplay(commDeleteTarget)}»?</AlertDialogTitle>
|
||||||
<AlertDialogDescription>Это приведёт к удалению привязки во всех модулях.</AlertDialogDescription>
|
<AlertDialogDescription>Это приведёт к удалению привязки во всех модулях.</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
|
|||||||
@@ -390,9 +390,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function communityName(id: string | null) {
|
function communityLabel(id: string | null) {
|
||||||
if (!id) return '—';
|
if (!id) return '—';
|
||||||
return communities.find((c) => c.id === id)?.name ?? id.slice(0, 8) + '…';
|
const c = communities.find((x) => x.id === id);
|
||||||
|
if (!c) return id.slice(0, 8) + '…';
|
||||||
|
const t = c.title?.trim();
|
||||||
|
return t || c.community;
|
||||||
|
}
|
||||||
|
|
||||||
|
function communityOptionLabel(c: BgpCommunity) {
|
||||||
|
const t = c.title?.trim();
|
||||||
|
return t || c.community;
|
||||||
}
|
}
|
||||||
|
|
||||||
const activeTab = $derived.by(() => {
|
const activeTab = $derived.by(() => {
|
||||||
@@ -464,7 +472,7 @@
|
|||||||
</Card>
|
</Card>
|
||||||
<Card class="p-3">
|
<Card class="p-3">
|
||||||
<p class="text-muted-foreground text-xs">Community по умолч.</p>
|
<p class="text-muted-foreground text-xs">Community по умолч.</p>
|
||||||
<p class="font-semibold text-sm">{communityName(mod.default_community_id)}</p>
|
<p class="font-semibold text-sm">{communityLabel(mod.default_community_id)}</p>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -510,7 +518,7 @@
|
|||||||
? new Date(entry.asn_resolved_at).toLocaleString('ru-RU')
|
? new Date(entry.asn_resolved_at).toLocaleString('ru-RU')
|
||||||
: '—'}
|
: '—'}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell class="text-muted-foreground text-sm">{communityName(entry.community_id)}</TableCell>
|
<TableCell class="text-muted-foreground text-sm">{communityLabel(entry.community_id)}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div class="flex gap-1">
|
<div class="flex gap-1">
|
||||||
<Button variant="ghost" size="icon-sm" onclick={() => openAsEdit(entry)}><Pencil class="size-3.5" /></Button>
|
<Button variant="ghost" size="icon-sm" onclick={() => openAsEdit(entry)}><Pencil class="size-3.5" /></Button>
|
||||||
@@ -552,7 +560,7 @@
|
|||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell class="font-mono text-xs max-w-xs truncate">{src.url}</TableCell>
|
<TableCell class="font-mono text-xs max-w-xs truncate">{src.url}</TableCell>
|
||||||
<TableCell><Badge variant="outline">{src.source_kind}</Badge></TableCell>
|
<TableCell><Badge variant="outline">{src.source_kind}</Badge></TableCell>
|
||||||
<TableCell class="text-muted-foreground text-sm">{communityName(src.community_id)}</TableCell>
|
<TableCell class="text-muted-foreground text-sm">{communityLabel(src.community_id)}</TableCell>
|
||||||
<TableCell class="text-muted-foreground text-sm">{src.refresh_interval_sec ? `${src.refresh_interval_sec}с` : '—'}</TableCell>
|
<TableCell class="text-muted-foreground text-sm">{src.refresh_interval_sec ? `${src.refresh_interval_sec}с` : '—'}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div class="flex gap-1">
|
<div class="flex gap-1">
|
||||||
@@ -592,7 +600,7 @@
|
|||||||
{#each domainEntries as entry (entry.id)}
|
{#each domainEntries as entry (entry.id)}
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell class="font-mono">{entry.fqdn}</TableCell>
|
<TableCell class="font-mono">{entry.fqdn}</TableCell>
|
||||||
<TableCell class="text-muted-foreground text-sm">{communityName(entry.community_id)}</TableCell>
|
<TableCell class="text-muted-foreground text-sm">{communityLabel(entry.community_id)}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div class="flex gap-1">
|
<div class="flex gap-1">
|
||||||
<Button variant="ghost" size="icon-sm" onclick={() => openDomainEdit(entry)}><Pencil class="size-3.5" /></Button>
|
<Button variant="ghost" size="icon-sm" onclick={() => openDomainEdit(entry)}><Pencil class="size-3.5" /></Button>
|
||||||
@@ -631,7 +639,7 @@
|
|||||||
{#each ipEntries as entry (entry.id)}
|
{#each ipEntries as entry (entry.id)}
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell class="font-mono">{entry.prefix}</TableCell>
|
<TableCell class="font-mono">{entry.prefix}</TableCell>
|
||||||
<TableCell class="text-muted-foreground text-sm">{communityName(entry.community_id)}</TableCell>
|
<TableCell class="text-muted-foreground text-sm">{communityLabel(entry.community_id)}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div class="flex gap-1">
|
<div class="flex gap-1">
|
||||||
<Button variant="ghost" size="icon-sm" onclick={() => openIpEdit(entry)}><Pencil class="size-3.5" /></Button>
|
<Button variant="ghost" size="icon-sm" onclick={() => openIpEdit(entry)}><Pencil class="size-3.5" /></Button>
|
||||||
@@ -683,12 +691,12 @@
|
|||||||
<Label for="e-comm">Community по умолчанию</Label>
|
<Label for="e-comm">Community по умолчанию</Label>
|
||||||
<Select type="single" value={editForm.default_community_id ?? ''} onValueChange={(v) => { editForm.default_community_id = v || null; }}>
|
<Select type="single" value={editForm.default_community_id ?? ''} onValueChange={(v) => { editForm.default_community_id = v || null; }}>
|
||||||
<SelectTrigger id="e-comm" class="w-full">
|
<SelectTrigger id="e-comm" class="w-full">
|
||||||
{editForm.default_community_id ? communityName(editForm.default_community_id) : 'Не выбрано'}
|
{editForm.default_community_id ? communityLabel(editForm.default_community_id) : 'Не выбрано'}
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="">Не выбрано</SelectItem>
|
<SelectItem value="">Не выбрано</SelectItem>
|
||||||
{#each communities as c}
|
{#each communities as c}
|
||||||
<SelectItem value={c.id}>{c.name}</SelectItem>
|
<SelectItem value={c.id}>{communityOptionLabel(c)}</SelectItem>
|
||||||
{/each}
|
{/each}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
@@ -765,12 +773,12 @@
|
|||||||
<Label for="as-comm">Community</Label>
|
<Label for="as-comm">Community</Label>
|
||||||
<Select type="single" value={asForm.community_id ?? ''} onValueChange={(v) => { asForm.community_id = v || null; }}>
|
<Select type="single" value={asForm.community_id ?? ''} onValueChange={(v) => { asForm.community_id = v || null; }}>
|
||||||
<SelectTrigger id="as-comm" class="w-full">
|
<SelectTrigger id="as-comm" class="w-full">
|
||||||
{asForm.community_id ? communityName(asForm.community_id) : 'Не выбрано'}
|
{asForm.community_id ? communityLabel(asForm.community_id) : 'Не выбрано'}
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="">Не выбрано</SelectItem>
|
<SelectItem value="">Не выбрано</SelectItem>
|
||||||
{#each communities as c}
|
{#each communities as c}
|
||||||
<SelectItem value={c.id}>{c.name}</SelectItem>
|
<SelectItem value={c.id}>{communityOptionLabel(c)}</SelectItem>
|
||||||
{/each}
|
{/each}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
@@ -815,12 +823,12 @@
|
|||||||
<Label for="cdn-comm">Community</Label>
|
<Label for="cdn-comm">Community</Label>
|
||||||
<Select type="single" value={cdnForm.community_id ?? ''} onValueChange={(v) => { cdnForm.community_id = v || null; }}>
|
<Select type="single" value={cdnForm.community_id ?? ''} onValueChange={(v) => { cdnForm.community_id = v || null; }}>
|
||||||
<SelectTrigger id="cdn-comm" class="w-full">
|
<SelectTrigger id="cdn-comm" class="w-full">
|
||||||
{cdnForm.community_id ? communityName(cdnForm.community_id) : 'Не выбрано'}
|
{cdnForm.community_id ? communityLabel(cdnForm.community_id) : 'Не выбрано'}
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="">Не выбрано</SelectItem>
|
<SelectItem value="">Не выбрано</SelectItem>
|
||||||
{#each communities as c}
|
{#each communities as c}
|
||||||
<SelectItem value={c.id}>{c.name}</SelectItem>
|
<SelectItem value={c.id}>{communityOptionLabel(c)}</SelectItem>
|
||||||
{/each}
|
{/each}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
@@ -865,12 +873,12 @@
|
|||||||
<Label for="dom-comm">Community</Label>
|
<Label for="dom-comm">Community</Label>
|
||||||
<Select type="single" value={domainForm.community_id ?? ''} onValueChange={(v) => { domainForm.community_id = v || null; }}>
|
<Select type="single" value={domainForm.community_id ?? ''} onValueChange={(v) => { domainForm.community_id = v || null; }}>
|
||||||
<SelectTrigger id="dom-comm" class="w-full">
|
<SelectTrigger id="dom-comm" class="w-full">
|
||||||
{domainForm.community_id ? communityName(domainForm.community_id) : 'Не выбрано'}
|
{domainForm.community_id ? communityLabel(domainForm.community_id) : 'Не выбрано'}
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="">Не выбрано</SelectItem>
|
<SelectItem value="">Не выбрано</SelectItem>
|
||||||
{#each communities as c}
|
{#each communities as c}
|
||||||
<SelectItem value={c.id}>{c.name}</SelectItem>
|
<SelectItem value={c.id}>{communityOptionLabel(c)}</SelectItem>
|
||||||
{/each}
|
{/each}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
@@ -911,11 +919,11 @@
|
|||||||
<Label for="ip-comm">Community (обязательно)</Label>
|
<Label for="ip-comm">Community (обязательно)</Label>
|
||||||
<Select type="single" value={ipForm.community_id} onValueChange={(v) => { ipForm.community_id = v; }}>
|
<Select type="single" value={ipForm.community_id} onValueChange={(v) => { ipForm.community_id = v; }}>
|
||||||
<SelectTrigger id="ip-comm" class="w-full">
|
<SelectTrigger id="ip-comm" class="w-full">
|
||||||
{ipForm.community_id ? communityName(ipForm.community_id) : 'Выберите community'}
|
{ipForm.community_id ? communityLabel(ipForm.community_id) : 'Выберите community'}
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{#each communities as c}
|
{#each communities as c}
|
||||||
<SelectItem value={c.id}>{c.name}</SelectItem>
|
<SelectItem value={c.id}>{communityOptionLabel(c)}</SelectItem>
|
||||||
{/each}
|
{/each}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|||||||
Reference in New Issue
Block a user