Compare commits

...
2 Commits
Author SHA1 Message Date
Denozordec 1e13ef9e70 feat: add last refreshed timestamp to modules and CDN sources
CI / changes (push) Successful in 6s
CI / openapi (push) Successful in 23s
CI / go (push) Successful in 45s
CI / docker-web (deploy/docker/evobgp-web/Dockerfile, , evobgp-web) (push) Successful in 1m14s
CI / docker-web (deploy/docker/evobgp-web/Dockerfile, evobgp-all, evobgp-web-all) (push) Successful in 1m7s
CI / docker-bird (push) Has been skipped
CI / bird2 (push) Successful in 16s
CI / docker-go-prime (push) Successful in 23s
CI / docker-go (deploy/docker/evobgp-agent/Dockerfile, , evobgp-agent) (push) Successful in 1m2s
CI / docker-go (evobgp-all, 1, deploy/docker/gobinary/Dockerfile, , evobgp-all) (push) Successful in 6m21s
CI / docker-go (evobgp-api, 1, deploy/docker/gobinary/Dockerfile, , evobgp-api) (push) Successful in 1m32s
CI / docker-go (evobgp-deploy, 0, deploy/docker/gobinary/Dockerfile, , evobgp-deploy) (push) Successful in 1m26s
CI / docker-go (evobgp-ingest, 0, deploy/docker/gobinary/Dockerfile, , evobgp-ingest) (push) Successful in 1m26s
CI / docker-go (evobgp-node, 0, deploy/docker/gobinary/Dockerfile, , evobgp-node) (push) Successful in 1m8s
CI / docker-go (evobgp-render, 0, deploy/docker/gobinary/Dockerfile, , evobgp-render) (push) Successful in 1m25s
CI / docker-go (evobgp-scheduler, 0, deploy/docker/gobinary/Dockerfile, , evobgp-scheduler) (push) Successful in 1m28s
Introduced a new field, last_refreshed_at, to track the last successful update time for modules and CDN sources. Updated the database schema, API responses, and internal logic to support this feature. Enhanced the frontend to display the last refreshed timestamp, improving visibility into the data update status for users.
2026-04-09 16:10:00 +07:00
Denozordec 47764345f6 feat: enhance CDN source management with last refreshed timestamp
Added functionality to track the last refreshed timestamp for CDN sources. Updated the database schema and relevant methods to include the last refreshed timestamp during creation and updates. Implemented logic to skip fetching CDN sources based on their refresh interval, improving efficiency in the module prefix collection process. Enhanced the data retrieval methods to support the new timestamp field, ensuring accurate state management for CDN sources.
2026-04-09 16:04:53 +07:00
20 changed files with 229 additions and 58 deletions

No files matched your search

+8
View File
@@ -339,6 +339,10 @@ components:
type: ["string", "null"] type: ["string", "null"]
default_community_id: default_community_id:
type: ["string", "null"] type: ["string", "null"]
last_refreshed_at:
type: ["string", "null"]
format: date-time
description: Время последнего успешного обновления данных модуля.
additionalProperties: true additionalProperties: true
ModuleCreate: ModuleCreate:
@@ -361,6 +365,10 @@ components:
type: ["string", "null"] type: ["string", "null"]
refresh_interval_sec: refresh_interval_sec:
type: ["integer", "null"] type: ["integer", "null"]
last_refreshed_at:
type: ["string", "null"]
format: date-time
description: Время последнего успешного обновления этого CDN-источника.
cron_expr: cron_expr:
type: ["string", "null"] type: ["string", "null"]
default_community_id: default_community_id:
+5
View File
@@ -110,6 +110,11 @@ func moduleJSON(mod *store.Module) map[string]any {
"refresh_interval_sec": mod.RefreshIntervalSec, "refresh_interval_sec": mod.RefreshIntervalSec,
"cron_expr": mod.CronExpr, "cron_expr": mod.CronExpr,
} }
if mod.LastRefreshedAt != nil {
m["last_refreshed_at"] = mod.LastRefreshedAt.UTC().Format(time.RFC3339Nano)
} else {
m["last_refreshed_at"] = nil
}
if mod.DefaultCommunityID != nil { if mod.DefaultCommunityID != nil {
m["default_community_id"] = *mod.DefaultCommunityID m["default_community_id"] = *mod.DefaultCommunityID
} else { } else {
+5
View File
@@ -199,6 +199,11 @@ func cdnSourceJSON(x *store.CDNSource) map[string]any {
} else { } else {
m["community_id"] = nil m["community_id"] = nil
} }
if x.LastRefreshedAt != nil {
m["last_refreshed_at"] = x.LastRefreshedAt.UTC().Format(time.RFC3339Nano)
} else {
m["last_refreshed_at"] = nil
}
return m return m
} }
+52 -2
View File
@@ -59,6 +59,8 @@ func RefreshModuleIngest(ctx context.Context, st store.Backend, hc *http.Client,
if err != nil { if err != nil {
return err return err
} }
refreshedAt := time.Now().UTC()
_, _ = st.UpdateModule(tenantID, moduleID, &store.ModulePatch{LastRefreshedAt: &refreshedAt})
return nil return nil
} }
@@ -200,7 +202,16 @@ func collectModulePrefixRows(ctx context.Context, st store.Backend, hc *http.Cli
return nil, err return nil, err
} }
var rows []store.PrefixRow var rows []store.PrefixRow
latestCDNRows := latestCDNRowsBySource(st, tenantID)
for _, src := range sources { for _, src := range sources {
sourceKey := "cdn:" + src.ID
now := time.Now().UTC()
if shouldSkipCDNSourceFetch(src, now) {
if cached := latestCDNRows[sourceKey]; len(cached) > 0 {
rows = append(rows, cached...)
continue
}
}
u := strings.TrimSpace(src.URL) u := strings.TrimSpace(src.URL)
if u == "" { if u == "" {
continue continue
@@ -224,10 +235,14 @@ func collectModulePrefixRows(ctx context.Context, st store.Backend, hc *http.Cli
return nil, err return nil, err
} }
etag := strings.TrimSpace(resp.Header.Get("ETag")) etag := strings.TrimSpace(resp.Header.Get("ETag"))
patch := &store.CDNSourcePatch{}
if etag != "" && etag != strings.TrimSpace(src.Etag) { if etag != "" && etag != strings.TrimSpace(src.Etag) {
e := etag e := etag
_, _ = st.UpdateCDNSource(tenantID, moduleID, src.ID, &store.CDNSourcePatch{Etag: &e}) patch.Etag = &e
} }
refreshedAt := now
patch.LastRefreshedAt = &refreshedAt
_, _ = st.UpdateCDNSource(tenantID, moduleID, src.ID, patch)
pfxs, err := ExtractCIDRs(string(body), src.SourceKind, src.PrefixPath) pfxs, err := ExtractCIDRs(string(body), src.SourceKind, src.PrefixPath)
if err != nil { if err != nil {
return nil, fmt.Errorf("cdn parse %s: %w", u, err) return nil, fmt.Errorf("cdn parse %s: %w", u, err)
@@ -238,7 +253,7 @@ func collectModulePrefixRows(ctx context.Context, st store.Backend, hc *http.Cli
c := *mod.DefaultCommunityID c := *mod.DefaultCommunityID
comm = &c comm = &c
} }
rows = append(rows, store.PrefixRow{Prefix: pfx.String(), CommunityID: comm, Source: "cdn:" + src.ID}) rows = append(rows, store.PrefixRow{Prefix: pfx.String(), CommunityID: comm, Source: sourceKey})
} }
} }
return rows, nil return rows, nil
@@ -293,6 +308,41 @@ func collectModulePrefixRows(ctx context.Context, st store.Backend, hc *http.Cli
} }
} }
func shouldSkipCDNSourceFetch(src *store.CDNSource, now time.Time) bool {
if src == nil || src.RefreshIntervalSec == nil || *src.RefreshIntervalSec <= 0 || src.LastRefreshedAt == nil {
return false
}
nextRefreshAt := src.LastRefreshedAt.UTC().Add(time.Duration(*src.RefreshIntervalSec) * time.Second)
return now.UTC().Before(nextRefreshAt)
}
func latestCDNRowsBySource(st store.Backend, tenantID string) map[string][]store.PrefixRow {
out := make(map[string][]store.PrefixRow)
if st == nil {
return out
}
revs, _, _ := st.ListRevisions(tenantID, "", "", 1)
if len(revs) == 0 || strings.TrimSpace(revs[0].ID) == "" {
return out
}
revID := strings.TrimSpace(revs[0].ID)
cursor := ""
for {
page, next, more := st.ListRevisionPrefixes(tenantID, revID, cursor, 2000)
for _, row := range page {
if !strings.HasPrefix(strings.TrimSpace(row.Source), "cdn:") {
continue
}
out[row.Source] = append(out[row.Source], row)
}
if !more || strings.TrimSpace(next) == "" {
break
}
cursor = next
}
return out
}
type dohJSONAnswer struct { type dohJSONAnswer struct {
Type int `json:"type"` Type int `json:"type"`
Data string `json:"data"` Data string `json:"data"`
+42 -11
View File
@@ -113,7 +113,7 @@ func (p *Postgres) PeerSessionCountsByState() map[string]int {
func (p *Postgres) ListModules(tenantID string) []*store.Module { func (p *Postgres) ListModules(tenantID string) []*store.Module {
ctx := context.Background() ctx := context.Background()
rows, err := p.pool.Query(ctx, ` rows, err := p.pool.Query(ctx, `
SELECT id, type, name, enabled, priority, doh_profile_id::text, refresh_interval_sec, cron_expr, default_community_id::text SELECT id, type, name, enabled, priority, doh_profile_id::text, refresh_interval_sec, cron_expr, default_community_id::text, last_refreshed_at
FROM module WHERE tenant_id = $1 AND deleted_at IS NULL ORDER BY priority, name`, tenantID) FROM module WHERE tenant_id = $1 AND deleted_at IS NULL ORDER BY priority, name`, tenantID)
if err != nil { if err != nil {
return nil return nil
@@ -125,7 +125,8 @@ func (p *Postgres) ListModules(tenantID string) []*store.Module {
m.TenantID = tenantID m.TenantID = tenantID
var doh, dc, cron *string var doh, dc, cron *string
var refresh *int32 var refresh *int32
if err := rows.Scan(&m.ID, &m.Type, &m.Name, &m.Enabled, &m.Priority, &doh, &refresh, &cron, &dc); err != nil { var last *time.Time
if err := rows.Scan(&m.ID, &m.Type, &m.Name, &m.Enabled, &m.Priority, &doh, &refresh, &cron, &dc, &last); err != nil {
continue continue
} }
if refresh != nil { if refresh != nil {
@@ -140,6 +141,10 @@ func (p *Postgres) ListModules(tenantID string) []*store.Module {
if dc != nil && *dc != "" { if dc != nil && *dc != "" {
m.DefaultCommunityID = dc m.DefaultCommunityID = dc
} }
if last != nil {
t := last.UTC()
m.LastRefreshedAt = &t
}
out = append(out, &m) out = append(out, &m)
} }
return out return out
@@ -151,10 +156,11 @@ func (p *Postgres) GetModule(tenantID, moduleID string) (*store.Module, error) {
m.TenantID = tenantID m.TenantID = tenantID
var doh, dc, cron *string var doh, dc, cron *string
var refresh *int32 var refresh *int32
var last *time.Time
err := p.pool.QueryRow(ctx, ` err := p.pool.QueryRow(ctx, `
SELECT id, type, name, enabled, priority, doh_profile_id::text, refresh_interval_sec, cron_expr, default_community_id::text SELECT id, type, name, enabled, priority, doh_profile_id::text, refresh_interval_sec, cron_expr, default_community_id::text, last_refreshed_at
FROM module WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL`, moduleID, tenantID).Scan( FROM module WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL`, moduleID, tenantID).Scan(
&m.ID, &m.Type, &m.Name, &m.Enabled, &m.Priority, &doh, &refresh, &cron, &dc) &m.ID, &m.Type, &m.Name, &m.Enabled, &m.Priority, &doh, &refresh, &cron, &dc, &last)
if err != nil { if err != nil {
if errors.Is(err, pgx.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {
return nil, store.ErrNotFound return nil, store.ErrNotFound
@@ -173,6 +179,10 @@ func (p *Postgres) GetModule(tenantID, moduleID string) (*store.Module, error) {
if dc != nil && *dc != "" { if dc != nil && *dc != "" {
m.DefaultCommunityID = dc m.DefaultCommunityID = dc
} }
if last != nil {
t := last.UTC()
m.LastRefreshedAt = &t
}
return &m, nil return &m, nil
} }
@@ -197,10 +207,14 @@ func (p *Postgres) CreateModule(tenantID string, in *store.Module) (*store.Modul
if strings.TrimSpace(in.CronExpr) != "" { if strings.TrimSpace(in.CronExpr) != "" {
cronArg = strings.TrimSpace(in.CronExpr) cronArg = strings.TrimSpace(in.CronExpr)
} }
var lastArg any
if in.LastRefreshedAt != nil {
lastArg = in.LastRefreshedAt.UTC()
}
_, err := p.pool.Exec(ctx, ` _, err := p.pool.Exec(ctx, `
INSERT INTO module (id, tenant_id, type, name, enabled, priority, doh_profile_id, refresh_interval_sec, cron_expr, default_community_id) INSERT INTO module (id, tenant_id, type, name, enabled, priority, doh_profile_id, refresh_interval_sec, cron_expr, default_community_id, last_refreshed_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)`, VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)`,
id, tenantID, in.Type, in.Name, in.Enabled, in.Priority, doh, ri, cronArg, dc) id, tenantID, in.Type, in.Name, in.Enabled, in.Priority, doh, ri, cronArg, dc, lastArg)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -224,6 +238,7 @@ func (p *Postgres) UpdateModule(tenantID, moduleID string, patch *store.ModulePa
var dc, doh *string var dc, doh *string
dc = base.DefaultCommunityID dc = base.DefaultCommunityID
doh = base.DohProfileID doh = base.DohProfileID
last := base.LastRefreshedAt
if patch.Name != nil { if patch.Name != nil {
name = strings.TrimSpace(*patch.Name) name = strings.TrimSpace(*patch.Name)
} }
@@ -255,6 +270,10 @@ func (p *Postgres) UpdateModule(tenantID, moduleID string, patch *store.ModulePa
doh = &v doh = &v
} }
} }
if patch.LastRefreshedAt != nil {
t := patch.LastRefreshedAt.UTC()
last = &t
}
var dcArg, dohArg any var dcArg, dohArg any
if dc != nil { if dc != nil {
dcArg = *dc dcArg = *dc
@@ -270,11 +289,15 @@ func (p *Postgres) UpdateModule(tenantID, moduleID string, patch *store.ModulePa
if strings.TrimSpace(cron) != "" { if strings.TrimSpace(cron) != "" {
cronArg = strings.TrimSpace(cron) cronArg = strings.TrimSpace(cron)
} }
var lastArg any
if last != nil {
lastArg = last.UTC()
}
_, err = p.pool.Exec(ctx, ` _, err = p.pool.Exec(ctx, `
UPDATE module SET name=$3, enabled=$4, priority=$5, refresh_interval_sec=$6, cron_expr=$7, UPDATE module SET name=$3, enabled=$4, priority=$5, refresh_interval_sec=$6, cron_expr=$7,
default_community_id=$8, doh_profile_id=$9, updated_at=now() default_community_id=$8, doh_profile_id=$9, last_refreshed_at=$10, updated_at=now()
WHERE id=$1 AND tenant_id=$2 AND deleted_at IS NULL`, WHERE id=$1 AND tenant_id=$2 AND deleted_at IS NULL`,
moduleID, tenantID, name, en, pr, riArg, cronArg, dcArg, dohArg) moduleID, tenantID, name, en, pr, riArg, cronArg, dcArg, dohArg, lastArg)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -698,7 +721,7 @@ func (p *Postgres) CreateRollbackRevision(tenantID, sourceRevisionID string) (st
newID := uuid.NewString() newID := uuid.NewString()
parent := sourceRevisionID parent := sourceRevisionID
meta, _ := json.Marshal(map[string]any{ meta, _ := json.Marshal(map[string]any{
"preview_fragments": src.PreviewFragments, "preview_fragments": src.PreviewFragments,
"materialized_prefix_count": src.MaterializedPrefixCount, "materialized_prefix_count": src.MaterializedPrefixCount,
}) })
var modArg any var modArg any
@@ -883,7 +906,7 @@ func (p *Postgres) CreateRenderRevision(revisionID, tenantID, moduleID string, p
previewFragments = map[string]string{} previewFragments = map[string]string{}
} }
meta, err := json.Marshal(map[string]any{ meta, err := json.Marshal(map[string]any{
"preview_fragments": previewFragments, "preview_fragments": previewFragments,
"materialized_prefix_count": len(prefixes), "materialized_prefix_count": len(prefixes),
}) })
if err != nil { if err != nil {
@@ -1178,6 +1201,14 @@ func nullInt32Ptr(i *int) *int32 {
return &v return &v
} }
func nullTimePtr(t *time.Time) *time.Time {
if t == nil {
return nil
}
v := t.UTC()
return &v
}
func nullJSON(s string) *string { func nullJSON(s string) *string {
if strings.TrimSpace(s) == "" { if strings.TrimSpace(s) == "" {
v := "{}" v := "{}"
+23 -9
View File
@@ -23,7 +23,7 @@ func (p *Postgres) ListCDNSources(tenantID, moduleID string) ([]*store.CDNSource
} }
ctx := context.Background() ctx := context.Background()
rows, err := p.pool.Query(ctx, ` rows, err := p.pool.Query(ctx, `
SELECT id::text, source_kind, url, COALESCE(prefix_path,''), COALESCE(etag,''), refresh_interval_sec, community_id::text SELECT id::text, source_kind, url, COALESCE(prefix_path,''), COALESCE(etag,''), refresh_interval_sec, community_id::text, last_refreshed_at
FROM module_cdn_source WHERE module_id=$1 ORDER BY url`, moduleID) FROM module_cdn_source WHERE module_id=$1 ORDER BY url`, moduleID)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -35,7 +35,8 @@ func (p *Postgres) ListCDNSources(tenantID, moduleID string) ([]*store.CDNSource
s.ModuleID = moduleID s.ModuleID = moduleID
var ri *int32 var ri *int32
var comm *string var comm *string
if err := rows.Scan(&s.ID, &s.SourceKind, &s.URL, &s.PrefixPath, &s.Etag, &ri, &comm); err != nil { var last *time.Time
if err := rows.Scan(&s.ID, &s.SourceKind, &s.URL, &s.PrefixPath, &s.Etag, &ri, &comm, &last); err != nil {
continue continue
} }
if ri != nil { if ri != nil {
@@ -43,6 +44,10 @@ func (p *Postgres) ListCDNSources(tenantID, moduleID string) ([]*store.CDNSource
s.RefreshIntervalSec = &v s.RefreshIntervalSec = &v
} }
s.CommunityID = strOrNil(comm) s.CommunityID = strOrNil(comm)
if last != nil {
t := last.UTC()
s.LastRefreshedAt = &t
}
out = append(out, &s) out = append(out, &s)
} }
return out, nil return out, nil
@@ -62,9 +67,9 @@ func (p *Postgres) CreateCDNSource(tenantID, moduleID string, in *store.CDNSourc
ctx := context.Background() ctx := context.Background()
id := uuid.NewString() id := uuid.NewString()
_, err = p.pool.Exec(ctx, ` _, err = p.pool.Exec(ctx, `
INSERT INTO module_cdn_source (id, module_id, source_kind, url, prefix_path, etag, refresh_interval_sec, community_id) INSERT INTO module_cdn_source (id, module_id, source_kind, url, prefix_path, etag, refresh_interval_sec, community_id, last_refreshed_at)
VALUES ($1,$2,$3,$4,$5,$6,$7, NULLIF($8::uuid, '00000000-0000-0000-0000-000000000000'::uuid))`, VALUES ($1,$2,$3,$4,$5,$6,$7, NULLIF($8::uuid, '00000000-0000-0000-0000-000000000000'::uuid), $9)`,
id, moduleID, in.SourceKind, strings.TrimSpace(in.URL), strings.TrimSpace(in.PrefixPath), in.Etag, nullInt32Ptr(in.RefreshIntervalSec), uuidOrNilPtr(in.CommunityID)) id, moduleID, in.SourceKind, strings.TrimSpace(in.URL), strings.TrimSpace(in.PrefixPath), in.Etag, nullInt32Ptr(in.RefreshIntervalSec), uuidOrNilPtr(in.CommunityID), nullTimePtr(in.LastRefreshedAt))
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -76,9 +81,10 @@ func (p *Postgres) getCDNSource(ctx context.Context, moduleID, id string) (*stor
s.ModuleID = moduleID s.ModuleID = moduleID
var ri *int32 var ri *int32
var comm *string var comm *string
var last *time.Time
err := p.pool.QueryRow(ctx, ` err := p.pool.QueryRow(ctx, `
SELECT id::text, source_kind, url, COALESCE(prefix_path,''), COALESCE(etag,''), refresh_interval_sec, community_id::text SELECT id::text, source_kind, url, COALESCE(prefix_path,''), COALESCE(etag,''), refresh_interval_sec, community_id::text, last_refreshed_at
FROM module_cdn_source WHERE id=$1 AND module_id=$2`, id, moduleID).Scan(&s.ID, &s.SourceKind, &s.URL, &s.PrefixPath, &s.Etag, &ri, &comm) FROM module_cdn_source WHERE id=$1 AND module_id=$2`, id, moduleID).Scan(&s.ID, &s.SourceKind, &s.URL, &s.PrefixPath, &s.Etag, &ri, &comm, &last)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -87,6 +93,10 @@ func (p *Postgres) getCDNSource(ctx context.Context, moduleID, id string) (*stor
s.RefreshIntervalSec = &v s.RefreshIntervalSec = &v
} }
s.CommunityID = strOrNil(comm) s.CommunityID = strOrNil(comm)
if last != nil {
t := last.UTC()
s.LastRefreshedAt = &t
}
return &s, nil return &s, nil
} }
@@ -131,12 +141,16 @@ func (p *Postgres) UpdateCDNSource(tenantID, moduleID, sourceID string, patch *s
cur.CommunityID = &v cur.CommunityID = &v
} }
} }
if patch.LastRefreshedAt != nil {
t := patch.LastRefreshedAt.UTC()
cur.LastRefreshedAt = &t
}
ctx := context.Background() ctx := context.Background()
_, err = p.pool.Exec(ctx, ` _, err = p.pool.Exec(ctx, `
UPDATE module_cdn_source SET source_kind=$3, url=$4, prefix_path=$5, etag=$6, refresh_interval_sec=$7, UPDATE module_cdn_source SET source_kind=$3, url=$4, prefix_path=$5, etag=$6, refresh_interval_sec=$7,
community_id=NULLIF($8::uuid, '00000000-0000-0000-0000-000000000000'::uuid), updated_at=now() community_id=NULLIF($8::uuid, '00000000-0000-0000-0000-000000000000'::uuid), last_refreshed_at=$9, updated_at=now()
WHERE id=$1 AND module_id=$2`, WHERE id=$1 AND module_id=$2`,
sourceID, moduleID, cur.SourceKind, cur.URL, cur.PrefixPath, cur.Etag, nullInt32Ptr(cur.RefreshIntervalSec), uuidOrNilPtr(cur.CommunityID)) sourceID, moduleID, cur.SourceKind, cur.URL, cur.PrefixPath, cur.Etag, nullInt32Ptr(cur.RefreshIntervalSec), uuidOrNilPtr(cur.CommunityID), nullTimePtr(cur.LastRefreshedAt))
if err != nil { if err != nil {
return nil, err return nil, err
} }
+24 -21
View File
@@ -83,34 +83,37 @@ type Backend interface {
// ModulePatch is a partial update for module. // ModulePatch is a partial update for module.
type ModulePatch struct { type ModulePatch struct {
Name *string `json:"name,omitempty"` Name *string `json:"name,omitempty"`
Enabled *bool `json:"enabled,omitempty"` Enabled *bool `json:"enabled,omitempty"`
Priority *int `json:"priority,omitempty"` Priority *int `json:"priority,omitempty"`
RefreshIntervalSec *int `json:"refresh_interval_sec,omitempty"` RefreshIntervalSec *int `json:"refresh_interval_sec,omitempty"`
CronExpr *string `json:"cron_expr,omitempty"` CronExpr *string `json:"cron_expr,omitempty"`
DefaultCommunityID *string `json:"default_community_id,omitempty"` DefaultCommunityID *string `json:"default_community_id,omitempty"`
DohProfileID *string `json:"doh_profile_id,omitempty"` DohProfileID *string `json:"doh_profile_id,omitempty"`
LastRefreshedAt *time.Time `json:"-"`
} }
// CDNSource is a row under a CDN module. // CDNSource is a row under a CDN module.
type CDNSource struct { type CDNSource struct {
ID string `json:"id,omitempty"` ID string `json:"id,omitempty"`
ModuleID string `json:"module_id,omitempty"` ModuleID string `json:"module_id,omitempty"`
SourceKind string `json:"source_kind"` SourceKind string `json:"source_kind"`
URL string `json:"url"` URL string `json:"url"`
PrefixPath string `json:"prefix_path,omitempty"` PrefixPath string `json:"prefix_path,omitempty"`
Etag string `json:"etag"` Etag string `json:"etag"`
RefreshIntervalSec *int `json:"refresh_interval_sec"` RefreshIntervalSec *int `json:"refresh_interval_sec"`
CommunityID *string `json:"community_id"` CommunityID *string `json:"community_id"`
LastRefreshedAt *time.Time `json:"-"`
} }
type CDNSourcePatch struct { type CDNSourcePatch struct {
SourceKind *string `json:"source_kind,omitempty"` SourceKind *string `json:"source_kind,omitempty"`
URL *string `json:"url,omitempty"` URL *string `json:"url,omitempty"`
PrefixPath *string `json:"prefix_path,omitempty"` PrefixPath *string `json:"prefix_path,omitempty"`
Etag *string `json:"etag,omitempty"` Etag *string `json:"etag,omitempty"`
RefreshIntervalSec *int `json:"refresh_interval_sec,omitempty"` RefreshIntervalSec *int `json:"refresh_interval_sec,omitempty"`
CommunityID *string `json:"community_id,omitempty"` CommunityID *string `json:"community_id,omitempty"`
LastRefreshedAt *time.Time `json:"-"`
} }
type ASEntry struct { type ASEntry struct {
+13 -12
View File
@@ -38,7 +38,7 @@ type Memory struct {
asEntries map[string]*ASEntry asEntries map[string]*ASEntry
domainEnt map[string]*DomainEntry domainEnt map[string]*DomainEntry
ipRanges map[string]*IPRangeEntry ipRanges map[string]*IPRangeEntry
settings map[string]map[string]any // tenantID -> key -> JSON-compatible value settings map[string]map[string]any // tenantID -> key -> JSON-compatible value
revPrefixes map[string][]PrefixRow revPrefixes map[string][]PrefixRow
// DemoIDs valid after SeedDemo() // DemoIDs valid after SeedDemo()
@@ -61,17 +61,18 @@ type Tenant struct {
} }
type Module struct { type Module struct {
ID string ID string
TenantID string TenantID string
Type string // AS_PREFIXES, CDN_CIDRS, DOMAINS, IP_RANGES Type string // AS_PREFIXES, CDN_CIDRS, DOMAINS, IP_RANGES
Name string Name string
Enabled bool Enabled bool
RefreshIntervalSec int // 0 = unset RefreshIntervalSec int // 0 = unset
CronExpr string // optional cron for scheduler (display / future use) CronExpr string // optional cron for scheduler (display / future use)
Priority int Priority int
DefaultCommunityID *string DefaultCommunityID *string
DohProfileID *string DohProfileID *string
DeletedAt *time.Time LastRefreshedAt *time.Time
DeletedAt *time.Time
} }
type Revision struct { type Revision struct {
+10
View File
@@ -32,6 +32,7 @@ func (m *Memory) CreateModule(tenantID string, in *Module) (*Module, error) {
CronExpr: in.CronExpr, CronExpr: in.CronExpr,
DefaultCommunityID: in.DefaultCommunityID, DefaultCommunityID: in.DefaultCommunityID,
DohProfileID: in.DohProfileID, DohProfileID: in.DohProfileID,
LastRefreshedAt: in.LastRefreshedAt,
} }
m.modules[id] = mod m.modules[id] = mod
return mod, nil return mod, nil
@@ -78,6 +79,10 @@ func (m *Memory) UpdateModule(tenantID, moduleID string, patch *ModulePatch) (*M
mod.DohProfileID = &v mod.DohProfileID = &v
} }
} }
if patch.LastRefreshedAt != nil {
t := patch.LastRefreshedAt.UTC()
mod.LastRefreshedAt = &t
}
return mod, nil return mod, nil
} }
@@ -143,6 +148,7 @@ func (m *Memory) CreateCDNSource(tenantID, moduleID string, in *CDNSource) (*CDN
Etag: in.Etag, Etag: in.Etag,
RefreshIntervalSec: in.RefreshIntervalSec, RefreshIntervalSec: in.RefreshIntervalSec,
CommunityID: in.CommunityID, CommunityID: in.CommunityID,
LastRefreshedAt: in.LastRefreshedAt,
} }
m.cdnSources[id] = s m.cdnSources[id] = s
return s, nil return s, nil
@@ -184,6 +190,10 @@ func (m *Memory) UpdateCDNSource(tenantID, moduleID, sourceID string, patch *CDN
s.CommunityID = &v s.CommunityID = &v
} }
} }
if patch.LastRefreshedAt != nil {
t := patch.LastRefreshedAt.UTC()
s.LastRefreshedAt = &t
}
return s, nil return s, nil
} }
@@ -0,0 +1,2 @@
ALTER TABLE module_cdn_source
DROP COLUMN last_refreshed_at;
@@ -0,0 +1,2 @@
ALTER TABLE module_cdn_source
ADD COLUMN last_refreshed_at TIMESTAMPTZ NULL;
@@ -0,0 +1,2 @@
ALTER TABLE module
DROP COLUMN last_refreshed_at;
@@ -0,0 +1,2 @@
ALTER TABLE module
ADD COLUMN last_refreshed_at TIMESTAMPTZ NULL;
@@ -0,0 +1,2 @@
ALTER TABLE module_cdn_source
DROP COLUMN last_refreshed_at;
@@ -0,0 +1,2 @@
ALTER TABLE module_cdn_source
ADD COLUMN last_refreshed_at TEXT;
@@ -0,0 +1,2 @@
ALTER TABLE module
DROP COLUMN last_refreshed_at;
@@ -0,0 +1,2 @@
ALTER TABLE module
ADD COLUMN last_refreshed_at TEXT;
+2
View File
@@ -18,6 +18,7 @@ export type ModuleRow = {
cron_expr: string | null; cron_expr: string | null;
default_community_id: string | null; default_community_id: string | null;
doh_profile_id: string | null; doh_profile_id: string | null;
last_refreshed_at: string | null;
}; };
export type ModulesResponse = Page<ModuleRow>; export type ModulesResponse = Page<ModuleRow>;
@@ -71,6 +72,7 @@ export type CdnSource = {
prefix_path: string; prefix_path: string;
community_id: string | null; community_id: string | null;
refresh_interval_sec: number | null; refresh_interval_sec: number | null;
last_refreshed_at: string | null;
}; };
export type CdnSourceCreate = { export type CdnSourceCreate = {
url: string; url: string;
+12 -1
View File
@@ -136,6 +136,13 @@
return '—'; return '—';
} }
function formatDateTime(value: string | null | undefined): string {
if (typeof value !== 'string' || value.trim().length === 0) return '—';
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) return '—';
return parsed.toLocaleString('ru-RU');
}
function toggleModuleSelection(id: string) { function toggleModuleSelection(id: string) {
const next = new Set(selectedModuleIds); const next = new Set(selectedModuleIds);
if (next.has(id)) next.delete(id); if (next.has(id)) next.delete(id);
@@ -232,6 +239,7 @@
<TableHead>Тип</TableHead> <TableHead>Тип</TableHead>
<TableHead>Приоритет</TableHead> <TableHead>Приоритет</TableHead>
<TableHead>Интервал</TableHead> <TableHead>Интервал</TableHead>
<TableHead>Последнее обновление</TableHead>
<TableHead>Статус</TableHead> <TableHead>Статус</TableHead>
<TableHead class="w-16"></TableHead> <TableHead class="w-16"></TableHead>
</TableRow> </TableRow>
@@ -255,6 +263,9 @@
<TableCell class="text-muted-foreground font-mono text-xs"> <TableCell class="text-muted-foreground font-mono text-xs">
{moduleIntervalLabel(m)} {moduleIntervalLabel(m)}
</TableCell> </TableCell>
<TableCell class="text-muted-foreground text-sm whitespace-nowrap">
{formatDateTime(m.last_refreshed_at)}
</TableCell>
<TableCell> <TableCell>
{#if m.enabled} {#if m.enabled}
<Badge variant="default" class="text-xs">вкл</Badge> <Badge variant="default" class="text-xs">вкл</Badge>
@@ -270,7 +281,7 @@
</TableRow> </TableRow>
{:else} {:else}
<TableRow> <TableRow>
<TableCell colspan={7} class="text-muted-foreground text-center py-8"> <TableCell colspan={8} class="text-muted-foreground text-center py-8">
{loading ? 'Загрузка…' : 'Нет модулей. Создайте первый.'} {loading ? 'Загрузка…' : 'Нет модулей. Создайте первый.'}
</TableCell> </TableCell>
</TableRow> </TableRow>
+17 -2
View File
@@ -762,6 +762,13 @@
return '—'; return '—';
} }
function formatDateTime(value: string | null | undefined): string {
if (typeof value !== 'string' || value.trim().length === 0) return '—';
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) return '—';
return parsed.toLocaleString('ru-RU');
}
const activeTab = $derived.by(() => { const activeTab = $derived.by(() => {
if (!mod) return 'entries'; if (!mod) return 'entries';
switch (mod.type) { switch (mod.type) {
@@ -831,7 +838,7 @@
</div> </div>
<!-- Module metadata --> <!-- Module metadata -->
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-4"> <div class="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-5">
<Card class="bg-chart-3/10 p-3"> <Card class="bg-chart-3/10 p-3">
<p class="text-muted-foreground flex items-center gap-1.5 text-xs"><ArrowDownUp class="size-3.5" />Приоритет</p> <p class="text-muted-foreground flex items-center gap-1.5 text-xs"><ArrowDownUp class="size-3.5" />Приоритет</p>
<p class="font-semibold">{mod.priority}</p> <p class="font-semibold">{mod.priority}</p>
@@ -850,6 +857,10 @@
<p class="text-muted-foreground flex items-center gap-1.5 text-xs"><ShieldCheck class="size-3.5" />Community по умолч.</p> <p class="text-muted-foreground flex items-center gap-1.5 text-xs"><ShieldCheck class="size-3.5" />Community по умолч.</p>
<p class="font-semibold text-sm break-all">{communityLabel(mod.default_community_id)}</p> <p class="font-semibold text-sm break-all">{communityLabel(mod.default_community_id)}</p>
</Card> </Card>
<Card class="bg-muted/60 p-3">
<p class="text-muted-foreground flex items-center gap-1.5 text-xs"><RefreshCw class="size-3.5" />Последнее обновление</p>
<p class="font-semibold text-sm whitespace-nowrap">{formatDateTime(mod.last_refreshed_at)}</p>
</Card>
</div> </div>
<Card> <Card>
@@ -1058,6 +1069,7 @@
<TableHead>Тип</TableHead> <TableHead>Тип</TableHead>
<TableHead>Community</TableHead> <TableHead>Community</TableHead>
<TableHead>Интервал</TableHead> <TableHead>Интервал</TableHead>
<TableHead>Последнее обновление</TableHead>
<TableHead class="w-20"></TableHead> <TableHead class="w-20"></TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
@@ -1089,6 +1101,9 @@
? `${src.refresh_interval_sec}с` ? `${src.refresh_interval_sec}с`
: '—'} : '—'}
</TableCell> </TableCell>
<TableCell class="text-muted-foreground text-sm whitespace-nowrap">
{formatDateTime(src.last_refreshed_at)}
</TableCell>
<TableCell> <TableCell>
<div class="flex gap-1"> <div class="flex gap-1">
<Button variant="ghost" size="icon-sm" onclick={() => openCdnEdit(src)}><Pencil class="size-3.5" /></Button> <Button variant="ghost" size="icon-sm" onclick={() => openCdnEdit(src)}><Pencil class="size-3.5" /></Button>
@@ -1098,7 +1113,7 @@
</TableRow> </TableRow>
{:else} {:else}
<TableRow> <TableRow>
<TableCell colspan={6} class="text-muted-foreground text-center py-6">Нет источников</TableCell> <TableCell colspan={7} class="text-muted-foreground text-center py-6">Нет источников</TableCell>
</TableRow> </TableRow>
{/each} {/each}
</TableBody> </TableBody>