Compare commits
2
Commits
73d0003281
...
b70a643016
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b70a643016 | ||
|
|
1db6b5b7b2 |
No files matched your search
@@ -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);
|
||||||
+13
-1
@@ -1,9 +1,21 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="ru">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<meta name="text-scale" content="scale" />
|
<meta name="text-scale" content="scale" />
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
try {
|
||||||
|
var k = 'evobgp-theme';
|
||||||
|
var t = localStorage.getItem(k);
|
||||||
|
var dark =
|
||||||
|
t === 'dark' ||
|
||||||
|
(t !== 'light' && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||||
|
if (dark) document.documentElement.classList.add('dark');
|
||||||
|
} catch (e) {}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
%sveltekit.head%
|
%sveltekit.head%
|
||||||
</head>
|
</head>
|
||||||
<body data-sveltekit-preload-data="hover">
|
<body data-sveltekit-preload-data="hover">
|
||||||
|
|||||||
@@ -2,9 +2,19 @@
|
|||||||
import { resolve } from '$app/paths';
|
import { resolve } from '$app/paths';
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import { cn } from '$lib/utils.js';
|
import { cn } from '$lib/utils.js';
|
||||||
|
import { Button } from '$lib/components/ui/button/index.js';
|
||||||
import { buttonVariants } from '$lib/components/ui/button/button.svelte';
|
import { buttonVariants } from '$lib/components/ui/button/button.svelte';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuRadioGroup,
|
||||||
|
DropdownMenuRadioItem,
|
||||||
|
DropdownMenuTrigger
|
||||||
|
} from '$lib/components/ui/dropdown-menu/index.js';
|
||||||
import { Separator } from '$lib/components/ui/separator/index.js';
|
import { Separator } from '$lib/components/ui/separator/index.js';
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '$lib/components/ui/tooltip/index.js';
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '$lib/components/ui/tooltip/index.js';
|
||||||
|
import type { ThemePreference } from '$lib/theme.js';
|
||||||
|
|
||||||
import Activity from '@lucide/svelte/icons/activity';
|
import Activity from '@lucide/svelte/icons/activity';
|
||||||
import Boxes from '@lucide/svelte/icons/boxes';
|
import Boxes from '@lucide/svelte/icons/boxes';
|
||||||
@@ -17,6 +27,9 @@
|
|||||||
import CalendarClock from '@lucide/svelte/icons/calendar-clock';
|
import CalendarClock from '@lucide/svelte/icons/calendar-clock';
|
||||||
import PanelLeftClose from '@lucide/svelte/icons/panel-left-close';
|
import PanelLeftClose from '@lucide/svelte/icons/panel-left-close';
|
||||||
import PanelLeftOpen from '@lucide/svelte/icons/panel-left-open';
|
import PanelLeftOpen from '@lucide/svelte/icons/panel-left-open';
|
||||||
|
import Sun from '@lucide/svelte/icons/sun';
|
||||||
|
import Moon from '@lucide/svelte/icons/moon';
|
||||||
|
import Monitor from '@lucide/svelte/icons/monitor';
|
||||||
|
|
||||||
const nav = [
|
const nav = [
|
||||||
{ href: '/', label: 'Обзор', icon: LayoutDashboard },
|
{ href: '/', label: 'Обзор', icon: LayoutDashboard },
|
||||||
@@ -34,7 +47,7 @@
|
|||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
let collapsed = $state(false);
|
let collapsed = $state(false);
|
||||||
let { children } = $props();
|
let { children, theme = $bindable<ThemePreference>('system') } = $props();
|
||||||
|
|
||||||
function isActive(href: string) {
|
function isActive(href: string) {
|
||||||
const pathname = page.url.pathname;
|
const pathname = page.url.pathname;
|
||||||
@@ -60,6 +73,37 @@
|
|||||||
<p class="text-sidebar-foreground/50 truncate text-xs">Control plane</p>
|
<p class="text-sidebar-foreground/50 truncate text-xs">Control plane</p>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger
|
||||||
|
class={cn(
|
||||||
|
buttonVariants({ variant: 'ghost', size: 'icon-sm' }),
|
||||||
|
'text-sidebar-foreground/60 hover:text-sidebar-foreground shrink-0'
|
||||||
|
)}
|
||||||
|
aria-label="Тема оформления"
|
||||||
|
>
|
||||||
|
<span class="flex size-4 items-center justify-center">
|
||||||
|
<Sun class="size-4 dark:hidden" />
|
||||||
|
<Moon class="hidden size-4 dark:block" />
|
||||||
|
</span>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" class="w-44">
|
||||||
|
<DropdownMenuLabel>Тема</DropdownMenuLabel>
|
||||||
|
<DropdownMenuRadioGroup bind:value={theme}>
|
||||||
|
<DropdownMenuRadioItem value="light" class="gap-2">
|
||||||
|
<Sun class="size-4" />
|
||||||
|
Светлая
|
||||||
|
</DropdownMenuRadioItem>
|
||||||
|
<DropdownMenuRadioItem value="dark" class="gap-2">
|
||||||
|
<Moon class="size-4" />
|
||||||
|
Тёмная
|
||||||
|
</DropdownMenuRadioItem>
|
||||||
|
<DropdownMenuRadioItem value="system" class="gap-2">
|
||||||
|
<Monitor class="size-4" />
|
||||||
|
Как в системе
|
||||||
|
</DropdownMenuRadioItem>
|
||||||
|
</DropdownMenuRadioGroup>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
<button
|
<button
|
||||||
onclick={() => (collapsed = !collapsed)}
|
onclick={() => (collapsed = !collapsed)}
|
||||||
class={cn(
|
class={cn(
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { browser } from '$app/environment';
|
||||||
|
|
||||||
|
export const THEME_STORAGE_KEY = 'evobgp-theme';
|
||||||
|
|
||||||
|
export type ThemePreference = 'light' | 'dark' | 'system';
|
||||||
|
|
||||||
|
export function readTheme(): ThemePreference {
|
||||||
|
if (!browser) return 'system';
|
||||||
|
try {
|
||||||
|
const s = localStorage.getItem(THEME_STORAGE_KEY);
|
||||||
|
if (s === 'light' || s === 'dark' || s === 'system') return s;
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
return 'system';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Синхронизирует класс `dark` на `document.documentElement` с выбранным режимом. */
|
||||||
|
export function applyTheme(mode: ThemePreference): void {
|
||||||
|
if (!browser) return;
|
||||||
|
const dark =
|
||||||
|
mode === 'dark' ||
|
||||||
|
(mode === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||||
|
document.documentElement.classList.toggle('dark', dark);
|
||||||
|
}
|
||||||
@@ -1,15 +1,45 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { browser } from '$app/environment';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
import './layout.css';
|
import './layout.css';
|
||||||
import favicon from '$lib/assets/favicon.svg';
|
import favicon from '$lib/assets/favicon.svg';
|
||||||
import AppShell from '$lib/AppShell.svelte';
|
import AppShell from '$lib/AppShell.svelte';
|
||||||
|
import { applyTheme, readTheme, THEME_STORAGE_KEY, type ThemePreference } from '$lib/theme.js';
|
||||||
import { Toaster } from 'svelte-sonner';
|
import { Toaster } from 'svelte-sonner';
|
||||||
|
|
||||||
let { children } = $props();
|
let { children } = $props();
|
||||||
|
|
||||||
|
let themePref = $state<ThemePreference>('system');
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
themePref = readTheme();
|
||||||
|
applyTheme(themePref);
|
||||||
|
const mq = window.matchMedia('(prefers-color-scheme: dark)');
|
||||||
|
const onOs = () => {
|
||||||
|
if (themePref === 'system') applyTheme('system');
|
||||||
|
};
|
||||||
|
mq.addEventListener('change', onOs);
|
||||||
|
return () => mq.removeEventListener('change', onOs);
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!browser) return;
|
||||||
|
applyTheme(themePref);
|
||||||
|
try {
|
||||||
|
localStorage.setItem(THEME_STORAGE_KEY, themePref);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const sonnerTheme = $derived(
|
||||||
|
themePref === 'system' ? 'system' : themePref === 'dark' ? 'dark' : 'light'
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<link rel="icon" href={favicon} />
|
<link rel="icon" href={favicon} />
|
||||||
<title>EvoBGP</title>
|
<title>EvoBGP</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
<Toaster richColors position="top-right" />
|
<Toaster richColors theme={sonnerTheme} position="top-right" />
|
||||||
<AppShell>{@render children()}</AppShell>
|
<AppShell bind:theme={themePref}>{@render children()}</AppShell>
|
||||||
@@ -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' }
|
||||||
@@ -202,9 +202,23 @@
|
|||||||
<Input id="m-interval" type="number" placeholder="3600" bind:value={form.refresh_interval_sec} />
|
<Input id="m-interval" type="number" placeholder="3600" bind:value={form.refresh_interval_sec} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-3">
|
<div
|
||||||
<Switch id="m-enabled" bind:checked={form.enabled} />
|
class="border-border bg-muted/30 flex flex-row items-center justify-between gap-4 rounded-lg border p-3"
|
||||||
<Label for="m-enabled">Включён</Label>
|
>
|
||||||
|
<div class="grid min-w-0 flex-1 gap-1 pr-2">
|
||||||
|
<Label for="m-enabled" class="text-foreground leading-snug">Включён</Label>
|
||||||
|
<p class="text-muted-foreground text-xs leading-snug">
|
||||||
|
Модуль участвует в сборке ревизий, если включён.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
id="m-enabled"
|
||||||
|
class="shrink-0"
|
||||||
|
checked={form.enabled !== false}
|
||||||
|
onCheckedChange={(v) => {
|
||||||
|
form = { ...form, enabled: v };
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -684,9 +689,23 @@
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-3">
|
<div
|
||||||
<Switch id="e-enabled" bind:checked={editForm.enabled} />
|
class="border-border bg-muted/30 flex flex-row items-center justify-between gap-4 rounded-lg border p-3"
|
||||||
<Label for="e-enabled">Включён</Label>
|
>
|
||||||
|
<div class="grid min-w-0 flex-1 gap-1 pr-2">
|
||||||
|
<Label for="e-enabled" class="text-foreground leading-snug">Включён</Label>
|
||||||
|
<p class="text-muted-foreground text-xs leading-snug">
|
||||||
|
Отключённые модули не участвуют в обновлении конфигурации.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
id="e-enabled"
|
||||||
|
class="shrink-0"
|
||||||
|
checked={editForm.enabled !== false}
|
||||||
|
onCheckedChange={(v) => {
|
||||||
|
editForm = { ...editForm, enabled: v };
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
@@ -717,16 +736,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 +769,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