feat: implement AS holder name resolution and enhance ASEntry structure. Add ASHolderName function to retrieve organization names from RIPEstat, update ASEntry model to include ASN name, prefix count, and resolution timestamp. Modify database interactions and API responses to support new fields, improving ASN metadata handling.
CI / changes (push) Successful in 5s
CI / openapi (push) Has been skipped
CI / go (push) Successful in 24s
CI / docker-web (deploy/docker/evobgp-web/Dockerfile, , evobgp-web) (push) Successful in 1m9s
CI / docker-web (deploy/docker/evobgp-web/Dockerfile, evobgp-all, evobgp-web-all) (push) Successful in 1m3s
CI / docker-bird (push) Has been skipped
CI / bird2 (push) Successful in 16s
CI / docker-go (deploy/docker/evobgp-agent/Dockerfile, , evobgp-agent) (push) Successful in 1m0s
CI / docker-go (evobgp-all, 1, deploy/docker/gobinary/Dockerfile, , evobgp-all) (push) Successful in 1m37s
CI / docker-go (evobgp-api, 1, deploy/docker/gobinary/Dockerfile, , evobgp-api) (push) Successful in 1m25s
CI / docker-go (evobgp-deploy, 0, deploy/docker/gobinary/Dockerfile, , evobgp-deploy) (push) Successful in 1m29s
CI / docker-go (evobgp-ingest, 0, deploy/docker/gobinary/Dockerfile, , evobgp-ingest) (push) Successful in 1m25s
CI / docker-go (evobgp-node, 0, deploy/docker/gobinary/Dockerfile, , evobgp-node) (push) Successful in 1m21s
CI / docker-go (evobgp-render, 0, deploy/docker/gobinary/Dockerfile, , evobgp-render) (push) Successful in 1m24s
CI / docker-go (evobgp-scheduler, 0, deploy/docker/gobinary/Dockerfile, , evobgp-scheduler) (push) Successful in 1m31s
CI / changes (push) Successful in 5s
CI / openapi (push) Has been skipped
CI / go (push) Successful in 24s
CI / docker-web (deploy/docker/evobgp-web/Dockerfile, , evobgp-web) (push) Successful in 1m9s
CI / docker-web (deploy/docker/evobgp-web/Dockerfile, evobgp-all, evobgp-web-all) (push) Successful in 1m3s
CI / docker-bird (push) Has been skipped
CI / bird2 (push) Successful in 16s
CI / docker-go (deploy/docker/evobgp-agent/Dockerfile, , evobgp-agent) (push) Successful in 1m0s
CI / docker-go (evobgp-all, 1, deploy/docker/gobinary/Dockerfile, , evobgp-all) (push) Successful in 1m37s
CI / docker-go (evobgp-api, 1, deploy/docker/gobinary/Dockerfile, , evobgp-api) (push) Successful in 1m25s
CI / docker-go (evobgp-deploy, 0, deploy/docker/gobinary/Dockerfile, , evobgp-deploy) (push) Successful in 1m29s
CI / docker-go (evobgp-ingest, 0, deploy/docker/gobinary/Dockerfile, , evobgp-ingest) (push) Successful in 1m25s
CI / docker-go (evobgp-node, 0, deploy/docker/gobinary/Dockerfile, , evobgp-node) (push) Successful in 1m21s
CI / docker-go (evobgp-render, 0, deploy/docker/gobinary/Dockerfile, , evobgp-render) (push) Successful in 1m24s
CI / docker-go (evobgp-scheduler, 0, deploy/docker/gobinary/Dockerfile, , evobgp-scheduler) (push) Successful in 1m31s
This commit is contained in:
@@ -18,6 +18,9 @@ import (
|
||||
// DefaultRIPEStatURL is the RIPEstat announced-prefixes data call (no API key).
|
||||
const DefaultRIPEStatURL = "https://stat.ripe.net/data/announced-prefixes/data.json"
|
||||
|
||||
// DefaultASOverviewURL is the RIPEstat as-overview data call (holder / org name, no API key).
|
||||
const DefaultASOverviewURL = "https://stat.ripe.net/data/as-overview/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 {
|
||||
@@ -80,6 +83,51 @@ func AnnouncedPrefixes(ctx context.Context, hc *http.Client, asn int64) ([]netip
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ASHolderName returns the holder / organization label for the ASN from RIPEstat as-overview (best-effort).
|
||||
func ASHolderName(ctx context.Context, hc *http.Client, asn int64) (string, error) {
|
||||
if hc == nil {
|
||||
hc = http.DefaultClient
|
||||
}
|
||||
base := strings.TrimSpace(os.Getenv("EVOBGP_RIPESTAT_AS_OVERVIEW_URL"))
|
||||
if base == "" {
|
||||
base = DefaultASOverviewURL
|
||||
}
|
||||
u := fmt.Sprintf("%s?resource=AS%d", strings.TrimSuffix(base, "?"), asn)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
|
||||
if err != nil {
|
||||
return "", 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 "", fmt.Errorf("ripestat as-overview AS%d: %w", asn, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("ripestat as-overview AS%d: HTTP %s: %s", asn, resp.Status, truncateForErr(body, 200))
|
||||
}
|
||||
|
||||
var wrap struct {
|
||||
Status string `json:"status"`
|
||||
Data struct {
|
||||
Holder string `json:"holder"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &wrap); err != nil {
|
||||
return "", fmt.Errorf("ripestat as-overview AS%d: json: %w", asn, err)
|
||||
}
|
||||
if wrap.Status != "" && wrap.Status != "ok" {
|
||||
return "", fmt.Errorf("ripestat as-overview AS%d: status %q", asn, wrap.Status)
|
||||
}
|
||||
return strings.TrimSpace(wrap.Data.Holder), nil
|
||||
}
|
||||
|
||||
func truncateForErr(b []byte, n int) string {
|
||||
s := string(b)
|
||||
if len(s) > n {
|
||||
|
||||
@@ -31,3 +31,24 @@ func TestAnnouncedPrefixes_Mock(t *testing.T) {
|
||||
t.Fatalf("order or values: %#v", pfx)
|
||||
}
|
||||
}
|
||||
|
||||
func TestASHolderName_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":{"holder":"Example Networks Ltd"}}`))
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
t.Setenv("EVOBGP_RIPESTAT_AS_OVERVIEW_URL", ts.URL)
|
||||
|
||||
name, err := ASHolderName(context.Background(), ts.Client(), 64496)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if name != "Example Networks Ltd" {
|
||||
t.Fatalf("holder: %q", name)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"evobgp/internal/store"
|
||||
)
|
||||
@@ -237,6 +239,21 @@ func asEntryJSON(x *store.ASEntry) map[string]any {
|
||||
} else {
|
||||
m["community_id"] = nil
|
||||
}
|
||||
if strings.TrimSpace(x.ASNName) != "" {
|
||||
m["asn_name"] = strings.TrimSpace(x.ASNName)
|
||||
} else {
|
||||
m["asn_name"] = nil
|
||||
}
|
||||
if x.PrefixCount != nil {
|
||||
m["prefix_count"] = *x.PrefixCount
|
||||
} else {
|
||||
m["prefix_count"] = nil
|
||||
}
|
||||
if x.ASNResolvedAt != nil {
|
||||
m["asn_resolved_at"] = x.ASNResolvedAt.UTC().Format(time.RFC3339Nano)
|
||||
} else {
|
||||
m["asn_resolved_at"] = nil
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"evobgp/internal/asnresolve"
|
||||
"evobgp/internal/birdfmt"
|
||||
@@ -86,6 +87,15 @@ func RefreshModule(ctx context.Context, st store.Backend, hc *http.Client, tenan
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolve AS%d: %w", e.ASN, err)
|
||||
}
|
||||
holder := ""
|
||||
asnresolve.PolitePause()
|
||||
if h, err := asnresolve.ASHolderName(ctx, hc, e.ASN); err == nil {
|
||||
holder = h
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
if err := st.UpdateASEntryResolveMeta(tenantID, moduleID, e.ID, holder, int64(len(pfxs)), now); err != nil {
|
||||
return "", fmt.Errorf("as entry meta AS%d: %w", e.ASN, err)
|
||||
}
|
||||
src := fmt.Sprintf("as:%d", e.ASN)
|
||||
for _, pfx := range pfxs {
|
||||
k := pfx.String()
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"evobgp/internal/store"
|
||||
|
||||
@@ -164,7 +165,8 @@ func (p *Postgres) ListASEntries(tenantID, moduleID string) ([]*store.ASEntry, e
|
||||
}
|
||||
ctx := context.Background()
|
||||
rows, err := p.pool.Query(ctx, `
|
||||
SELECT id::text, asn, community_id::text FROM module_as_entry WHERE module_id=$1`, moduleID)
|
||||
SELECT id::text, asn, community_id::text, asn_name, prefix_count, asn_resolved_at
|
||||
FROM module_as_entry WHERE module_id=$1 ORDER BY asn`, moduleID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -173,11 +175,18 @@ func (p *Postgres) ListASEntries(tenantID, moduleID string) ([]*store.ASEntry, e
|
||||
for rows.Next() {
|
||||
var e store.ASEntry
|
||||
e.ModuleID = moduleID
|
||||
var comm *string
|
||||
if err := rows.Scan(&e.ID, &e.ASN, &comm); err != nil {
|
||||
var comm, asnName *string
|
||||
var pc *int64
|
||||
var at *time.Time
|
||||
if err := rows.Scan(&e.ID, &e.ASN, &comm, &asnName, &pc, &at); err != nil {
|
||||
continue
|
||||
}
|
||||
e.CommunityID = strOrNil(comm)
|
||||
if asnName != nil {
|
||||
e.ASNName = *asnName
|
||||
}
|
||||
e.PrefixCount = pc
|
||||
e.ASNResolvedAt = at
|
||||
out = append(out, &e)
|
||||
}
|
||||
return out, nil
|
||||
@@ -209,14 +218,22 @@ func (p *Postgres) CreateASEntry(tenantID, moduleID string, in *store.ASEntry) (
|
||||
func (p *Postgres) getASEntry(ctx context.Context, moduleID, id string) (*store.ASEntry, error) {
|
||||
var e store.ASEntry
|
||||
e.ModuleID = moduleID
|
||||
var comm *string
|
||||
var comm, asnName *string
|
||||
var pc *int64
|
||||
var at *time.Time
|
||||
err := p.pool.QueryRow(ctx, `
|
||||
SELECT id::text, asn, community_id::text FROM module_as_entry WHERE id=$1 AND module_id=$2`, id, moduleID).Scan(
|
||||
&e.ID, &e.ASN, &comm)
|
||||
SELECT id::text, asn, community_id::text, asn_name, prefix_count, asn_resolved_at
|
||||
FROM module_as_entry WHERE id=$1 AND module_id=$2`, id, moduleID).Scan(
|
||||
&e.ID, &e.ASN, &comm, &asnName, &pc, &at)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
e.CommunityID = strOrNil(comm)
|
||||
if asnName != nil {
|
||||
e.ASNName = *asnName
|
||||
}
|
||||
e.PrefixCount = pc
|
||||
e.ASNResolvedAt = at
|
||||
return &e, nil
|
||||
}
|
||||
|
||||
@@ -231,6 +248,7 @@ func (p *Postgres) UpdateASEntry(tenantID, moduleID, entryID string, patch *stor
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
prevASN := cur.ASN
|
||||
if patch.ASN != nil {
|
||||
cur.ASN = *patch.ASN
|
||||
}
|
||||
@@ -245,17 +263,51 @@ func (p *Postgres) UpdateASEntry(tenantID, moduleID, entryID string, patch *stor
|
||||
if !store.ValidASN(cur.ASN) {
|
||||
return nil, store.ErrInvalidInput
|
||||
}
|
||||
clearResolve := patch.ASN != nil && cur.ASN != prevASN
|
||||
ctx := context.Background()
|
||||
_, err = p.pool.Exec(ctx, `
|
||||
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`,
|
||||
entryID, moduleID, cur.ASN, uuidOrNilPtr(cur.CommunityID))
|
||||
if clearResolve {
|
||||
_, err = p.pool.Exec(ctx, `
|
||||
UPDATE module_as_entry SET asn=$3, community_id=NULLIF($4::uuid, '00000000-0000-0000-0000-000000000000'::uuid),
|
||||
asn_name=NULL, prefix_count=NULL, asn_resolved_at=NULL, updated_at=now()
|
||||
WHERE id=$1 AND module_id=$2`,
|
||||
entryID, moduleID, cur.ASN, uuidOrNilPtr(cur.CommunityID))
|
||||
} else {
|
||||
_, err = p.pool.Exec(ctx, `
|
||||
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`,
|
||||
entryID, moduleID, cur.ASN, uuidOrNilPtr(cur.CommunityID))
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return p.getASEntry(ctx, moduleID, entryID)
|
||||
}
|
||||
|
||||
func (p *Postgres) UpdateASEntryResolveMeta(tenantID, moduleID, entryID string, asnName string, prefixCount int64, resolvedAt time.Time) error {
|
||||
if _, err := p.GetModule(tenantID, moduleID); err != nil {
|
||||
return err
|
||||
}
|
||||
ctx := context.Background()
|
||||
var nameArg any
|
||||
sn := strings.TrimSpace(asnName)
|
||||
if sn == "" {
|
||||
nameArg = nil
|
||||
} else {
|
||||
nameArg = sn
|
||||
}
|
||||
tag, err := p.pool.Exec(ctx, `
|
||||
UPDATE module_as_entry SET asn_name=$3, prefix_count=$4, asn_resolved_at=$5, updated_at=now()
|
||||
WHERE id=$1 AND module_id=$2`,
|
||||
entryID, moduleID, nameArg, prefixCount, resolvedAt.UTC())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return store.ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Postgres) DeleteASEntry(tenantID, moduleID, entryID string) error {
|
||||
if _, err := p.GetModule(tenantID, moduleID); err != nil {
|
||||
return err
|
||||
|
||||
@@ -28,6 +28,8 @@ type Backend interface {
|
||||
CreateASEntry(tenantID, moduleID string, in *ASEntry) (*ASEntry, error)
|
||||
UpdateASEntry(tenantID, moduleID, entryID string, patch *ASEntryPatch) (*ASEntry, error)
|
||||
DeleteASEntry(tenantID, moduleID, entryID string) error
|
||||
// UpdateASEntryResolveMeta записывает имя AS, число объявленных префиксов и время успешного резолва (pipeline).
|
||||
UpdateASEntryResolveMeta(tenantID, moduleID, entryID string, asnName string, prefixCount int64, resolvedAt time.Time) error
|
||||
|
||||
ListDomainEntries(tenantID, moduleID string) ([]*DomainEntry, error)
|
||||
CreateDomainEntry(tenantID, moduleID string, in *DomainEntry) (*DomainEntry, error)
|
||||
@@ -109,10 +111,13 @@ type CDNSourcePatch struct {
|
||||
}
|
||||
|
||||
type ASEntry struct {
|
||||
ID string `json:"id,omitempty"`
|
||||
ModuleID string `json:"module_id,omitempty"`
|
||||
ASN int64 `json:"asn"`
|
||||
CommunityID *string `json:"community_id"`
|
||||
ID string `json:"id,omitempty"`
|
||||
ModuleID string `json:"module_id,omitempty"`
|
||||
ASN int64 `json:"asn"`
|
||||
CommunityID *string `json:"community_id"`
|
||||
ASNName string `json:"asn_name,omitempty"`
|
||||
PrefixCount *int64 `json:"prefix_count,omitempty"`
|
||||
ASNResolvedAt *time.Time `json:"asn_resolved_at,omitempty"`
|
||||
}
|
||||
|
||||
type ASEntryPatch struct {
|
||||
|
||||
@@ -248,6 +248,7 @@ func (m *Memory) UpdateASEntry(tenantID, moduleID, entryID string, patch *ASEntr
|
||||
if !ok || e.ModuleID != moduleID {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
prevASN := e.ASN
|
||||
if patch.ASN != nil {
|
||||
e.ASN = *patch.ASN
|
||||
}
|
||||
@@ -262,9 +263,32 @@ func (m *Memory) UpdateASEntry(tenantID, moduleID, entryID string, patch *ASEntr
|
||||
if !ValidASN(e.ASN) {
|
||||
return nil, ErrInvalidInput
|
||||
}
|
||||
if patch.ASN != nil && e.ASN != prevASN {
|
||||
e.ASNName = ""
|
||||
e.PrefixCount = nil
|
||||
e.ASNResolvedAt = nil
|
||||
}
|
||||
return e, nil
|
||||
}
|
||||
|
||||
func (m *Memory) UpdateASEntryResolveMeta(tenantID, moduleID, entryID string, asnName string, prefixCount int64, resolvedAt time.Time) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if _, err := m.moduleWriteOK(tenantID, moduleID); err != nil {
|
||||
return err
|
||||
}
|
||||
e, ok := m.asEntries[entryID]
|
||||
if !ok || e.ModuleID != moduleID {
|
||||
return ErrNotFound
|
||||
}
|
||||
e.ASNName = strings.TrimSpace(asnName)
|
||||
pc := prefixCount
|
||||
e.PrefixCount = &pc
|
||||
t := resolvedAt.UTC()
|
||||
e.ASNResolvedAt = &t
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Memory) DeleteASEntry(tenantID, moduleID, entryID string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE module_as_entry DROP COLUMN IF EXISTS asn_name;
|
||||
ALTER TABLE module_as_entry DROP COLUMN IF EXISTS prefix_count;
|
||||
ALTER TABLE module_as_entry DROP COLUMN IF EXISTS asn_resolved_at;
|
||||
@@ -0,0 +1,4 @@
|
||||
-- Метаданные RIPEstat после резолва ASN (имя, число префиксов, время обновления).
|
||||
ALTER TABLE module_as_entry ADD COLUMN asn_name TEXT;
|
||||
ALTER TABLE module_as_entry ADD COLUMN prefix_count BIGINT;
|
||||
ALTER TABLE module_as_entry ADD COLUMN asn_resolved_at TIMESTAMPTZ;
|
||||
@@ -0,0 +1,4 @@
|
||||
-- Требуется SQLite 3.35.0+ (DROP COLUMN).
|
||||
ALTER TABLE module_as_entry DROP COLUMN asn_name;
|
||||
ALTER TABLE module_as_entry DROP COLUMN prefix_count;
|
||||
ALTER TABLE module_as_entry DROP COLUMN asn_resolved_at;
|
||||
@@ -0,0 +1,4 @@
|
||||
-- Метаданные RIPEstat после резолва ASN (имя, число префиксов, время обновления).
|
||||
ALTER TABLE module_as_entry ADD COLUMN asn_name TEXT;
|
||||
ALTER TABLE module_as_entry ADD COLUMN prefix_count INTEGER;
|
||||
ALTER TABLE module_as_entry ADD COLUMN asn_resolved_at TEXT;
|
||||
@@ -38,6 +38,12 @@ export type AsEntry = {
|
||||
id: string;
|
||||
asn: number;
|
||||
community_id: string | null;
|
||||
/** Имя/держатель AS (RIPEstat), после успешного обновления модуля */
|
||||
asn_name?: string | null;
|
||||
/** Число объявленных префиксов на момент последнего резолва */
|
||||
prefix_count?: number | null;
|
||||
/** ISO-время последнего успешного резолва ASN */
|
||||
asn_resolved_at?: string | null;
|
||||
};
|
||||
export type AsEntryCreate = {
|
||||
asn: number;
|
||||
|
||||
@@ -474,7 +474,9 @@
|
||||
<CardHeader class="flex flex-row items-center justify-between pb-2">
|
||||
<div>
|
||||
<CardTitle class="text-base">AS-записи</CardTitle>
|
||||
<CardDescription>Номер AS и привязка к BGP community</CardDescription>
|
||||
<CardDescription>
|
||||
Номер AS и community; имя, число префиксов и дата обновляются при успешном обновлении модуля (RIPEstat)
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button size="sm" onclick={openAsCreate}><Plus />Добавить</Button>
|
||||
</CardHeader>
|
||||
@@ -483,6 +485,9 @@
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>ASN</TableHead>
|
||||
<TableHead>Название AS</TableHead>
|
||||
<TableHead class="text-right">Префиксов</TableHead>
|
||||
<TableHead>Обновлено</TableHead>
|
||||
<TableHead>Community</TableHead>
|
||||
<TableHead class="w-20"></TableHead>
|
||||
</TableRow>
|
||||
@@ -491,6 +496,20 @@
|
||||
{#each asEntries as entry (entry.id)}
|
||||
<TableRow>
|
||||
<TableCell class="font-mono">{entry.asn}</TableCell>
|
||||
<TableCell
|
||||
class="text-muted-foreground text-sm max-w-[14rem] truncate"
|
||||
title={entry.asn_name ?? ''}
|
||||
>
|
||||
{entry.asn_name?.trim() ? entry.asn_name : '—'}
|
||||
</TableCell>
|
||||
<TableCell class="text-right font-mono text-sm">
|
||||
{entry.prefix_count != null && entry.prefix_count !== undefined ? entry.prefix_count : '—'}
|
||||
</TableCell>
|
||||
<TableCell class="text-muted-foreground text-sm whitespace-nowrap">
|
||||
{entry.asn_resolved_at
|
||||
? new Date(entry.asn_resolved_at).toLocaleString('ru-RU')
|
||||
: '—'}
|
||||
</TableCell>
|
||||
<TableCell class="text-muted-foreground text-sm">{communityName(entry.community_id)}</TableCell>
|
||||
<TableCell>
|
||||
<div class="flex gap-1">
|
||||
@@ -501,7 +520,7 @@
|
||||
</TableRow>
|
||||
{:else}
|
||||
<TableRow>
|
||||
<TableCell colspan={3} class="text-muted-foreground text-center py-6">Нет записей</TableCell>
|
||||
<TableCell colspan={6} class="text-muted-foreground text-center py-6">Нет записей</TableCell>
|
||||
</TableRow>
|
||||
{/each}
|
||||
</TableBody>
|
||||
|
||||
Reference in New Issue
Block a user