From 1e13ef9e703958950206a33c89a3e737fd5239f9 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Thu, 9 Apr 2026 16:10:00 +0700 Subject: [PATCH] feat: add last refreshed timestamp to modules and CDN sources 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. --- docs/openapi.yaml | 8 ++++ internal/httpapi/routes.go | 5 +++ internal/httpapi/routes_crud.go | 5 +++ internal/pipeline/refresh.go | 2 + internal/repository/postgres.go | 41 +++++++++++++++---- internal/store/backend.go | 15 +++---- internal/store/memory.go | 25 +++++------ internal/store/memory_crud.go | 5 +++ .../000008_module_last_refreshed_at.down.sql | 2 + .../000008_module_last_refreshed_at.up.sql | 2 + .../000008_module_last_refreshed_at.down.sql | 2 + .../000008_module_last_refreshed_at.up.sql | 2 + web/src/lib/api/types.ts | 2 + web/src/routes/modules/+page.svelte | 13 +++++- .../routes/modules/[moduleId]/+page.svelte | 19 ++++++++- 15 files changed, 117 insertions(+), 31 deletions(-) create mode 100644 migrations/postgres/000008_module_last_refreshed_at.down.sql create mode 100644 migrations/postgres/000008_module_last_refreshed_at.up.sql create mode 100644 migrations/sqlite/000008_module_last_refreshed_at.down.sql create mode 100644 migrations/sqlite/000008_module_last_refreshed_at.up.sql diff --git a/docs/openapi.yaml b/docs/openapi.yaml index ae08791..47b1e72 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -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: diff --git a/internal/httpapi/routes.go b/internal/httpapi/routes.go index c39562f..f092a03 100644 --- a/internal/httpapi/routes.go +++ b/internal/httpapi/routes.go @@ -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 { diff --git a/internal/httpapi/routes_crud.go b/internal/httpapi/routes_crud.go index 2f1121e..af0c093 100644 --- a/internal/httpapi/routes_crud.go +++ b/internal/httpapi/routes_crud.go @@ -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 } diff --git a/internal/pipeline/refresh.go b/internal/pipeline/refresh.go index d8eda6c..bc4b14a 100644 --- a/internal/pipeline/refresh.go +++ b/internal/pipeline/refresh.go @@ -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 } diff --git a/internal/repository/postgres.go b/internal/repository/postgres.go index c7c5459..0a13594 100644 --- a/internal/repository/postgres.go +++ b/internal/repository/postgres.go @@ -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 } diff --git a/internal/store/backend.go b/internal/store/backend.go index fcd02fe..6a56b7e 100644 --- a/internal/store/backend.go +++ b/internal/store/backend.go @@ -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. diff --git a/internal/store/memory.go b/internal/store/memory.go index d70964d..c30b162 100644 --- a/internal/store/memory.go +++ b/internal/store/memory.go @@ -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 { diff --git a/internal/store/memory_crud.go b/internal/store/memory_crud.go index 56d6ca8..f12f6c1 100644 --- a/internal/store/memory_crud.go +++ b/internal/store/memory_crud.go @@ -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 } diff --git a/migrations/postgres/000008_module_last_refreshed_at.down.sql b/migrations/postgres/000008_module_last_refreshed_at.down.sql new file mode 100644 index 0000000..ec5f3ce --- /dev/null +++ b/migrations/postgres/000008_module_last_refreshed_at.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE module +DROP COLUMN last_refreshed_at; diff --git a/migrations/postgres/000008_module_last_refreshed_at.up.sql b/migrations/postgres/000008_module_last_refreshed_at.up.sql new file mode 100644 index 0000000..1387dc9 --- /dev/null +++ b/migrations/postgres/000008_module_last_refreshed_at.up.sql @@ -0,0 +1,2 @@ +ALTER TABLE module +ADD COLUMN last_refreshed_at TIMESTAMPTZ NULL; diff --git a/migrations/sqlite/000008_module_last_refreshed_at.down.sql b/migrations/sqlite/000008_module_last_refreshed_at.down.sql new file mode 100644 index 0000000..ec5f3ce --- /dev/null +++ b/migrations/sqlite/000008_module_last_refreshed_at.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE module +DROP COLUMN last_refreshed_at; diff --git a/migrations/sqlite/000008_module_last_refreshed_at.up.sql b/migrations/sqlite/000008_module_last_refreshed_at.up.sql new file mode 100644 index 0000000..d56a843 --- /dev/null +++ b/migrations/sqlite/000008_module_last_refreshed_at.up.sql @@ -0,0 +1,2 @@ +ALTER TABLE module +ADD COLUMN last_refreshed_at TEXT; diff --git a/web/src/lib/api/types.ts b/web/src/lib/api/types.ts index 18c2f5f..b9d0f4a 100644 --- a/web/src/lib/api/types.ts +++ b/web/src/lib/api/types.ts @@ -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; @@ -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; diff --git a/web/src/routes/modules/+page.svelte b/web/src/routes/modules/+page.svelte index d42ee2f..313a8a8 100644 --- a/web/src/routes/modules/+page.svelte +++ b/web/src/routes/modules/+page.svelte @@ -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 @@ Тип Приоритет Интервал + Последнее обновление Статус @@ -255,6 +263,9 @@ {moduleIntervalLabel(m)} + + {formatDateTime(m.last_refreshed_at)} + {#if m.enabled} вкл @@ -270,7 +281,7 @@ {:else} - + {loading ? 'Загрузка…' : 'Нет модулей. Создайте первый.'} diff --git a/web/src/routes/modules/[moduleId]/+page.svelte b/web/src/routes/modules/[moduleId]/+page.svelte index e71dc6d..88fffbf 100644 --- a/web/src/routes/modules/[moduleId]/+page.svelte +++ b/web/src/routes/modules/[moduleId]/+page.svelte @@ -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 @@ -
+

Приоритет

{mod.priority}

@@ -850,6 +857,10 @@

Community по умолч.

{communityLabel(mod.default_community_id)}

+ +

Последнее обновление

+

{formatDateTime(mod.last_refreshed_at)}

+
@@ -1058,6 +1069,7 @@ Тип Community Интервал + Последнее обновление @@ -1089,6 +1101,9 @@ ? `${src.refresh_interval_sec}с` : '—'} + + {formatDateTime(src.last_refreshed_at)} +
@@ -1098,7 +1113,7 @@ {:else} - Нет источников + Нет источников {/each}