From e1aaa183774691f6839bbc67fa1eaf72428ae623 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Mon, 6 Apr 2026 00:01:16 +0700 Subject: [PATCH] feat: enhance RefreshModule to support legacy ASN resolution and improve prefix handling. Introduce sorting of ASNs and manage announced prefixes with polite pauses for better API interaction. Update buildPreviewFragments to streamline route rendering for BIRD configurations. --- internal/asnresolve/ripestat.go | 108 +++++++++++++++++++ internal/asnresolve/ripestat_test.go | 33 ++++++ internal/birdfmt/community_bird.go | 123 +++++++++++++++++++++ internal/birdfmt/community_bird_test.go | 23 ++++ internal/birdfmt/static_attrs.go | 137 ++++++++++++++++++++++++ internal/birdfmt/static_attrs_test.go | 20 ++++ internal/pipeline/bird_materialize.go | 94 ++++++++++++++++ internal/pipeline/refresh.go | 56 +++++----- web/src/routes/operations/+page.svelte | 99 +++++++++-------- 9 files changed, 625 insertions(+), 68 deletions(-) create mode 100644 internal/asnresolve/ripestat.go create mode 100644 internal/asnresolve/ripestat_test.go create mode 100644 internal/birdfmt/community_bird.go create mode 100644 internal/birdfmt/community_bird_test.go create mode 100644 internal/birdfmt/static_attrs.go create mode 100644 internal/birdfmt/static_attrs_test.go create mode 100644 internal/pipeline/bird_materialize.go diff --git a/internal/asnresolve/ripestat.go b/internal/asnresolve/ripestat.go new file mode 100644 index 0000000..6d9a94d --- /dev/null +++ b/internal/asnresolve/ripestat.go @@ -0,0 +1,108 @@ +// Package asnresolve fetches IP prefixes announced by an ASN (control-plane ingest). +package asnresolve + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/netip" + "os" + "sort" + "strconv" + "strings" + "time" +) + +// DefaultRIPEStatURL is the RIPEstat announced-prefixes data call (no API key). +const DefaultRIPEStatURL = "https://stat.ripe.net/data/announced-prefixes/data.json" + +// AnnouncedPrefixes returns currently announced IPv4/IPv6 prefixes for the ASN (best-effort via RIPEstat). +func AnnouncedPrefixes(ctx context.Context, hc *http.Client, asn int64) ([]netip.Prefix, error) { + if hc == nil { + hc = http.DefaultClient + } + base := strings.TrimSpace(os.Getenv("EVOBGP_RIPESTAT_ANNOUNCED_PREFIXES_URL")) + if base == "" { + base = DefaultRIPEStatURL + } + u := fmt.Sprintf("%s?resource=AS%d", strings.TrimSuffix(base, "?"), asn) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) + if err != nil { + return nil, err + } + req.Header.Set("Accept", "application/json") + req.Header.Set("User-Agent", "evobgp-asnresolve/1.0") + + resp, err := hc.Do(req) + if err != nil { + return nil, fmt.Errorf("ripestat fetch AS%d: %w", asn, err) + } + defer resp.Body.Close() + body, err := io.ReadAll(io.LimitReader(resp.Body, 32<<20)) + if err != nil { + return nil, err + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("ripestat AS%d: HTTP %s: %s", asn, resp.Status, truncateForErr(body, 200)) + } + + var wrap struct { + Status string `json:"status"` + Data struct { + Prefixes []struct { + Prefix string `json:"prefix"` + } `json:"prefixes"` + } `json:"data"` + Messages [][]string `json:"messages"` + } + if err := json.Unmarshal(body, &wrap); err != nil { + return nil, fmt.Errorf("ripestat AS%d: json: %w", asn, err) + } + if wrap.Status != "" && wrap.Status != "ok" { + return nil, fmt.Errorf("ripestat AS%d: status %q", asn, wrap.Status) + } + + var out []netip.Prefix + for _, row := range wrap.Data.Prefixes { + p := strings.TrimSpace(row.Prefix) + if p == "" { + continue + } + pfx, err := netip.ParsePrefix(p) + if err != nil { + continue + } + out = append(out, pfx.Masked()) + } + sort.Slice(out, func(i, j int) bool { return out[i].String() < out[j].String() }) + return out, nil +} + +func truncateForErr(b []byte, n int) string { + s := string(b) + if len(s) > n { + return s[:n] + "…" + } + return s +} + +// PolitePause is a short delay between upstream ASN lookups (same refresh). +func PolitePause() { + d := 150 * time.Millisecond + if s := strings.TrimSpace(os.Getenv("EVOBGP_ASN_RESOLVE_PAUSE_MS")); s != "" { + if ms, err := parsePositiveInt(s); err == nil && ms > 0 { + d = time.Duration(ms) * time.Millisecond + } + } + time.Sleep(d) +} + +func parsePositiveInt(s string) (int, error) { + n, err := strconv.Atoi(s) + if err != nil || n <= 0 { + return 0, fmt.Errorf("invalid") + } + return n, nil +} diff --git a/internal/asnresolve/ripestat_test.go b/internal/asnresolve/ripestat_test.go new file mode 100644 index 0000000..4d3a935 --- /dev/null +++ b/internal/asnresolve/ripestat_test.go @@ -0,0 +1,33 @@ +package asnresolve + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" +) + +func TestAnnouncedPrefixes_Mock(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Get("resource") != "AS64496" { + t.Fatalf("resource: %q", r.URL.Query().Get("resource")) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"status":"ok","data":{"prefixes":[{"prefix":"203.0.113.0/24"},{"prefix":"2001:db8::/32"}]}}`)) + })) + defer ts.Close() + + t.Setenv("EVOBGP_RIPESTAT_ANNOUNCED_PREFIXES_URL", ts.URL) + t.Setenv("EVOBGP_ASN_RESOLVE_PAUSE_MS", "1") + + pfx, err := AnnouncedPrefixes(context.Background(), ts.Client(), 64496) + if err != nil { + t.Fatal(err) + } + if len(pfx) != 2 { + t.Fatalf("got %d prefixes", len(pfx)) + } + if pfx[0].String() != "2001:db8::/32" || pfx[1].String() != "203.0.113.0/24" { + t.Fatalf("order or values: %#v", pfx) + } +} diff --git a/internal/birdfmt/community_bird.go b/internal/birdfmt/community_bird.go new file mode 100644 index 0000000..f0ed1b7 --- /dev/null +++ b/internal/birdfmt/community_bird.go @@ -0,0 +1,123 @@ +package birdfmt + +import ( + "encoding/json" + "fmt" + "regexp" + "strconv" + "strings" +) + +var standardPairRE = regexp.MustCompile(`^\s*(\d{1,5}):(\d{1,5})\s*$`) + +// RouteCommunityAttrs returns BIRD 2 lines (indented with 4 spaces) for use inside a static `route … { … }` block. +// Empty string means no community attributes. +func RouteCommunityAttrs(kind, name, valueJSON string) (string, error) { + name = strings.TrimSpace(name) + raw := strings.TrimSpace(valueJSON) + if raw == "" { + raw = "{}" + } + + if s, ok := parseLargeFromJSONLoose(raw); ok { + return s, nil + } + _ = kind // reserved for future kinds (e.g. extended communities) + + if m := standardPairRE.FindStringSubmatch(name); len(m) == 3 { + return standardAddLine(m[1], m[2]) + } + + var obj map[string]any + if json.Unmarshal([]byte(raw), &obj) == nil { + if v, ok := obj["standard"].(string); ok { + if m := standardPairRE.FindStringSubmatch(v); len(m) == 3 { + return standardAddLine(m[1], m[2]) + } + } + if arr, ok := obj["standard"].([]any); ok && len(arr) == 2 { + a, aok := numberToUint32String(arr[0]) + b, bok := numberToUint32String(arr[1]) + if aok && bok { + return standardAddLine(a, b) + } + } + } + + var strVal string + if json.Unmarshal([]byte(raw), &strVal) == nil { + if m := standardPairRE.FindStringSubmatch(strVal); len(m) == 3 { + return standardAddLine(m[1], m[2]) + } + } + + return "", nil +} + +func standardAddLine(a, b string) (string, error) { + ai, err1 := strconv.ParseUint(strings.TrimSpace(a), 10, 16) + bi, err2 := strconv.ParseUint(strings.TrimSpace(b), 10, 16) + if err1 != nil || err2 != nil { + return "", fmt.Errorf("birdfmt: standard community parts must be 0..65535 (%s:%s)", a, b) + } + return fmt.Sprintf(" bgp_community.add((%d,%d));", ai, bi), nil +} + +func parseLargeFromJSONLoose(raw string) (string, bool) { + var obj map[string]any + if json.Unmarshal([]byte(raw), &obj) != nil { + return "", false + } + arr, ok := obj["large"].([]any) + if !ok || len(arr) == 0 { + return "", false + } + var b strings.Builder + for _, row := range arr { + triple, ok := row.([]any) + if !ok || len(triple) != 3 { + continue + } + a, ok1 := numberToUint32String(triple[0]) + bb, ok2 := numberToUint32String(triple[1]) + c, ok3 := numberToUint32String(triple[2]) + if !ok1 || !ok2 || !ok3 { + continue + } + ai, _ := strconv.ParseUint(a, 10, 32) + bi, _ := strconv.ParseUint(bb, 10, 32) + ci, _ := strconv.ParseUint(c, 10, 32) + fmt.Fprintf(&b, " bgp_large_community.add((%d,%d,%d));\n", ai, bi, ci) + } + s := strings.TrimRight(b.String(), "\r\n") + return s, s != "" +} + +func numberToUint32String(v any) (string, bool) { + switch x := v.(type) { + case float64: + if x < 0 || x > 4294967295 { + return "", false + } + return strconv.FormatUint(uint64(x), 10), true + case json.Number: + n, err := x.Int64() + if err != nil || n < 0 || n > 4294967295 { + return "", false + } + return strconv.FormatUint(uint64(n), 10), true + case int: + if x < 0 || x > 4294967295 { + return "", false + } + return strconv.FormatUint(uint64(x), 10), true + case string: + n, err := strconv.ParseUint(strings.TrimSpace(x), 10, 32) + if err != nil { + return "", false + } + return strconv.FormatUint(n, 10), true + default: + return "", false + } +} diff --git a/internal/birdfmt/community_bird_test.go b/internal/birdfmt/community_bird_test.go new file mode 100644 index 0000000..f775c3b --- /dev/null +++ b/internal/birdfmt/community_bird_test.go @@ -0,0 +1,23 @@ +package birdfmt + +import "testing" + +func TestRouteCommunityAttrs_StandardName(t *testing.T) { + s, err := RouteCommunityAttrs("", "65000:120", "{}") + if err != nil { + t.Fatal(err) + } + if s != " bgp_community.add((65000,120));" { + t.Fatalf("got %q", s) + } +} + +func TestRouteCommunityAttrs_LargeJSON(t *testing.T) { + s, err := RouteCommunityAttrs("large", "x", `{"large":[[65000,1,2]]}`) + if err != nil { + t.Fatal(err) + } + if s != " bgp_large_community.add((65000,1,2));" { + t.Fatalf("got %q", s) + } +} diff --git a/internal/birdfmt/static_attrs.go b/internal/birdfmt/static_attrs.go new file mode 100644 index 0000000..5a7d7af --- /dev/null +++ b/internal/birdfmt/static_attrs.go @@ -0,0 +1,137 @@ +package birdfmt + +import ( + "net/netip" + "sort" + "strings" +) + +// StaticRoute is one static route line in BIRD (optionally with a per-route body). +type StaticRoute struct { + Prefix netip.Prefix + RouteBody string // optional lines inside `route P unreachable { ... }` +} + +// RenderStaticIPv4Routes renders a BIRD 2 protocol static block for IPv4. +func RenderStaticIPv4Routes(protocolName string, routes []StaticRoute) string { + if protocolName == "" { + protocolName = "evobgp_prefixes_v4" + } + type key struct { + p string + b string + } + uniq := make(map[key]StaticRoute) + for _, r := range routes { + if !r.Prefix.Addr().Is4() { + continue + } + p := r.Prefix.Masked() + body := strings.TrimSpace(r.RouteBody) + uniq[key{p.String(), body}] = StaticRoute{Prefix: p, RouteBody: body} + } + keys := make([]key, 0, len(uniq)) + for k := range uniq { + keys = append(keys, k) + } + sort.Slice(keys, func(i, j int) bool { + if keys[i].p != keys[j].p { + return keys[i].p < keys[j].p + } + return keys[i].b < keys[j].b + }) + + var b strings.Builder + b.WriteString("protocol static ") + b.WriteString(protocolName) + b.WriteString(" {\n ipv4;\n") + for _, k := range keys { + r := uniq[k] + p := r.Prefix.String() + if r.RouteBody == "" { + b.WriteString(" route ") + b.WriteString(p) + b.WriteString(" unreachable;\n") + continue + } + b.WriteString(" route ") + b.WriteString(p) + b.WriteString(" unreachable {\n") + for _, line := range strings.Split(r.RouteBody, "\n") { + line = strings.TrimRight(line, "\r") + if strings.TrimSpace(line) == "" { + continue + } + if !strings.HasPrefix(line, " ") && !strings.HasPrefix(line, "\t") { + b.WriteString(" ") + } + b.WriteString(line) + b.WriteByte('\n') + } + b.WriteString(" };\n") + } + b.WriteString("}\n") + return b.String() +} + +// RenderStaticIPv6Routes renders a BIRD 2 protocol static block for IPv6. +func RenderStaticIPv6Routes(protocolName string, routes []StaticRoute) string { + if protocolName == "" { + protocolName = "evobgp_prefixes_v6" + } + type key struct { + p string + b string + } + uniq := make(map[key]StaticRoute) + for _, r := range routes { + if !r.Prefix.Addr().Is6() { + continue + } + p := r.Prefix.Masked() + body := strings.TrimSpace(r.RouteBody) + uniq[key{p.String(), body}] = StaticRoute{Prefix: p, RouteBody: body} + } + keys := make([]key, 0, len(uniq)) + for k := range uniq { + keys = append(keys, k) + } + sort.Slice(keys, func(i, j int) bool { + if keys[i].p != keys[j].p { + return keys[i].p < keys[j].p + } + return keys[i].b < keys[j].b + }) + + var b strings.Builder + b.WriteString("protocol static ") + b.WriteString(protocolName) + b.WriteString(" {\n ipv6;\n") + for _, k := range keys { + r := uniq[k] + p := r.Prefix.String() + if r.RouteBody == "" { + b.WriteString(" route ") + b.WriteString(p) + b.WriteString(" unreachable;\n") + continue + } + b.WriteString(" route ") + b.WriteString(p) + b.WriteString(" unreachable {\n") + for _, line := range strings.Split(r.RouteBody, "\n") { + line = strings.TrimRight(line, "\r") + if strings.TrimSpace(line) == "" { + continue + } + if !strings.HasPrefix(line, " ") && !strings.HasPrefix(line, "\t") { + b.WriteString(" ") + } + b.WriteString(line) + b.WriteByte('\n') + } + b.WriteString(" };\n") + } + b.WriteString("}\n") + return b.String() +} diff --git a/internal/birdfmt/static_attrs_test.go b/internal/birdfmt/static_attrs_test.go new file mode 100644 index 0000000..2a053a1 --- /dev/null +++ b/internal/birdfmt/static_attrs_test.go @@ -0,0 +1,20 @@ +package birdfmt + +import ( + "net/netip" + "strings" + "testing" +) + +func TestRenderStaticIPv4Routes_WithCommunity(t *testing.T) { + p := netip.MustParsePrefix("203.0.113.0/24") + s := RenderStaticIPv4Routes("t", []StaticRoute{ + {Prefix: p, RouteBody: " bgp_community.add((65000,1));"}, + }) + if !strings.Contains(s, "route 203.0.113.0/24 unreachable {") { + t.Fatalf("missing block: %s", s) + } + if !strings.Contains(s, "bgp_community.add((65000,1))") { + t.Fatalf("missing community: %s", s) + } +} diff --git a/internal/pipeline/bird_materialize.go b/internal/pipeline/bird_materialize.go new file mode 100644 index 0000000..acbca63 --- /dev/null +++ b/internal/pipeline/bird_materialize.go @@ -0,0 +1,94 @@ +package pipeline + +import ( + "fmt" + "net/netip" + "strconv" + "strings" + + "evobgp/internal/birdfmt" + "evobgp/internal/store" +) + +// materializeRowsForBird turns prefix rows into export-filter CIDR lists, optional AS_PATH rules, and static routes with per-prefix communities. +// Duplicate CIDRs: first occurrence wins for static community; filters use unique CIDRs. +func materializeRowsForBird(st store.Backend, tenantID string, rows []store.PrefixRow) ( + filterV4, filterV6 []netip.Prefix, + pathASNs []int64, + staticV4, staticV6 []birdfmt.StaticRoute, + err error, +) { + var pathSeen map[int64]struct{} + addPath := func(n int64) { + if pathSeen == nil { + pathSeen = make(map[int64]struct{}) + } + if _, ok := pathSeen[n]; ok { + return + } + pathSeen[n] = struct{}{} + pathASNs = append(pathASNs, n) + } + + filterSeen := make(map[string]struct{}) + staticChosen := make(map[string]birdfmt.StaticRoute) + + for _, pr := range rows { + p := strings.TrimSpace(pr.Prefix) + if strings.HasPrefix(p, "as:") { + n, err := strconv.ParseInt(strings.TrimPrefix(p, "as:"), 10, 64) + if err != nil || !store.ValidASN(n) { + continue + } + addPath(n) + continue + } + pfx, err := netip.ParsePrefix(p) + if err != nil { + continue + } + pfx = pfx.Masked() + key := pfx.String() + if _, ok := filterSeen[key]; !ok { + filterSeen[key] = struct{}{} + if pfx.Addr().Is4() { + filterV4 = append(filterV4, pfx) + } else { + filterV6 = append(filterV6, pfx) + } + } + + if _, ok := staticChosen[key]; ok { + continue + } + body, err := communityRouteBody(st, tenantID, pr.CommunityID) + if err != nil { + return nil, nil, nil, nil, nil, err + } + staticChosen[key] = birdfmt.StaticRoute{Prefix: pfx, RouteBody: body} + } + + for _, sr := range staticChosen { + if sr.Prefix.Addr().Is4() { + staticV4 = append(staticV4, sr) + } else if sr.Prefix.Addr().Is6() { + staticV6 = append(staticV6, sr) + } + } + return filterV4, filterV6, pathASNs, staticV4, staticV6, nil +} + +func communityRouteBody(st store.Backend, tenantID string, cid *string) (string, error) { + if cid == nil { + return "", nil + } + id := strings.TrimSpace(*cid) + if id == "" { + return "", nil + } + c, err := st.GetCommunity(tenantID, id) + if err != nil { + return "", fmt.Errorf("community %s: %w", id, err) + } + return birdfmt.RouteCommunityAttrs(c.Kind, c.Name, c.ValueJSON) +} diff --git a/internal/pipeline/refresh.go b/internal/pipeline/refresh.go index 556a157..faad5ff 100644 --- a/internal/pipeline/refresh.go +++ b/internal/pipeline/refresh.go @@ -8,10 +8,12 @@ import ( "io" "net/http" "net/netip" + "os" "sort" "strconv" "strings" + "evobgp/internal/asnresolve" "evobgp/internal/birdfmt" "evobgp/internal/store" @@ -62,7 +64,10 @@ func RefreshModule(ctx context.Context, st store.Backend, hc *http.Client, tenan if err != nil { return "", err } - for _, e := range list { + sort.Slice(list, func(i, j int) bool { return list[i].ASN < list[j].ASN }) + legacy := strings.TrimSpace(os.Getenv("EVOBGP_ASN_RESOLVE")) == "0" + seenPfx := make(map[string]struct{}) + for i, e := range list { if !store.ValidASN(e.ASN) { continue } @@ -71,7 +76,26 @@ func RefreshModule(ctx context.Context, st store.Backend, hc *http.Client, tenan c := *mod.DefaultCommunityID comm = &c } - rows = append(rows, store.PrefixRow{Prefix: MaterializedASPrefixKey(e.ASN), CommunityID: comm, Source: "as_entry"}) + if legacy { + rows = append(rows, store.PrefixRow{Prefix: MaterializedASPrefixKey(e.ASN), CommunityID: comm, Source: "as_entry"}) + continue + } + if i > 0 { + asnresolve.PolitePause() + } + pfxs, err := asnresolve.AnnouncedPrefixes(ctx, hc, e.ASN) + if err != nil { + return "", fmt.Errorf("resolve AS%d: %w", e.ASN, err) + } + src := fmt.Sprintf("as:%d", e.ASN) + for _, pfx := range pfxs { + k := pfx.String() + if _, ok := seenPfx[k]; ok { + continue + } + seenPfx[k] = struct{}{} + rows = append(rows, store.PrefixRow{Prefix: k, CommunityID: comm, Source: src}) + } } case "CDN_CIDRS": sources, err := st.ListCDNSources(tenantID, moduleID) @@ -187,27 +211,9 @@ func hashMaterialization(moduleID string, rows []store.PrefixRow) string { } func buildPreviewFragments(st store.Backend, tenantID, moduleID, revisionID string, rows []store.PrefixRow) (map[string]string, error) { - var v4, v6 []netip.Prefix - var pathASNs []int64 - for _, pr := range rows { - p := strings.TrimSpace(pr.Prefix) - if strings.HasPrefix(p, "as:") { - n, err := strconv.ParseInt(strings.TrimPrefix(p, "as:"), 10, 64) - if err != nil || !store.ValidASN(n) { - continue - } - pathASNs = append(pathASNs, n) - continue - } - pfx, err := netip.ParsePrefix(p) - if err != nil { - continue - } - if pfx.Addr().Is4() { - v4 = append(v4, pfx.Masked()) - } else { - v6 = append(v6, pfx.Masked()) - } + v4, v6, pathASNs, sr4, sr6, err := materializeRowsForBird(st, tenantID, rows) + if err != nil { + return nil, err } f4, err := birdfmt.RenderExportFilterIPv4(birdFilterNameV4, v4, pathASNs) if err != nil { @@ -217,8 +223,8 @@ func buildPreviewFragments(st store.Backend, tenantID, moduleID, revisionID stri if err != nil { return nil, err } - staticV4 := birdfmt.RenderStaticIPv4Protocol("evobgp_prefixes_v4", v4) - staticV6 := birdfmt.RenderStaticIPv6Protocol("evobgp_prefixes_v6", v6) + staticV4 := birdfmt.RenderStaticIPv4Routes("evobgp_prefixes_v4", sr4) + staticV6 := birdfmt.RenderStaticIPv6Routes("evobgp_prefixes_v6", sr6) locals := birdLocalsFromStore(st, tenantID) peersBody, err := renderPeersBirdFragment(st, tenantID, locals) diff --git a/web/src/routes/operations/+page.svelte b/web/src/routes/operations/+page.svelte index 356bd4f..112eee6 100644 --- a/web/src/routes/operations/+page.svelte +++ b/web/src/routes/operations/+page.svelte @@ -505,8 +505,10 @@ - - + + Ревизия {previewRevision?.id.slice(0, 8)}… Срендеренный конфиг BIRD 2 (фрагменты из control plane) и материализованные префиксы. @@ -515,48 +517,59 @@ {#if previewLoading}

Загрузка…

{:else} - - - Конфиг BIRD - Префиксы - - - {@const frags = asPreviewFragments(previewData)} - {#if Object.keys(frags).length === 0} -

Нет фрагментов превью (старая ревизия или пустой render).

- {:else} -
- - -
-

- Совет: откройте _bird_full_expanded.conf — один текст с - bird.conf и содержимым всех include. -

- -
{frags[birdPreviewPath] ?? ''}
-
- {/if} -
- -

Префиксов: {prefixesData.length}

- - {#each prefixesData as pfx} -

{pfx}

+
+ + + Конфиг BIRD + Префиксы + + + {@const frags = asPreviewFragments(previewData)} + {#if Object.keys(frags).length === 0} +

Нет фрагментов превью (старая ревизия или пустой render).

{:else} -

Нет префиксов

- {/each} - -
-
+
+ + +
+

+ Совет: откройте _bird_full_expanded.conf — один текст с + bird.conf и содержимым всех include. +

+
+
{frags[birdPreviewPath] ?? ''}
+
+ {/if} + + +

+ Префиксов: + {prefixesData.length} +

+
+ {#each prefixesData as pfx} +

{pfx}

+ {:else} +

Нет префиксов

+ {/each} +
+
+ +
{/if}