diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..d0b5c1c --- /dev/null +++ b/AGENTS.md @@ -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, если подключены). diff --git a/README.md b/README.md index 0909095..900289f 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,7 @@ Control plane для управления префиксами, модулями | Документ | Содержание | |----------|------------| +| [AGENTS.md](AGENTS.md) | Краткая карта репозитория и советы для ИИ-агентов (экономия контекста) | | [docs/README.md](docs/README.md) | Оглавление и навигация по разделам | | [docs/overview.md](docs/overview.md) | Ключевые возможности продукта | | [docs/quickstart.md](docs/quickstart.md) | Быстрый запуск (Docker, локально, фронтенд) | diff --git a/docs/README.md b/docs/README.md index 5015409..68912ea 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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). - **Разработчик бэкенда или интегратор 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). diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 8767277..e3b4186 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -556,10 +556,12 @@ components: properties: id: $ref: "#/components/schemas/ResourceId" - name: + community: type: string - kind: + description: Техническое значение BGP community (строка для BIRD, например 65001:120 или large JSON в value_json). + title: type: string + description: Человекочитаемое название для UI и фильтров. additionalProperties: true BgpPeer: @@ -768,20 +770,20 @@ components: BgpCommunityCreate: type: object - required: [name] + required: [community] properties: - name: + community: type: string - kind: + title: type: string additionalProperties: true BgpCommunityPatch: type: object properties: - name: + community: type: string - kind: + title: type: string additionalProperties: true diff --git a/internal/httpapi/routes_crud.go b/internal/httpapi/routes_crud.go index 2b60b63..775b9a8 100644 --- a/internal/httpapi/routes_crud.go +++ b/internal/httpapi/routes_crud.go @@ -576,7 +576,7 @@ func commJSON(x *store.Community) map[string]any { if err := json.Unmarshal([]byte(x.ValueJSON), &v); err != nil { 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) { diff --git a/internal/pipeline/bird_materialize.go b/internal/pipeline/bird_materialize.go index acbca63..cebd228 100644 --- a/internal/pipeline/bird_materialize.go +++ b/internal/pipeline/bird_materialize.go @@ -90,5 +90,5 @@ func communityRouteBody(st store.Backend, tenantID string, cid *string) (string, if err != nil { return "", fmt.Errorf("community %s: %w", id, err) } - return birdfmt.RouteCommunityAttrs(c.Kind, c.Name, c.ValueJSON) + return birdfmt.RouteCommunityAttrs("", c.Community, c.ValueJSON) } diff --git a/internal/pipeline/refresh.go b/internal/pipeline/refresh.go index ce66a83..b55c9ec 100644 --- a/internal/pipeline/refresh.go +++ b/internal/pipeline/refresh.go @@ -33,6 +33,8 @@ func MaterializedASPrefixKey(asn int64) string { // 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). +// 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) { if hc == nil { hc = http.DefaultClient @@ -50,13 +52,17 @@ func RefreshModule(ctx context.Context, st store.Backend, hc *http.Client, tenan return "", err } - revisionID = uuid.NewString() - parent := parentRevision(st, tenantID, moduleID) agg, err := aggregateTenantPrefixRows(ctx, st, hc, tenantID, moduleID, rows) if err != nil { return "", err } 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) if err != nil { return "", err @@ -233,6 +239,15 @@ func parentRevision(st store.Backend, tenantID, moduleID string) *string { 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). func hashAggregatedMaterialization(tenantID string, rows []store.PrefixRow) string { type line struct{ p, c, s string } diff --git a/internal/pipeline/refresh_aggregate_test.go b/internal/pipeline/refresh_aggregate_test.go index a059d19..58e8a16 100644 --- a/internal/pipeline/refresh_aggregate_test.go +++ b/internal/pipeline/refresh_aggregate_test.go @@ -25,27 +25,50 @@ func TestRefreshModule_AggregatesAllEnabledModules(t *testing.T) { } 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) } - revs, _, _ := m.ListRevisions(tenant, modIP, "", 1) - if len(revs) == 0 { - t.Fatal("no revision") + after1, _, _ := m.ListRevisions(tenant, "", "", 200) + if len(after1) != beforeN+1 { + 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 { 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) } - revs2, _, _ := m.ListRevisions(tenant, mod2.ID, "", 1) - if len(revs2) == 0 { - t.Fatal("no revision for mod2") + if rev2 != rev1 { + t.Fatalf("second refresh: same materialization, want same revision id, got %s vs %s", rev2, rev1) } - px2, _, _ := m.ListRevisionPrefixes(tenant, revs2[0].ID, "", 1000) - if len(px2) != 2 { - t.Fatalf("second refresh: want 2 aggregated prefixes, got %d: %+v", len(px2), px2) + after2, _, _ := m.ListRevisions(tenant, "", "", 200) + if len(after2) != len(after1) { + 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)) } } diff --git a/internal/repository/postgres.go b/internal/repository/postgres.go index 302c75e..3c1e31e 100644 --- a/internal/repository/postgres.go +++ b/internal/repository/postgres.go @@ -954,7 +954,7 @@ func (p *Postgres) DeleteDohProfile(tenantID, id string) error { func (p *Postgres) ListCommunities(tenantID string) ([]*store.Community, error) { 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 { return nil, err } @@ -963,7 +963,7 @@ func (p *Postgres) ListCommunities(tenantID string) ([]*store.Community, error) for rows.Next() { var c store.Community 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 } out = append(out, &c) @@ -975,8 +975,8 @@ func (p *Postgres) GetCommunity(tenantID, id string) (*store.Community, error) { ctx := context.Background() var c store.Community 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( - &c.ID, &c.Name, &c.Kind, &c.ValueJSON) + 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.Community, &c.Title, &c.ValueJSON) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil, store.ErrNotFound @@ -990,14 +990,19 @@ func (p *Postgres) CreateCommunity(tenantID string, in *store.Community) (*store if in == nil { return nil, store.ErrInvalidInput } + if strings.TrimSpace(in.Community) == "" { + return nil, store.ErrInvalidInput + } ctx := context.Background() id := uuid.NewString() vj := in.ValueJSON if strings.TrimSpace(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)`, - id, tenantID, in.Name, in.Kind, vj) + comm := strings.TrimSpace(in.Community) + 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 { return nil, err } @@ -1009,18 +1014,21 @@ func (p *Postgres) UpdateCommunity(tenantID, id string, patch *store.CommunityPa if err != nil { return nil, err } - if patch.Name != nil { - cur.Name = *patch.Name + if patch.Community != nil { + cur.Community = strings.TrimSpace(*patch.Community) } - if patch.Kind != nil { - cur.Kind = *patch.Kind + if patch.Title != nil { + cur.Title = strings.TrimSpace(*patch.Title) } if patch.ValueJSON != nil { cur.ValueJSON = *patch.ValueJSON } + if strings.TrimSpace(cur.Community) == "" { + return nil, store.ErrInvalidInput + } 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`, - id, tenantID, cur.Name, cur.Kind, cur.ValueJSON) + _, 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.Community, cur.Title, cur.ValueJSON) if err != nil { return nil, err } diff --git a/internal/repository/postgres_seed.go b/internal/repository/postgres_seed.go index d9af715..593412c 100644 --- a/internal/repository/postgres_seed.go +++ b/internal/repository/postgres_seed.go @@ -58,7 +58,7 @@ protocol direct { if _, err := tx.Exec(ctx, `INSERT INTO tenant (id, name, slug) VALUES ($1,'Demo','demo')`, tid); err != nil { 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 } if _, err := tx.Exec(ctx, ` diff --git a/internal/store/backend.go b/internal/store/backend.go index 8409091..04d4136 100644 --- a/internal/store/backend.go +++ b/internal/store/backend.go @@ -173,14 +173,14 @@ type DohProfilePatch struct { type Community struct { ID string `json:"id,omitempty"` TenantID string `json:"tenant_id,omitempty"` - Name string `json:"name"` - Kind string `json:"kind"` + Community string `json:"community"` + Title string `json:"title"` ValueJSON string `json:"value_json"` } type CommunityPatch struct { - Name *string `json:"name,omitempty"` - Kind *string `json:"kind,omitempty"` + Community *string `json:"community,omitempty"` + Title *string `json:"title,omitempty"` ValueJSON *string `json:"value_json,omitempty"` } diff --git a/internal/store/memory.go b/internal/store/memory.go index 2f51c48..d14b692 100644 --- a/internal/store/memory.go +++ b/internal/store/memory.go @@ -235,7 +235,7 @@ protocol direct { // Demo materialized prefixes for /revisions/{id}/prefixes 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{ {Prefix: "203.0.113.0/24", CommunityID: &cid, Source: "demo"}, {Prefix: "2001:db8::/32", CommunityID: &cid, Source: "demo"}, diff --git a/internal/store/memory_crud.go b/internal/store/memory_crud.go index f643830..7a35b11 100644 --- a/internal/store/memory_crud.go +++ b/internal/store/memory_crud.go @@ -569,7 +569,7 @@ func (m *Memory) GetCommunity(tenantID, id string) (*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 } m.mu.Lock() @@ -582,7 +582,7 @@ func (m *Memory) CreateCommunity(tenantID string, in *Community) (*Community, er if strings.TrimSpace(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 return c, nil } @@ -597,11 +597,11 @@ func (m *Memory) UpdateCommunity(tenantID, id string, patch *CommunityPatch) (*C if !ok || c.TenantID != tenantID { return nil, ErrNotFound } - if patch.Name != nil { - c.Name = *patch.Name + if patch.Community != nil { + c.Community = strings.TrimSpace(*patch.Community) } - if patch.Kind != nil { - c.Kind = strings.TrimSpace(*patch.Kind) + if patch.Title != nil { + c.Title = strings.TrimSpace(*patch.Title) } if patch.ValueJSON != nil { c.ValueJSON = *patch.ValueJSON diff --git a/migrations/postgres/000005_bgp_community_title.down.sql b/migrations/postgres/000005_bgp_community_title.down.sql new file mode 100644 index 0000000..c6318fc --- /dev/null +++ b/migrations/postgres/000005_bgp_community_title.down.sql @@ -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); diff --git a/migrations/postgres/000005_bgp_community_title.up.sql b/migrations/postgres/000005_bgp_community_title.up.sql new file mode 100644 index 0000000..b3117f9 --- /dev/null +++ b/migrations/postgres/000005_bgp_community_title.up.sql @@ -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; diff --git a/migrations/sqlite/000005_bgp_community_title.down.sql b/migrations/sqlite/000005_bgp_community_title.down.sql new file mode 100644 index 0000000..650f56f --- /dev/null +++ b/migrations/sqlite/000005_bgp_community_title.down.sql @@ -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; diff --git a/migrations/sqlite/000005_bgp_community_title.up.sql b/migrations/sqlite/000005_bgp_community_title.up.sql new file mode 100644 index 0000000..b9a2884 --- /dev/null +++ b/migrations/sqlite/000005_bgp_community_title.up.sql @@ -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; diff --git a/web/src/lib/api/types.ts b/web/src/lib/api/types.ts index 5d29184..76c6ed7 100644 --- a/web/src/lib/api/types.ts +++ b/web/src/lib/api/types.ts @@ -113,12 +113,12 @@ export type DohProfilesResponse = Page; // ---- Communities ---- export type BgpCommunity = { id: string; - name: string; - kind: string | null; + community: string; + title: string; }; export type BgpCommunityCreate = { - name: string; - kind?: string; + community: string; + title?: string; }; export type BgpCommunityPatch = Partial; export type CommunitiesResponse = Page; diff --git a/web/src/routes/directories/+page.svelte b/web/src/routes/directories/+page.svelte index 3d0779a..56f4b90 100644 --- a/web/src/routes/directories/+page.svelte +++ b/web/src/routes/directories/+page.svelte @@ -9,7 +9,6 @@ DohProfileCreate, DohProfilesResponse } from '$lib/api/types.js'; - import { Badge } from '$lib/components/ui/badge/index.js'; import { Button } from '$lib/components/ui/button/index.js'; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '$lib/components/ui/card/index.js'; import { Input } from '$lib/components/ui/input/index.js'; @@ -52,7 +51,7 @@ let commLoading = $state(false); let commDialog = $state(false); let commEdit = $state(null); - let commForm = $state({ name: '', kind: '' }); + let commForm = $state({ community: '', title: '' }); let commSaving = $state(false); let commDeleteTarget = $state(null); @@ -92,21 +91,27 @@ onMount(() => { loadComm(); loadDoh(); }); // --- Community actions --- + function commDisplay(c: BgpCommunity | null) { + if (!c) return ''; + const t = c.title?.trim(); + return t || c.community; + } + function openCommCreate() { commEdit = null; - commForm = { name: '', kind: '' }; + commForm = { community: '', title: '' }; commDialog = true; } function openCommEdit(c: BgpCommunity) { commEdit = c; - commForm = { name: c.name, kind: c.kind ?? '' }; + commForm = { community: c.community, title: c.title ?? '' }; commDialog = true; } async function saveComm() { - if (!commForm.name.trim()) { toast.error('Укажите название'); return; } + if (!commForm.community.trim()) { toast.error('Укажите community'); return; } commSaving = true; try { - const body = { ...commForm, kind: commForm.kind || undefined }; + const body = { ...commForm, title: commForm.title?.trim() || undefined }; if (commEdit) { await apiMutate(`/v1/communities/${commEdit.id}`, 'PATCH', body); toast.success('Community обновлена'); @@ -208,8 +213,8 @@ + Community Название - Вид ID @@ -217,14 +222,8 @@ {#each communities as c (c.id)} - {c.name} - - {#if c.kind} - {c.kind} - {:else} - - {/if} - + {c.community} + {c.title?.trim() || '—'} {c.id}
@@ -307,12 +306,12 @@
- - + +
- - + +
@@ -325,7 +324,7 @@ { if (!v) commDeleteTarget = null; }}> - Удалить community «{commDeleteTarget?.name}»? + Удалить community «{commDisplay(commDeleteTarget)}»? Это приведёт к удалению привязки во всех модулях. diff --git a/web/src/routes/modules/[moduleId]/+page.svelte b/web/src/routes/modules/[moduleId]/+page.svelte index 0cb6c60..91be897 100644 --- a/web/src/routes/modules/[moduleId]/+page.svelte +++ b/web/src/routes/modules/[moduleId]/+page.svelte @@ -390,9 +390,17 @@ } } - function communityName(id: string | null) { + function communityLabel(id: string | null) { 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(() => { @@ -464,7 +472,7 @@

Community по умолч.

-

{communityName(mod.default_community_id)}

+

{communityLabel(mod.default_community_id)}

@@ -510,7 +518,7 @@ ? new Date(entry.asn_resolved_at).toLocaleString('ru-RU') : '—'}
- {communityName(entry.community_id)} + {communityLabel(entry.community_id)}
@@ -552,7 +560,7 @@ {src.url} {src.source_kind} - {communityName(src.community_id)} + {communityLabel(src.community_id)} {src.refresh_interval_sec ? `${src.refresh_interval_sec}с` : '—'}
@@ -592,7 +600,7 @@ {#each domainEntries as entry (entry.id)} {entry.fqdn} - {communityName(entry.community_id)} + {communityLabel(entry.community_id)}
@@ -631,7 +639,7 @@ {#each ipEntries as entry (entry.id)} {entry.prefix} - {communityName(entry.community_id)} + {communityLabel(entry.community_id)}
@@ -683,12 +691,12 @@ @@ -765,12 +773,12 @@ @@ -815,12 +823,12 @@ @@ -865,12 +873,12 @@ @@ -911,11 +919,11 @@