diff --git a/apps/cms/src/admin/pages/SystemStatus.tsx b/apps/cms/src/admin/pages/SystemStatus.tsx index ef78cb03..3c53f444 100644 --- a/apps/cms/src/admin/pages/SystemStatus.tsx +++ b/apps/cms/src/admin/pages/SystemStatus.tsx @@ -29,6 +29,10 @@ type PhaseResult = { type PhaseWatermark = { phase: string lastSyncedAt: string + created: number + updated: number + softDeleted: number + errors: number } type SyncStatus = { @@ -175,15 +179,23 @@ function PhaseWatermarksTable({ }) { if (watermarks.length === 0) return null + const hasStats = watermarks.some( + (w) => w.created > 0 || w.updated > 0 || w.softDeleted > 0 || w.errors > 0, + ) + + const headers = hasStats + ? ["Phase", "Last Synced", "Created", "Updated", "Deleted", "Errors"] + : ["Phase", "Last Synced"] + return ( - Phase watermarks (from database) + Last sync results (from database) - {["Phase", "Last Synced"].map((h) => ( + {headers.map((h) => ( + + + + + )} ))} @@ -244,8 +277,8 @@ function SyncCard({ {!isProduction && ( - - + + Core sync is disabled outside production. Use{" "} pnpm data-import to restore a snapshot locally. @@ -354,8 +387,8 @@ function SnapshotCard({ {!isProduction && ( - - + + Snapshot creation is disabled outside production. Use{" "} pnpm data-import to download a snapshot locally. diff --git a/apps/cms/src/api/core-sync/controllers/core-sync.ts b/apps/cms/src/api/core-sync/controllers/core-sync.ts index d19b4aca..fa7f1f80 100644 --- a/apps/cms/src/api/core-sync/controllers/core-sync.ts +++ b/apps/cms/src/api/core-sync/controllers/core-sync.ts @@ -15,7 +15,7 @@ type StrapiContext = { export default ({ strapi }: { strapi: Core.Strapi }) => ({ async trigger(ctx: StrapiContext) { - if (process.env.NODE_ENV !== "production") { + if (process.env.CORE_SYNC_ENABLED !== "true") { ctx.status = 403 ctx.body = { error: @@ -49,7 +49,7 @@ export default ({ strapi }: { strapi: Core.Strapi }) => ({ async status(ctx: StrapiContext) { const status = getSyncStatus() - const isProduction = process.env.NODE_ENV === "production" + const isProduction = process.env.CORE_SYNC_ENABLED === "true" // When idle with no in-memory lastRun (e.g. after restart), enrich with // persistent watermarks from the core_sync_states table so the UI always diff --git a/apps/cms/src/api/core-sync/services/core-sync.ts b/apps/cms/src/api/core-sync/services/core-sync.ts index 328b454f..5b678796 100644 --- a/apps/cms/src/api/core-sync/services/core-sync.ts +++ b/apps/cms/src/api/core-sync/services/core-sync.ts @@ -8,6 +8,7 @@ import { getLastSyncTime, setLastSyncTime, getAllSyncTimes, + updateSyncStats, } from "./strapi-helpers" import { syncLanguages } from "./sync-languages" import { syncCountries } from "./sync-countries" @@ -192,13 +193,16 @@ export async function runSync( phases.push({ phase, ...stats }) // Only advance the watermark if the phase had no errors — failed records - // need to be retried on the next incremental sync + // need to be retried on the next incremental sync. + // Always persist stats so the admin UI can show row counts after restart. if (stats.errors === 0) { - await setLastSyncTime(strapi, phase, syncStartTime) + await setLastSyncTime(strapi, phase, syncStartTime, stats) } else { strapi.log.warn( `[core-sync] ${phase}: ${stats.errors} errors — watermark NOT advanced`, ) + // Persist stats without advancing the watermark + await updateSyncStats(strapi, phase, stats).catch(() => {}) } } diff --git a/apps/cms/src/api/core-sync/services/strapi-helpers.ts b/apps/cms/src/api/core-sync/services/strapi-helpers.ts index 65e2d898..5ffe6972 100644 --- a/apps/cms/src/api/core-sync/services/strapi-helpers.ts +++ b/apps/cms/src/api/core-sync/services/strapi-helpers.ts @@ -158,7 +158,7 @@ export async function buildCoreIdMap( const SYNC_STATE_TABLE = "core_sync_states" let tableEnsured = false -/** Ensure the sync-state table exists (idempotent, cached after first check). */ +/** Ensure the sync-state table exists with stats columns (idempotent, cached). */ export async function ensureSyncStateTable(strapi: Core.Strapi): Promise { if (tableEnsured) return const knex = strapi.db.connection @@ -167,8 +167,24 @@ export async function ensureSyncStateTable(strapi: Core.Strapi): Promise { await knex.schema.createTable(SYNC_STATE_TABLE, (t) => { t.string("phase").primary() t.timestamp("last_synced_at").notNullable() + t.integer("created").defaultTo(0) + t.integer("updated").defaultTo(0) + t.integer("soft_deleted").defaultTo(0) + t.integer("errors").defaultTo(0) }) strapi.log.info(`[core-sync] Created ${SYNC_STATE_TABLE} table`) + } else { + // Migrate existing tables: add stats columns if missing + const hasCreated = await knex.schema.hasColumn(SYNC_STATE_TABLE, "created") + if (!hasCreated) { + await knex.schema.alterTable(SYNC_STATE_TABLE, (t) => { + t.integer("created").defaultTo(0) + t.integer("updated").defaultTo(0) + t.integer("soft_deleted").defaultTo(0) + t.integer("errors").defaultTo(0) + }) + strapi.log.info(`[core-sync] Added stats columns to ${SYNC_STATE_TABLE}`) + } } tableEnsured = true } @@ -186,36 +202,85 @@ export async function getLastSyncTime( return val instanceof Date ? val.toISOString() : String(val) } -/** Read the most recent sync timestamp across all phases (null = never synced). */ +type PersistedPhaseState = { + phase: string + lastSyncedAt: string + created: number + updated: number + softDeleted: number + errors: number +} + +/** Read all phase sync states from the database, ordered by most recent first. */ export async function getAllSyncTimes( strapi: Core.Strapi, -): Promise<{ phase: string; lastSyncedAt: string }[]> { +): Promise { const knex = strapi.db.connection const exists = await knex.schema.hasTable(SYNC_STATE_TABLE) if (!exists) return [] const rows = await knex(SYNC_STATE_TABLE) - .select("phase", "last_synced_at") + .select( + "phase", + "last_synced_at", + "created", + "updated", + "soft_deleted", + "errors", + ) .orderBy("last_synced_at", "desc") - return rows.map((row: { phase: string; last_synced_at: Date | string }) => ({ - phase: row.phase, - lastSyncedAt: - row.last_synced_at instanceof Date - ? row.last_synced_at.toISOString() - : String(row.last_synced_at), - })) + return rows.map( + (row: { + phase: string + last_synced_at: Date | string + created?: number + updated?: number + soft_deleted?: number + errors?: number + }) => ({ + phase: row.phase, + lastSyncedAt: + row.last_synced_at instanceof Date + ? row.last_synced_at.toISOString() + : String(row.last_synced_at), + created: row.created ?? 0, + updated: row.updated ?? 0, + softDeleted: row.soft_deleted ?? 0, + errors: row.errors ?? 0, + }), + ) } -/** Persist the sync timestamp for a phase after a successful run. */ +/** Persist the sync timestamp and stats for a phase after a successful run. */ export async function setLastSyncTime( strapi: Core.Strapi, phase: string, timestamp: string, + stats?: SyncStats, ): Promise { const knex = strapi.db.connection - await knex(SYNC_STATE_TABLE) - .insert({ phase, last_synced_at: timestamp }) - .onConflict("phase") - .merge() + const row: Record = { phase, last_synced_at: timestamp } + if (stats) { + row.created = stats.created + row.updated = stats.updated + row.soft_deleted = stats.softDeleted + row.errors = stats.errors + } + await knex(SYNC_STATE_TABLE).insert(row).onConflict("phase").merge() +} + +/** Update only the stats columns for a phase (without advancing the watermark). */ +export async function updateSyncStats( + strapi: Core.Strapi, + phase: string, + stats: SyncStats, +): Promise { + const knex = strapi.db.connection + await knex(SYNC_STATE_TABLE).where({ phase }).update({ + created: stats.created, + updated: stats.updated, + soft_deleted: stats.softDeleted, + errors: stats.errors, + }) } export async function softDeleteUnseen( diff --git a/apps/cms/src/api/data-snapshot/controllers/data-snapshot.ts b/apps/cms/src/api/data-snapshot/controllers/data-snapshot.ts index fe2ab266..e2c5a074 100644 --- a/apps/cms/src/api/data-snapshot/controllers/data-snapshot.ts +++ b/apps/cms/src/api/data-snapshot/controllers/data-snapshot.ts @@ -14,7 +14,7 @@ type StrapiContext = { export default ({ strapi }: { strapi: Core.Strapi }) => ({ async trigger(ctx: StrapiContext) { - if (process.env.NODE_ENV !== "production") { + if (process.env.CORE_SYNC_ENABLED !== "true") { ctx.status = 403 ctx.body = { error: @@ -54,7 +54,7 @@ export default ({ strapi }: { strapi: Core.Strapi }) => ({ async status(ctx: StrapiContext) { const status = getSnapshotStatus() - const isProduction = process.env.NODE_ENV === "production" + const isProduction = process.env.CORE_SYNC_ENABLED === "true" // When idle with no in-memory lastRun (e.g. after restart), enrich with // persistent metadata from S3 so the UI always shows the latest snapshot info.
+ {hasStats && ( + <> + + {w.created} + + {w.updated} + + {w.softDeleted} + + 0 ? "danger600" : undefined} + > + {w.errors} + +