refactor: update AS entry handling in API and database to support ASN-only entries. Remove prefix field from AS entry structure and adjust related functions and tests. Enhance OpenAPI specifications and documentation to reflect changes in AS entry representation.
This commit is contained in:
@@ -59,7 +59,7 @@
|
|||||||
|
|
||||||
| Метод | Путь | Описание |
|
| Метод | Путь | Описание |
|
||||||
|-------|------|----------|
|
|-------|------|----------|
|
||||||
| `GET` | `/v1/modules/{module_id}/as-entries` | Список ASN/префиксов. |
|
| `GET` | `/v1/modules/{module_id}/as-entries` | Список записей: ASN + community. |
|
||||||
| `POST` | `/v1/modules/{module_id}/as-entries` | Добавить. |
|
| `POST` | `/v1/modules/{module_id}/as-entries` | Добавить. |
|
||||||
| `PATCH` | `/v1/modules/{module_id}/as-entries/{entry_id}` | Обновить. |
|
| `PATCH` | `/v1/modules/{module_id}/as-entries/{entry_id}` | Обновить. |
|
||||||
| `DELETE` | `/v1/modules/{module_id}/as-entries/{entry_id}` | Удалить. |
|
| `DELETE` | `/v1/modules/{module_id}/as-entries/{entry_id}` | Удалить. |
|
||||||
|
|||||||
+23
-8
@@ -433,25 +433,38 @@ components:
|
|||||||
type: object
|
type: object
|
||||||
required:
|
required:
|
||||||
- id
|
- id
|
||||||
|
- asn
|
||||||
properties:
|
properties:
|
||||||
id:
|
id:
|
||||||
$ref: "#/components/schemas/ResourceId"
|
$ref: "#/components/schemas/ResourceId"
|
||||||
asn:
|
asn:
|
||||||
type: ["integer", "null"]
|
type: integer
|
||||||
prefix:
|
minimum: 1
|
||||||
type: ["string", "null"]
|
maximum: 4294967295
|
||||||
description: CIDR или префикс в зависимости от модели.
|
|
||||||
community_id:
|
community_id:
|
||||||
type: ["string", "null"]
|
type: ["string", "null"]
|
||||||
additionalProperties: true
|
additionalProperties: true
|
||||||
|
|
||||||
AsEntryCreate:
|
AsEntryCreate:
|
||||||
type: object
|
type: object
|
||||||
|
required:
|
||||||
|
- asn
|
||||||
properties:
|
properties:
|
||||||
asn:
|
asn:
|
||||||
type: integer
|
type: integer
|
||||||
prefix:
|
minimum: 1
|
||||||
type: string
|
maximum: 4294967295
|
||||||
|
community_id:
|
||||||
|
type: ["string", "null"]
|
||||||
|
additionalProperties: true
|
||||||
|
|
||||||
|
AsEntryPatch:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
asn:
|
||||||
|
type: integer
|
||||||
|
minimum: 1
|
||||||
|
maximum: 4294967295
|
||||||
community_id:
|
community_id:
|
||||||
type: ["string", "null"]
|
type: ["string", "null"]
|
||||||
additionalProperties: true
|
additionalProperties: true
|
||||||
@@ -600,7 +613,9 @@ components:
|
|||||||
|
|
||||||
PrefixSnapshotItem:
|
PrefixSnapshotItem:
|
||||||
type: object
|
type: object
|
||||||
description: Элемент материализованного снимка префиксов (детали - по реализации).
|
description: >
|
||||||
|
Элемент материализованного снимка. Поле prefix обычно содержит CIDR;
|
||||||
|
для модулей AS_PREFIXES допускается ключ вида as:<номер_asn> (не CIDR).
|
||||||
additionalProperties: true
|
additionalProperties: true
|
||||||
|
|
||||||
Job:
|
Job:
|
||||||
@@ -1166,7 +1181,7 @@ paths:
|
|||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: "#/components/schemas/AsEntryCreate"
|
$ref: "#/components/schemas/AsEntryPatch"
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
description: Успешно.
|
description: Успешно.
|
||||||
|
|||||||
+1
-1
@@ -8,7 +8,7 @@ EvoBGP — это control plane для описания источников п
|
|||||||
|
|
||||||
| Тип | Назначение |
|
| Тип | Назначение |
|
||||||
|-----|------------|
|
|-----|------------|
|
||||||
| `AS_PREFIXES` | ASN и связанные префиксы |
|
| `AS_PREFIXES` | Номера AS и привязка к BGP community (без статического CIDR в записи) |
|
||||||
| `CDN_CIDRS` | CIDR из внешних CDN-источников (URL, виды источников) |
|
| `CDN_CIDRS` | CIDR из внешних CDN-источников (URL, виды источников) |
|
||||||
| `DOMAINS` | FQDN с привязкой к BGP community; опционально DoH-профили |
|
| `DOMAINS` | FQDN с привязкой к BGP community; опционально DoH-профили |
|
||||||
| `IP_RANGES` | Статические CIDR + `community_id` (данные в БД, без внешнего ingest по URL) |
|
| `IP_RANGES` | Статические CIDR + `community_id` (данные в БД, без внешнего ingest по URL) |
|
||||||
|
|||||||
+43
-10
@@ -7,9 +7,27 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const maxBGPASN = 4294967295
|
||||||
|
|
||||||
|
func filterUniqueASNs(pathASNs []int64) []int64 {
|
||||||
|
seen := make(map[int64]struct{})
|
||||||
|
for _, a := range pathASNs {
|
||||||
|
if a >= 1 && a <= maxBGPASN {
|
||||||
|
seen[a] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out := make([]int64, 0, len(seen))
|
||||||
|
for a := range seen {
|
||||||
|
out = append(out, a)
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i, j int) bool { return out[i] < out[j] })
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// RenderExportFilterIPv4 renders a BIRD 2 filter that accepts IPv4 routes whose prefix
|
// RenderExportFilterIPv4 renders a BIRD 2 filter that accepts IPv4 routes whose prefix
|
||||||
// is in prefixes (exact CIDR match via set membership), and rejects others.
|
// is in prefixes (exact CIDR match via set membership), and/or routes whose AS_PATH
|
||||||
func RenderExportFilterIPv4(filterName string, prefixes []netip.Prefix) (string, error) {
|
// contains any of pathASNs (BIRD pattern [= * ASN =]), then rejects others.
|
||||||
|
func RenderExportFilterIPv4(filterName string, prefixes []netip.Prefix, pathASNs []int64) (string, error) {
|
||||||
if strings.TrimSpace(filterName) == "" {
|
if strings.TrimSpace(filterName) == "" {
|
||||||
return "", fmt.Errorf("birdfmt: filter name is required")
|
return "", fmt.Errorf("birdfmt: filter name is required")
|
||||||
}
|
}
|
||||||
@@ -27,24 +45,32 @@ func RenderExportFilterIPv4(filterName string, prefixes []netip.Prefix) (string,
|
|||||||
}
|
}
|
||||||
sort.Strings(keys)
|
sort.Strings(keys)
|
||||||
|
|
||||||
|
asns := filterUniqueASNs(pathASNs)
|
||||||
|
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
b.WriteString("filter ")
|
b.WriteString("filter ")
|
||||||
b.WriteString(strings.TrimSpace(filterName))
|
b.WriteString(strings.TrimSpace(filterName))
|
||||||
b.WriteString(" {\n")
|
b.WriteString(" {\n")
|
||||||
if len(keys) == 0 {
|
if len(keys) > 0 {
|
||||||
b.WriteString(" reject;\n")
|
|
||||||
} else {
|
|
||||||
b.WriteString(" if net ~ [ ")
|
b.WriteString(" if net ~ [ ")
|
||||||
b.WriteString(strings.Join(keys, ", "))
|
b.WriteString(strings.Join(keys, ", "))
|
||||||
b.WriteString(" ] then accept;\n")
|
b.WriteString(" ] then accept;\n")
|
||||||
|
}
|
||||||
|
for _, asn := range asns {
|
||||||
|
fmt.Fprintf(&b, " if bgp_path ~ [= * %d =] then accept;\n", asn)
|
||||||
|
}
|
||||||
|
if len(keys) == 0 && len(asns) == 0 {
|
||||||
|
b.WriteString(" reject;\n")
|
||||||
|
} else {
|
||||||
b.WriteString(" reject;\n")
|
b.WriteString(" reject;\n")
|
||||||
}
|
}
|
||||||
b.WriteString("}\n")
|
b.WriteString("}\n")
|
||||||
return b.String(), nil
|
return b.String(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// RenderExportFilterIPv6 renders a BIRD 2 filter for IPv6 prefixes (CIDR set, then reject).
|
// RenderExportFilterIPv6 renders a BIRD 2 filter for IPv6 prefixes (CIDR set) and/or
|
||||||
func RenderExportFilterIPv6(filterName string, prefixes []netip.Prefix) (string, error) {
|
// AS_PATH matches, then reject.
|
||||||
|
func RenderExportFilterIPv6(filterName string, prefixes []netip.Prefix, pathASNs []int64) (string, error) {
|
||||||
if strings.TrimSpace(filterName) == "" {
|
if strings.TrimSpace(filterName) == "" {
|
||||||
return "", fmt.Errorf("birdfmt: filter name is required")
|
return "", fmt.Errorf("birdfmt: filter name is required")
|
||||||
}
|
}
|
||||||
@@ -62,16 +88,23 @@ func RenderExportFilterIPv6(filterName string, prefixes []netip.Prefix) (string,
|
|||||||
}
|
}
|
||||||
sort.Strings(keys)
|
sort.Strings(keys)
|
||||||
|
|
||||||
|
asns := filterUniqueASNs(pathASNs)
|
||||||
|
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
b.WriteString("filter ")
|
b.WriteString("filter ")
|
||||||
b.WriteString(strings.TrimSpace(filterName))
|
b.WriteString(strings.TrimSpace(filterName))
|
||||||
b.WriteString(" {\n")
|
b.WriteString(" {\n")
|
||||||
if len(keys) == 0 {
|
if len(keys) > 0 {
|
||||||
b.WriteString(" reject;\n")
|
|
||||||
} else {
|
|
||||||
b.WriteString(" if net ~ [ ")
|
b.WriteString(" if net ~ [ ")
|
||||||
b.WriteString(strings.Join(keys, ", "))
|
b.WriteString(strings.Join(keys, ", "))
|
||||||
b.WriteString(" ] then accept;\n")
|
b.WriteString(" ] then accept;\n")
|
||||||
|
}
|
||||||
|
for _, asn := range asns {
|
||||||
|
fmt.Fprintf(&b, " if bgp_path ~ [= * %d =] then accept;\n", asn)
|
||||||
|
}
|
||||||
|
if len(keys) == 0 && len(asns) == 0 {
|
||||||
|
b.WriteString(" reject;\n")
|
||||||
|
} else {
|
||||||
b.WriteString(" reject;\n")
|
b.WriteString(" reject;\n")
|
||||||
}
|
}
|
||||||
b.WriteString("}\n")
|
b.WriteString("}\n")
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestRenderExportFilterIPv4_Empty(t *testing.T) {
|
func TestRenderExportFilterIPv4_Empty(t *testing.T) {
|
||||||
got, err := RenderExportFilterIPv4("evobgp_x", nil)
|
got, err := RenderExportFilterIPv4("evobgp_x", nil, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -21,7 +21,7 @@ func TestRenderExportFilterIPv4_Empty(t *testing.T) {
|
|||||||
|
|
||||||
func TestRenderExportFilterIPv4_SkipsNonV4(t *testing.T) {
|
func TestRenderExportFilterIPv4_SkipsNonV4(t *testing.T) {
|
||||||
v6 := netip.MustParsePrefix("2001:db8::/32")
|
v6 := netip.MustParsePrefix("2001:db8::/32")
|
||||||
got, err := RenderExportFilterIPv4("f", []netip.Prefix{v6})
|
got, err := RenderExportFilterIPv4("f", []netip.Prefix{v6}, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -32,7 +32,7 @@ func TestRenderExportFilterIPv4_SkipsNonV4(t *testing.T) {
|
|||||||
|
|
||||||
func TestRenderExportFilterIPv6(t *testing.T) {
|
func TestRenderExportFilterIPv6(t *testing.T) {
|
||||||
p := netip.MustParsePrefix("2001:db8::/32")
|
p := netip.MustParsePrefix("2001:db8::/32")
|
||||||
got, err := RenderExportFilterIPv6("evobgp_export_v6", []netip.Prefix{p, p})
|
got, err := RenderExportFilterIPv6("evobgp_export_v6", []netip.Prefix{p, p}, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -45,3 +45,13 @@ func TestRenderExportFilterIPv6(t *testing.T) {
|
|||||||
t.Fatalf("mismatch\n--- got ---\n%s\n--- want ---\n%s", got, want)
|
t.Fatalf("mismatch\n--- got ---\n%s\n--- want ---\n%s", got, want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRenderExportFilterIPv4_ASPathOnly(t *testing.T) {
|
||||||
|
got, err := RenderExportFilterIPv4("f", nil, []int64{65001, 65002})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(got, "bgp_path ~ [= * 65001 =]") || !strings.Contains(got, "bgp_path ~ [= * 65002 =]") {
|
||||||
|
t.Fatal(got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,13 +11,13 @@ import (
|
|||||||
func TestStandardLayout_GeneratorMatchesFixtures(t *testing.T) {
|
func TestStandardLayout_GeneratorMatchesFixtures(t *testing.T) {
|
||||||
p4 := netip.MustParsePrefix("203.0.113.0/24")
|
p4 := netip.MustParsePrefix("203.0.113.0/24")
|
||||||
|
|
||||||
f4, err := RenderExportFilterIPv4("evobgp_export_v4", []netip.Prefix{p4})
|
f4, err := RenderExportFilterIPv4("evobgp_export_v4", []netip.Prefix{p4}, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
assertFileEquals(t, "testdata/scenarios/standard_layout/bird.d/evobgp_filters_v4.conf", f4)
|
assertFileEquals(t, "testdata/scenarios/standard_layout/bird.d/evobgp_filters_v4.conf", f4)
|
||||||
|
|
||||||
f6, err := RenderExportFilterIPv6("evobgp_export_v6", nil)
|
f6, err := RenderExportFilterIPv6("evobgp_export_v6", nil, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -231,17 +231,7 @@ func (s *Server) handleListAS(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func asEntryJSON(x *store.ASEntry) map[string]any {
|
func asEntryJSON(x *store.ASEntry) map[string]any {
|
||||||
m := map[string]any{"id": x.ID}
|
m := map[string]any{"id": x.ID, "asn": x.ASN}
|
||||||
if x.ASN != nil {
|
|
||||||
m["asn"] = *x.ASN
|
|
||||||
} else {
|
|
||||||
m["asn"] = nil
|
|
||||||
}
|
|
||||||
if x.Prefix != nil {
|
|
||||||
m["prefix"] = *x.Prefix
|
|
||||||
} else {
|
|
||||||
m["prefix"] = nil
|
|
||||||
}
|
|
||||||
if x.CommunityID != nil {
|
if x.CommunityID != nil {
|
||||||
m["community_id"] = *x.CommunityID
|
m["community_id"] = *x.CommunityID
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"evobgp/internal/birdfmt"
|
"evobgp/internal/birdfmt"
|
||||||
@@ -16,6 +17,11 @@ import (
|
|||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// MaterializedASPrefixKey returns the revision snapshot key for an AS-only entry (not a CIDR).
|
||||||
|
func MaterializedASPrefixKey(asn int64) string {
|
||||||
|
return fmt.Sprintf("as:%d", asn)
|
||||||
|
}
|
||||||
|
|
||||||
// RefreshModule runs ingest (where applicable) and creates a new rendered revision for the module.
|
// RefreshModule runs ingest (where applicable) and creates a new rendered revision for the module.
|
||||||
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 {
|
||||||
@@ -50,7 +56,7 @@ func RefreshModule(ctx context.Context, st store.Backend, hc *http.Client, tenan
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
for _, e := range list {
|
for _, e := range list {
|
||||||
if e.Prefix == nil || strings.TrimSpace(*e.Prefix) == "" {
|
if !store.ValidASN(e.ASN) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
comm := e.CommunityID
|
comm := e.CommunityID
|
||||||
@@ -58,7 +64,7 @@ func RefreshModule(ctx context.Context, st store.Backend, hc *http.Client, tenan
|
|||||||
c := *mod.DefaultCommunityID
|
c := *mod.DefaultCommunityID
|
||||||
comm = &c
|
comm = &c
|
||||||
}
|
}
|
||||||
rows = append(rows, store.PrefixRow{Prefix: strings.TrimSpace(*e.Prefix), CommunityID: comm, Source: "as_entry"})
|
rows = append(rows, store.PrefixRow{Prefix: MaterializedASPrefixKey(e.ASN), CommunityID: comm, Source: "as_entry"})
|
||||||
}
|
}
|
||||||
case "CDN_CIDRS":
|
case "CDN_CIDRS":
|
||||||
sources, err := st.ListCDNSources(tenantID, moduleID)
|
sources, err := st.ListCDNSources(tenantID, moduleID)
|
||||||
@@ -175,8 +181,18 @@ func hashMaterialization(moduleID string, rows []store.PrefixRow) string {
|
|||||||
|
|
||||||
func buildPreviewFragments(revisionID string, rows []store.PrefixRow) (map[string]string, error) {
|
func buildPreviewFragments(revisionID string, rows []store.PrefixRow) (map[string]string, error) {
|
||||||
var v4, v6 []netip.Prefix
|
var v4, v6 []netip.Prefix
|
||||||
|
var pathASNs []int64
|
||||||
for _, pr := range rows {
|
for _, pr := range rows {
|
||||||
pfx, err := netip.ParsePrefix(strings.TrimSpace(pr.Prefix))
|
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 {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -186,11 +202,11 @@ func buildPreviewFragments(revisionID string, rows []store.PrefixRow) (map[strin
|
|||||||
v6 = append(v6, pfx.Masked())
|
v6 = append(v6, pfx.Masked())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
f4, err := birdfmt.RenderExportFilterIPv4("evobgp_export_v4", v4)
|
f4, err := birdfmt.RenderExportFilterIPv4("evobgp_export_v4", v4, pathASNs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
f6, err := birdfmt.RenderExportFilterIPv6("evobgp_export_v6", v6)
|
f6, err := birdfmt.RenderExportFilterIPv6("evobgp_export_v6", v6, pathASNs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -830,7 +830,7 @@ func (p *Postgres) CreateRenderRevision(revisionID, tenantID, moduleID string, p
|
|||||||
}
|
}
|
||||||
_, err = tx.Exec(ctx, `
|
_, err = tx.Exec(ctx, `
|
||||||
INSERT INTO revision_materialized_prefix (revision_id, prefix, community_id, source)
|
INSERT INTO revision_materialized_prefix (revision_id, prefix, community_id, source)
|
||||||
VALUES ($1::uuid, $2::cidr, $3::uuid, $4)`,
|
VALUES ($1::uuid, $2, $3::uuid, $4)`,
|
||||||
strings.TrimSpace(revisionID), strings.TrimSpace(pr.Prefix), comm, src)
|
strings.TrimSpace(revisionID), strings.TrimSpace(pr.Prefix), comm, src)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -164,7 +164,7 @@ func (p *Postgres) ListASEntries(tenantID, moduleID string) ([]*store.ASEntry, e
|
|||||||
}
|
}
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
rows, err := p.pool.Query(ctx, `
|
rows, err := p.pool.Query(ctx, `
|
||||||
SELECT id::text, asn, prefix::text, community_id::text FROM module_as_entry WHERE module_id=$1`, moduleID)
|
SELECT id::text, asn, community_id::text FROM module_as_entry WHERE module_id=$1`, moduleID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -173,13 +173,10 @@ func (p *Postgres) ListASEntries(tenantID, moduleID string) ([]*store.ASEntry, e
|
|||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var e store.ASEntry
|
var e store.ASEntry
|
||||||
e.ModuleID = moduleID
|
e.ModuleID = moduleID
|
||||||
var asn *int64
|
var comm *string
|
||||||
var pref, comm *string
|
if err := rows.Scan(&e.ID, &e.ASN, &comm); err != nil {
|
||||||
if err := rows.Scan(&e.ID, &asn, &pref, &comm); err != nil {
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
e.ASN = asn
|
|
||||||
e.Prefix = pref
|
|
||||||
e.CommunityID = strOrNil(comm)
|
e.CommunityID = strOrNil(comm)
|
||||||
out = append(out, &e)
|
out = append(out, &e)
|
||||||
}
|
}
|
||||||
@@ -194,19 +191,15 @@ func (p *Postgres) CreateASEntry(tenantID, moduleID string, in *store.ASEntry) (
|
|||||||
if mod.Type != "AS_PREFIXES" {
|
if mod.Type != "AS_PREFIXES" {
|
||||||
return nil, store.ErrInvalidInput
|
return nil, store.ErrInvalidInput
|
||||||
}
|
}
|
||||||
if in == nil || (in.ASN == nil && (in.Prefix == nil || strings.TrimSpace(*in.Prefix) == "")) {
|
if in == nil || !store.ValidASN(in.ASN) {
|
||||||
return nil, store.ErrInvalidInput
|
return nil, store.ErrInvalidInput
|
||||||
}
|
}
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
id := uuid.NewString()
|
id := uuid.NewString()
|
||||||
var pref any
|
|
||||||
if in.Prefix != nil && strings.TrimSpace(*in.Prefix) != "" {
|
|
||||||
pref = strings.TrimSpace(*in.Prefix)
|
|
||||||
}
|
|
||||||
_, err = p.pool.Exec(ctx, `
|
_, err = p.pool.Exec(ctx, `
|
||||||
INSERT INTO module_as_entry (id, module_id, asn, prefix, community_id)
|
INSERT INTO module_as_entry (id, module_id, asn, community_id)
|
||||||
VALUES ($1,$2,$3,$4::cidr, NULLIF($5::uuid, '00000000-0000-0000-0000-000000000000'::uuid))`,
|
VALUES ($1,$2,$3,NULLIF($4::uuid, '00000000-0000-0000-0000-000000000000'::uuid))`,
|
||||||
id, moduleID, in.ASN, pref, uuidOrNilPtr(in.CommunityID))
|
id, moduleID, in.ASN, uuidOrNilPtr(in.CommunityID))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -216,16 +209,13 @@ func (p *Postgres) CreateASEntry(tenantID, moduleID string, in *store.ASEntry) (
|
|||||||
func (p *Postgres) getASEntry(ctx context.Context, moduleID, id string) (*store.ASEntry, error) {
|
func (p *Postgres) getASEntry(ctx context.Context, moduleID, id string) (*store.ASEntry, error) {
|
||||||
var e store.ASEntry
|
var e store.ASEntry
|
||||||
e.ModuleID = moduleID
|
e.ModuleID = moduleID
|
||||||
var asn *int64
|
var comm *string
|
||||||
var pref, comm *string
|
|
||||||
err := p.pool.QueryRow(ctx, `
|
err := p.pool.QueryRow(ctx, `
|
||||||
SELECT id::text, asn, prefix::text, community_id::text FROM module_as_entry WHERE id=$1 AND module_id=$2`, id, moduleID).Scan(
|
SELECT id::text, asn, community_id::text FROM module_as_entry WHERE id=$1 AND module_id=$2`, id, moduleID).Scan(
|
||||||
&e.ID, &asn, &pref, &comm)
|
&e.ID, &e.ASN, &comm)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
e.ASN = asn
|
|
||||||
e.Prefix = pref
|
|
||||||
e.CommunityID = strOrNil(comm)
|
e.CommunityID = strOrNil(comm)
|
||||||
return &e, nil
|
return &e, nil
|
||||||
}
|
}
|
||||||
@@ -242,15 +232,7 @@ func (p *Postgres) UpdateASEntry(tenantID, moduleID, entryID string, patch *stor
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if patch.ASN != nil {
|
if patch.ASN != nil {
|
||||||
cur.ASN = patch.ASN
|
cur.ASN = *patch.ASN
|
||||||
}
|
|
||||||
if patch.Prefix != nil {
|
|
||||||
p := strings.TrimSpace(*patch.Prefix)
|
|
||||||
if p == "" {
|
|
||||||
cur.Prefix = nil
|
|
||||||
} else {
|
|
||||||
cur.Prefix = &p
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if patch.CommunityID != nil {
|
if patch.CommunityID != nil {
|
||||||
v := strings.TrimSpace(*patch.CommunityID)
|
v := strings.TrimSpace(*patch.CommunityID)
|
||||||
@@ -260,15 +242,14 @@ func (p *Postgres) UpdateASEntry(tenantID, moduleID, entryID string, patch *stor
|
|||||||
cur.CommunityID = &v
|
cur.CommunityID = &v
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ctx := context.Background()
|
if !store.ValidASN(cur.ASN) {
|
||||||
var pref any
|
return nil, store.ErrInvalidInput
|
||||||
if cur.Prefix != nil {
|
|
||||||
pref = *cur.Prefix
|
|
||||||
}
|
}
|
||||||
|
ctx := context.Background()
|
||||||
_, err = p.pool.Exec(ctx, `
|
_, err = p.pool.Exec(ctx, `
|
||||||
UPDATE module_as_entry SET asn=$3, prefix=$4::cidr, community_id=NULLIF($5::uuid, '00000000-0000-0000-0000-000000000000'::uuid), updated_at=now()
|
UPDATE module_as_entry SET asn=$3, community_id=NULLIF($4::uuid, '00000000-0000-0000-0000-000000000000'::uuid), updated_at=now()
|
||||||
WHERE id=$1 AND module_id=$2`,
|
WHERE id=$1 AND module_id=$2`,
|
||||||
entryID, moduleID, cur.ASN, pref, uuidOrNilPtr(cur.CommunityID))
|
entryID, moduleID, cur.ASN, uuidOrNilPtr(cur.CommunityID))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -109,19 +109,22 @@ type CDNSourcePatch struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ASEntry struct {
|
type ASEntry struct {
|
||||||
ID string
|
ID string
|
||||||
ModuleID string
|
ModuleID string
|
||||||
ASN *int64
|
ASN int64
|
||||||
Prefix *string
|
CommunityID *string
|
||||||
CommunityID *string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type ASEntryPatch struct {
|
type ASEntryPatch struct {
|
||||||
ASN *int64
|
ASN *int64
|
||||||
Prefix *string
|
|
||||||
CommunityID *string
|
CommunityID *string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ValidASN reports whether n is a usable BGP ASN (1..4294967295).
|
||||||
|
func ValidASN(n int64) bool {
|
||||||
|
return n >= 1 && n <= 4294967295
|
||||||
|
}
|
||||||
|
|
||||||
type DomainEntry struct {
|
type DomainEntry struct {
|
||||||
ID string
|
ID string
|
||||||
ModuleID string
|
ModuleID string
|
||||||
|
|||||||
@@ -217,7 +217,7 @@ func (m *Memory) ListASEntries(tenantID, moduleID string) ([]*ASEntry, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *Memory) CreateASEntry(tenantID, moduleID string, in *ASEntry) (*ASEntry, error) {
|
func (m *Memory) CreateASEntry(tenantID, moduleID string, in *ASEntry) (*ASEntry, error) {
|
||||||
if in == nil || (in.ASN == nil && (in.Prefix == nil || strings.TrimSpace(*in.Prefix) == "")) {
|
if in == nil || !ValidASN(in.ASN) {
|
||||||
return nil, ErrInvalidInput
|
return nil, ErrInvalidInput
|
||||||
}
|
}
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
@@ -230,7 +230,7 @@ func (m *Memory) CreateASEntry(tenantID, moduleID string, in *ASEntry) (*ASEntry
|
|||||||
return nil, ErrInvalidInput
|
return nil, ErrInvalidInput
|
||||||
}
|
}
|
||||||
id := uuid.NewString()
|
id := uuid.NewString()
|
||||||
e := &ASEntry{ID: id, ModuleID: moduleID, ASN: in.ASN, Prefix: in.Prefix, CommunityID: in.CommunityID}
|
e := &ASEntry{ID: id, ModuleID: moduleID, ASN: in.ASN, CommunityID: in.CommunityID}
|
||||||
m.asEntries[id] = e
|
m.asEntries[id] = e
|
||||||
return e, nil
|
return e, nil
|
||||||
}
|
}
|
||||||
@@ -249,15 +249,7 @@ func (m *Memory) UpdateASEntry(tenantID, moduleID, entryID string, patch *ASEntr
|
|||||||
return nil, ErrNotFound
|
return nil, ErrNotFound
|
||||||
}
|
}
|
||||||
if patch.ASN != nil {
|
if patch.ASN != nil {
|
||||||
e.ASN = patch.ASN
|
e.ASN = *patch.ASN
|
||||||
}
|
|
||||||
if patch.Prefix != nil {
|
|
||||||
p := strings.TrimSpace(*patch.Prefix)
|
|
||||||
if p == "" {
|
|
||||||
e.Prefix = nil
|
|
||||||
} else {
|
|
||||||
e.Prefix = &p
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if patch.CommunityID != nil {
|
if patch.CommunityID != nil {
|
||||||
v := strings.TrimSpace(*patch.CommunityID)
|
v := strings.TrimSpace(*patch.CommunityID)
|
||||||
@@ -267,6 +259,9 @@ func (m *Memory) UpdateASEntry(tenantID, moduleID, entryID string, patch *ASEntr
|
|||||||
e.CommunityID = &v
|
e.CommunityID = &v
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if !ValidASN(e.ASN) {
|
||||||
|
return nil, ErrInvalidInput
|
||||||
|
}
|
||||||
return e, nil
|
return e, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
DELETE FROM revision_materialized_prefix WHERE prefix LIKE 'as:%';
|
||||||
|
|
||||||
|
ALTER TABLE revision_materialized_prefix
|
||||||
|
ALTER COLUMN prefix TYPE CIDR USING prefix::cidr;
|
||||||
|
|
||||||
|
ALTER TABLE module_as_entry ADD COLUMN prefix CIDR;
|
||||||
|
ALTER TABLE module_as_entry ALTER COLUMN asn DROP NOT NULL;
|
||||||
|
ALTER TABLE module_as_entry
|
||||||
|
ADD CONSTRAINT module_as_entry_asn_or_prefix_chk CHECK (asn IS NOT NULL OR prefix IS NOT NULL);
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
-- AS-запись: только ASN + community; снимок ревизии может хранить ключи вида as:<asn>.
|
||||||
|
ALTER TABLE revision_materialized_prefix
|
||||||
|
ALTER COLUMN prefix TYPE TEXT USING prefix::text;
|
||||||
|
|
||||||
|
DELETE FROM module_as_entry WHERE asn IS NULL;
|
||||||
|
|
||||||
|
ALTER TABLE module_as_entry DROP CONSTRAINT module_as_entry_asn_or_prefix_chk;
|
||||||
|
ALTER TABLE module_as_entry DROP COLUMN prefix;
|
||||||
|
ALTER TABLE module_as_entry ALTER COLUMN asn SET NOT NULL;
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
CREATE TABLE module_as_entry_old (
|
||||||
|
id TEXT PRIMARY KEY NOT NULL,
|
||||||
|
module_id TEXT NOT NULL REFERENCES module (id) ON DELETE CASCADE,
|
||||||
|
asn INTEGER,
|
||||||
|
prefix TEXT,
|
||||||
|
community_id TEXT REFERENCES bgp_community (id) ON DELETE SET NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||||
|
CHECK (asn IS NOT NULL OR prefix IS NOT NULL)
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO module_as_entry_old (id, module_id, asn, prefix, community_id, created_at, updated_at)
|
||||||
|
SELECT id, module_id, asn, NULL, community_id, created_at, updated_at FROM module_as_entry;
|
||||||
|
|
||||||
|
DROP TABLE module_as_entry;
|
||||||
|
ALTER TABLE module_as_entry_old RENAME TO module_as_entry;
|
||||||
|
|
||||||
|
CREATE INDEX idx_module_as_entry_module ON module_as_entry (module_id);
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
-- AS-запись: только ASN + community (revision_materialized_prefix.prefix уже TEXT).
|
||||||
|
|
||||||
|
DELETE FROM module_as_entry WHERE asn IS NULL;
|
||||||
|
|
||||||
|
CREATE TABLE module_as_entry_new (
|
||||||
|
id TEXT PRIMARY KEY NOT NULL,
|
||||||
|
module_id TEXT NOT NULL REFERENCES module (id) ON DELETE CASCADE,
|
||||||
|
asn INTEGER NOT NULL,
|
||||||
|
community_id TEXT REFERENCES bgp_community (id) ON DELETE SET NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO module_as_entry_new (id, module_id, asn, community_id, created_at, updated_at)
|
||||||
|
SELECT id, module_id, asn, community_id, created_at, updated_at FROM module_as_entry;
|
||||||
|
|
||||||
|
DROP TABLE module_as_entry;
|
||||||
|
ALTER TABLE module_as_entry_new RENAME TO module_as_entry;
|
||||||
|
|
||||||
|
CREATE INDEX idx_module_as_entry_module ON module_as_entry (module_id);
|
||||||
@@ -36,13 +36,15 @@ export type ModulePatch = Partial<Omit<ModuleCreate, 'type'>>;
|
|||||||
// ---- AS Entries ----
|
// ---- AS Entries ----
|
||||||
export type AsEntry = {
|
export type AsEntry = {
|
||||||
id: string;
|
id: string;
|
||||||
asn: number | null;
|
asn: number;
|
||||||
prefix: string | null;
|
|
||||||
community_id: string | null;
|
community_id: string | null;
|
||||||
};
|
};
|
||||||
export type AsEntryCreate = {
|
export type AsEntryCreate = {
|
||||||
|
asn: number;
|
||||||
|
community_id?: string | null;
|
||||||
|
};
|
||||||
|
export type AsEntryPatch = {
|
||||||
asn?: number;
|
asn?: number;
|
||||||
prefix?: string;
|
|
||||||
community_id?: string | null;
|
community_id?: string | null;
|
||||||
};
|
};
|
||||||
export type AsEntriesResponse = Page<AsEntry>;
|
export type AsEntriesResponse = Page<AsEntry>;
|
||||||
@@ -158,6 +160,7 @@ export type RevisionRow = {
|
|||||||
export type RevisionsResponse = Page<RevisionRow>;
|
export type RevisionsResponse = Page<RevisionRow>;
|
||||||
|
|
||||||
export type RevisionPrefix = {
|
export type RevisionPrefix = {
|
||||||
|
/** Обычно CIDR; для AS-модуля в снимке ревизии — строка вида `as:<номер_asn>`. */
|
||||||
prefix: string;
|
prefix: string;
|
||||||
community_id?: string | null;
|
community_id?: string | null;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -49,7 +49,7 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
const moduleTypes = [
|
const moduleTypes = [
|
||||||
{ value: 'AS_PREFIXES', label: 'AS Prefixes' },
|
{ value: 'AS_PREFIXES', label: 'AS (номера)' },
|
||||||
{ value: 'CDN_CIDRS', label: 'CDN CIDRs' },
|
{ value: 'CDN_CIDRS', label: 'CDN CIDRs' },
|
||||||
{ value: 'DOMAINS', label: 'Домены' },
|
{ value: 'DOMAINS', label: 'Домены' },
|
||||||
{ value: 'IP_RANGES', label: 'IP Ranges' }
|
{ value: 'IP_RANGES', label: 'IP Ranges' }
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
ModulePatch,
|
ModulePatch,
|
||||||
AsEntry,
|
AsEntry,
|
||||||
AsEntryCreate,
|
AsEntryCreate,
|
||||||
|
AsEntryPatch,
|
||||||
AsEntriesResponse,
|
AsEntriesResponse,
|
||||||
CdnSource,
|
CdnSource,
|
||||||
CdnSourceCreate,
|
CdnSourceCreate,
|
||||||
@@ -82,7 +83,7 @@
|
|||||||
let asEntries = $state<AsEntry[]>([]);
|
let asEntries = $state<AsEntry[]>([]);
|
||||||
let asDialog = $state(false);
|
let asDialog = $state(false);
|
||||||
let asEdit = $state<AsEntry | null>(null);
|
let asEdit = $state<AsEntry | null>(null);
|
||||||
let asForm = $state<AsEntryCreate>({ asn: undefined, prefix: undefined, community_id: null });
|
let asForm = $state<AsEntryCreate>({ asn: 0, community_id: null });
|
||||||
let asSaving = $state(false);
|
let asSaving = $state(false);
|
||||||
let asDeleteTarget = $state<AsEntry | null>(null);
|
let asDeleteTarget = $state<AsEntry | null>(null);
|
||||||
|
|
||||||
@@ -223,22 +224,28 @@
|
|||||||
// --- AS Entries ---
|
// --- AS Entries ---
|
||||||
function openAsCreate() {
|
function openAsCreate() {
|
||||||
asEdit = null;
|
asEdit = null;
|
||||||
asForm = { asn: undefined, prefix: undefined, community_id: null };
|
asForm = { asn: 0, community_id: null };
|
||||||
asDialog = true;
|
asDialog = true;
|
||||||
}
|
}
|
||||||
function openAsEdit(entry: AsEntry) {
|
function openAsEdit(entry: AsEntry) {
|
||||||
asEdit = entry;
|
asEdit = entry;
|
||||||
asForm = { asn: entry.asn ?? undefined, prefix: entry.prefix ?? undefined, community_id: entry.community_id };
|
asForm = { asn: entry.asn, community_id: entry.community_id };
|
||||||
asDialog = true;
|
asDialog = true;
|
||||||
}
|
}
|
||||||
async function saveAs() {
|
async function saveAs() {
|
||||||
|
const asn = Number(asForm.asn);
|
||||||
|
if (!Number.isFinite(asn) || asn < 1 || asn > 4294967295) {
|
||||||
|
toast.error('Укажите корректный ASN (1–4294967295)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
asSaving = true;
|
asSaving = true;
|
||||||
try {
|
try {
|
||||||
|
const body: AsEntryCreate | AsEntryPatch = { asn, community_id: asForm.community_id };
|
||||||
if (asEdit) {
|
if (asEdit) {
|
||||||
await apiMutate(`/v1/modules/${moduleId}/as-entries/${asEdit.id}`, 'PATCH', asForm);
|
await apiMutate(`/v1/modules/${moduleId}/as-entries/${asEdit.id}`, 'PATCH', body);
|
||||||
toast.success('Запись обновлена');
|
toast.success('Запись обновлена');
|
||||||
} else {
|
} else {
|
||||||
await apiMutate(`/v1/modules/${moduleId}/as-entries`, 'POST', asForm);
|
await apiMutate(`/v1/modules/${moduleId}/as-entries`, 'POST', body as AsEntryCreate);
|
||||||
toast.success('Запись добавлена');
|
toast.success('Запись добавлена');
|
||||||
}
|
}
|
||||||
asDialog = false;
|
asDialog = false;
|
||||||
@@ -468,7 +475,7 @@
|
|||||||
<CardHeader class="flex flex-row items-center justify-between pb-2">
|
<CardHeader class="flex flex-row items-center justify-between pb-2">
|
||||||
<div>
|
<div>
|
||||||
<CardTitle class="text-base">AS-записи</CardTitle>
|
<CardTitle class="text-base">AS-записи</CardTitle>
|
||||||
<CardDescription>ASN и/или префиксы для анонса</CardDescription>
|
<CardDescription>Номер AS и привязка к BGP community</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<Button size="sm" onclick={openAsCreate}><Plus />Добавить</Button>
|
<Button size="sm" onclick={openAsCreate}><Plus />Добавить</Button>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
@@ -477,7 +484,6 @@
|
|||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>ASN</TableHead>
|
<TableHead>ASN</TableHead>
|
||||||
<TableHead>Префикс</TableHead>
|
|
||||||
<TableHead>Community</TableHead>
|
<TableHead>Community</TableHead>
|
||||||
<TableHead class="w-20"></TableHead>
|
<TableHead class="w-20"></TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@@ -485,8 +491,7 @@
|
|||||||
<TableBody>
|
<TableBody>
|
||||||
{#each asEntries as entry (entry.id)}
|
{#each asEntries as entry (entry.id)}
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell class="font-mono">{entry.asn ?? '—'}</TableCell>
|
<TableCell class="font-mono">{entry.asn}</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">{communityName(entry.community_id)}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div class="flex gap-1">
|
<div class="flex gap-1">
|
||||||
@@ -497,7 +502,7 @@
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
{:else}
|
{:else}
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colspan={4} class="text-muted-foreground text-center py-6">Нет записей</TableCell>
|
<TableCell colspan={3} class="text-muted-foreground text-center py-6">Нет записей</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
{/each}
|
{/each}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
@@ -717,16 +722,12 @@
|
|||||||
<DialogContent class="sm:max-w-sm">
|
<DialogContent class="sm:max-w-sm">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{asEdit ? 'Редактировать запись' : 'Новая AS-запись'}</DialogTitle>
|
<DialogTitle>{asEdit ? 'Редактировать запись' : 'Новая AS-запись'}</DialogTitle>
|
||||||
<DialogDescription>ASN и/или префикс для анонса через BGP.</DialogDescription>
|
<DialogDescription>Номер автономной системы и community для политики анонса.</DialogDescription>
|
||||||
</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="as-asn">ASN</Label>
|
<Label for="as-asn">ASN</Label>
|
||||||
<Input id="as-asn" type="number" placeholder="12345" bind:value={asForm.asn} />
|
<Input id="as-asn" type="number" placeholder="12345" bind:value={asForm.asn} min={1} max={4294967295} />
|
||||||
</div>
|
|
||||||
<div class="space-y-1.5">
|
|
||||||
<Label for="as-prefix">Префикс</Label>
|
|
||||||
<Input id="as-prefix" placeholder="203.0.113.0/24" bind:value={asForm.prefix} />
|
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-1.5">
|
<div class="space-y-1.5">
|
||||||
<Label for="as-comm">Community</Label>
|
<Label for="as-comm">Community</Label>
|
||||||
@@ -754,7 +755,7 @@
|
|||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>Удалить запись?</AlertDialogTitle>
|
<AlertDialogTitle>Удалить запись?</AlertDialogTitle>
|
||||||
<AlertDialogDescription>ASN: {asDeleteTarget?.asn ?? '—'}, Префикс: {asDeleteTarget?.prefix ?? '—'}</AlertDialogDescription>
|
<AlertDialogDescription>ASN: {asDeleteTarget?.asn ?? '—'}</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
<AlertDialogCancel onclick={() => (asDeleteTarget = null)}>Отмена</AlertDialogCancel>
|
<AlertDialogCancel onclick={() => (asDeleteTarget = null)}>Отмена</AlertDialogCancel>
|
||||||
|
|||||||
Reference in New Issue
Block a user