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}