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.
This commit is contained in:
Denozordec
2026-04-09 16:10:00 +07:00
parent 47764345f6
commit 1e13ef9e70
15 changed files with 117 additions and 31 deletions
+8
View File
@@ -339,6 +339,10 @@ components:
type: ["string", "null"]
default_community_id:
type: ["string", "null"]
last_refreshed_at:
type: ["string", "null"]
format: date-time
description: Время последнего успешного обновления данных модуля.
additionalProperties: true
ModuleCreate:
@@ -361,6 +365,10 @@ components:
type: ["string", "null"]
refresh_interval_sec:
type: ["integer", "null"]
last_refreshed_at:
type: ["string", "null"]
format: date-time
description: Время последнего успешного обновления этого CDN-источника.
cron_expr:
type: ["string", "null"]
default_community_id:
+5
View File
@@ -110,6 +110,11 @@ func moduleJSON(mod *store.Module) map[string]any {
"refresh_interval_sec": mod.RefreshIntervalSec,
"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 {
m["default_community_id"] = *mod.DefaultCommunityID
} else {
+5
View File
@@ -199,6 +199,11 @@ func cdnSourceJSON(x *store.CDNSource) map[string]any {
} else {
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
}
+2
View File
@@ -59,6 +59,8 @@ func RefreshModuleIngest(ctx context.Context, st store.Backend, hc *http.Client,
if err != nil {
return err
}
refreshedAt := time.Now().UTC()
_, _ = st.UpdateModule(tenantID, moduleID, &store.ModulePatch{LastRefreshedAt: &refreshedAt})
return nil
}
+32 -9
View File
@@ -113,7 +113,7 @@ func (p *Postgres) PeerSessionCountsByState() map[string]int {
func (p *Postgres) ListModules(tenantID string) []*store.Module {
ctx := context.Background()
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)
if err != nil {
return nil
@@ -125,7 +125,8 @@ func (p *Postgres) ListModules(tenantID string) []*store.Module {
m.TenantID = tenantID
var doh, dc, cron *string
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
}
if refresh != nil {
@@ -140,6 +141,10 @@ func (p *Postgres) ListModules(tenantID string) []*store.Module {
if dc != nil && *dc != "" {
m.DefaultCommunityID = dc
}
if last != nil {
t := last.UTC()
m.LastRefreshedAt = &t
}
out = append(out, &m)
}
return out
@@ -151,10 +156,11 @@ func (p *Postgres) GetModule(tenantID, moduleID string) (*store.Module, error) {
m.TenantID = tenantID
var doh, dc, cron *string
var refresh *int32
var last *time.Time
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(
&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 errors.Is(err, pgx.ErrNoRows) {
return nil, store.ErrNotFound
@@ -173,6 +179,10 @@ func (p *Postgres) GetModule(tenantID, moduleID string) (*store.Module, error) {
if dc != nil && *dc != "" {
m.DefaultCommunityID = dc
}
if last != nil {
t := last.UTC()
m.LastRefreshedAt = &t
}
return &m, nil
}
@@ -197,10 +207,14 @@ func (p *Postgres) CreateModule(tenantID string, in *store.Module) (*store.Modul
if 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, `
INSERT INTO module (id, tenant_id, type, name, enabled, priority, doh_profile_id, refresh_interval_sec, cron_expr, default_community_id)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)`,
id, tenantID, in.Type, in.Name, in.Enabled, in.Priority, doh, ri, cronArg, dc)
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,$11)`,
id, tenantID, in.Type, in.Name, in.Enabled, in.Priority, doh, ri, cronArg, dc, lastArg)
if err != nil {
return nil, err
}
@@ -224,6 +238,7 @@ func (p *Postgres) UpdateModule(tenantID, moduleID string, patch *store.ModulePa
var dc, doh *string
dc = base.DefaultCommunityID
doh = base.DohProfileID
last := base.LastRefreshedAt
if patch.Name != nil {
name = strings.TrimSpace(*patch.Name)
}
@@ -255,6 +270,10 @@ func (p *Postgres) UpdateModule(tenantID, moduleID string, patch *store.ModulePa
doh = &v
}
}
if patch.LastRefreshedAt != nil {
t := patch.LastRefreshedAt.UTC()
last = &t
}
var dcArg, dohArg any
if dc != nil {
dcArg = *dc
@@ -270,11 +289,15 @@ func (p *Postgres) UpdateModule(tenantID, moduleID string, patch *store.ModulePa
if strings.TrimSpace(cron) != "" {
cronArg = strings.TrimSpace(cron)
}
var lastArg any
if last != nil {
lastArg = last.UTC()
}
_, err = p.pool.Exec(ctx, `
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`,
moduleID, tenantID, name, en, pr, riArg, cronArg, dcArg, dohArg)
moduleID, tenantID, name, en, pr, riArg, cronArg, dcArg, dohArg, lastArg)
if err != nil {
return nil, err
}
+8 -7
View File
@@ -83,13 +83,14 @@ type Backend interface {
// ModulePatch is a partial update for module.
type ModulePatch struct {
Name *string `json:"name,omitempty"`
Enabled *bool `json:"enabled,omitempty"`
Priority *int `json:"priority,omitempty"`
RefreshIntervalSec *int `json:"refresh_interval_sec,omitempty"`
CronExpr *string `json:"cron_expr,omitempty"`
DefaultCommunityID *string `json:"default_community_id,omitempty"`
DohProfileID *string `json:"doh_profile_id,omitempty"`
Name *string `json:"name,omitempty"`
Enabled *bool `json:"enabled,omitempty"`
Priority *int `json:"priority,omitempty"`
RefreshIntervalSec *int `json:"refresh_interval_sec,omitempty"`
CronExpr *string `json:"cron_expr,omitempty"`
DefaultCommunityID *string `json:"default_community_id,omitempty"`
DohProfileID *string `json:"doh_profile_id,omitempty"`
LastRefreshedAt *time.Time `json:"-"`
}
// CDNSource is a row under a CDN module.
+13 -12
View File
@@ -38,7 +38,7 @@ type Memory struct {
asEntries map[string]*ASEntry
domainEnt map[string]*DomainEntry
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
// DemoIDs valid after SeedDemo()
@@ -61,17 +61,18 @@ type Tenant struct {
}
type Module struct {
ID string
TenantID string
Type string // AS_PREFIXES, CDN_CIDRS, DOMAINS, IP_RANGES
Name string
Enabled bool
RefreshIntervalSec int // 0 = unset
CronExpr string // optional cron for scheduler (display / future use)
Priority int
DefaultCommunityID *string
DohProfileID *string
DeletedAt *time.Time
ID string
TenantID string
Type string // AS_PREFIXES, CDN_CIDRS, DOMAINS, IP_RANGES
Name string
Enabled bool
RefreshIntervalSec int // 0 = unset
CronExpr string // optional cron for scheduler (display / future use)
Priority int
DefaultCommunityID *string
DohProfileID *string
LastRefreshedAt *time.Time
DeletedAt *time.Time
}
type Revision struct {
+5
View File
@@ -32,6 +32,7 @@ func (m *Memory) CreateModule(tenantID string, in *Module) (*Module, error) {
CronExpr: in.CronExpr,
DefaultCommunityID: in.DefaultCommunityID,
DohProfileID: in.DohProfileID,
LastRefreshedAt: in.LastRefreshedAt,
}
m.modules[id] = mod
return mod, nil
@@ -78,6 +79,10 @@ func (m *Memory) UpdateModule(tenantID, moduleID string, patch *ModulePatch) (*M
mod.DohProfileID = &v
}
}
if patch.LastRefreshedAt != nil {
t := patch.LastRefreshedAt.UTC()
mod.LastRefreshedAt = &t
}
return mod, nil
}
@@ -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
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;
default_community_id: string | null;
doh_profile_id: string | null;
last_refreshed_at: string | null;
};
export type ModulesResponse = Page<ModuleRow>;
@@ -71,6 +72,7 @@ export type CdnSource = {
prefix_path: string;
community_id: string | null;
refresh_interval_sec: number | null;
last_refreshed_at: string | null;
};
export type CdnSourceCreate = {
url: string;
+12 -1
View File
@@ -136,6 +136,13 @@
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) {
const next = new Set(selectedModuleIds);
if (next.has(id)) next.delete(id);
@@ -232,6 +239,7 @@
<TableHead>Тип</TableHead>
<TableHead>Приоритет</TableHead>
<TableHead>Интервал</TableHead>
<TableHead>Последнее обновление</TableHead>
<TableHead>Статус</TableHead>
<TableHead class="w-16"></TableHead>
</TableRow>
@@ -255,6 +263,9 @@
<TableCell class="text-muted-foreground font-mono text-xs">
{moduleIntervalLabel(m)}
</TableCell>
<TableCell class="text-muted-foreground text-sm whitespace-nowrap">
{formatDateTime(m.last_refreshed_at)}
</TableCell>
<TableCell>
{#if m.enabled}
<Badge variant="default" class="text-xs">вкл</Badge>
@@ -270,7 +281,7 @@
</TableRow>
{:else}
<TableRow>
<TableCell colspan={7} class="text-muted-foreground text-center py-8">
<TableCell colspan={8} class="text-muted-foreground text-center py-8">
{loading ? 'Загрузка…' : 'Нет модулей. Создайте первый.'}
</TableCell>
</TableRow>
+17 -2
View File
@@ -762,6 +762,13 @@
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(() => {
if (!mod) return 'entries';
switch (mod.type) {
@@ -831,7 +838,7 @@
</div>
<!-- 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">
<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>
@@ -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="font-semibold text-sm break-all">{communityLabel(mod.default_community_id)}</p>
</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>
<Card>
@@ -1058,6 +1069,7 @@
<TableHead>Тип</TableHead>
<TableHead>Community</TableHead>
<TableHead>Интервал</TableHead>
<TableHead>Последнее обновление</TableHead>
<TableHead class="w-20"></TableHead>
</TableRow>
</TableHeader>
@@ -1089,6 +1101,9 @@
? `${src.refresh_interval_sec}с`
: '—'}
</TableCell>
<TableCell class="text-muted-foreground text-sm whitespace-nowrap">
{formatDateTime(src.last_refreshed_at)}
</TableCell>
<TableCell>
<div class="flex gap-1">
<Button variant="ghost" size="icon-sm" onclick={() => openCdnEdit(src)}><Pencil class="size-3.5" /></Button>
@@ -1098,7 +1113,7 @@
</TableRow>
{:else}
<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>
{/each}
</TableBody>