Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1e13ef9e70 | ||
|
|
47764345f6 |
No files matched your search
@@ -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:
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
|||||||
@@ -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,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
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in new issue
Block a user