Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,4 @@ mobile/ios/App/ForgeApp/Generated/
todos/*.md
!todos/.gitkeep
forge.code-workspace
.worktrees
213 changes: 185 additions & 28 deletions apps/cms/src/admin/pages/SystemStatus.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@ type PhaseResult = {
errors: number
}

type PhaseWatermark = {
phase: string
lastSyncedAt: string
}

type SyncStatus = {
inProgress: boolean
lastRun: string | null
Expand All @@ -39,6 +44,15 @@ type SyncStatus = {
duration?: number
error?: string
} | null
isProduction?: boolean
// Persisted data (available after restart)
persistedLastRun?: string | null
phaseWatermarks?: PhaseWatermark[]
}

type LocalImport = {
lastImportedAt: string | null
snapshotKey: string | null
}

type SnapshotStatus = {
Expand All @@ -50,6 +64,16 @@ type SnapshotStatus = {
sizeBytes?: number
error?: string
} | null
isProduction?: boolean
// Persisted data (available after restart)
persistedLastRun?: string | null
latestSnapshot?: {
key: string
lastModified: string
sizeBytes: number
} | null
// Local import data (non-production only)
localImport?: LocalImport | null
}

function formatBytes(bytes: number): string {
Expand Down Expand Up @@ -144,6 +168,56 @@ function PhaseResultsTable({ phases }: { phases: PhaseResult[] }) {
)
}

function PhaseWatermarksTable({
watermarks,
}: {
watermarks: PhaseWatermark[]
}) {
if (watermarks.length === 0) return null

return (
<Box paddingTop={2}>
<Typography variant="sigma" textColor="neutral600">
Phase watermarks (from database)
</Typography>
<table style={{ width: "100%", borderCollapse: "collapse" }}>
<thead>
<tr>
{["Phase", "Last Synced"].map((h) => (
<th
key={h}
style={{
textAlign: "left",
padding: "4px 8px",
borderBottom: "1px solid #ddd",
}}
>
<Typography variant="sigma" textColor="neutral600">
{h}
</Typography>
</th>
))}
</tr>
</thead>
<tbody>
{watermarks.map((w) => (
<tr key={w.phase}>
<td style={{ padding: "4px 8px" }}>
<Typography variant="omega">{w.phase}</Typography>
</td>
<td style={{ padding: "4px 8px" }}>
<Typography variant="omega">
{formatTime(w.lastSyncedAt)}
</Typography>
</td>
</tr>
))}
</tbody>
</table>
</Box>
)
}

function SyncCard({
status,
onTrigger,
Expand All @@ -155,6 +229,10 @@ function SyncCard({
}) {
const inProgress = status?.inProgress ?? false
const error = status?.lastResult?.error
const isProduction = status?.isProduction ?? false

// Use in-memory lastRun, or fall back to persisted watermark time
const effectiveLastRun = status?.lastRun ?? status?.persistedLastRun ?? null

return (
<Box background="neutral0" padding={6} shadow="tableShadow" hasRadius>
Expand All @@ -165,12 +243,22 @@ function SyncCard({
<StateBadge inProgress={inProgress} error={error} />
</Flex>

{status?.lastRun && (
{!isProduction && (
<Box paddingTop={2}>
<Alert variant="default" title="Development mode" closeLabel="Close">
Core sync is disabled outside production. Use{" "}
<code>pnpm data-import</code> to restore a snapshot locally.
</Alert>
</Box>
)}

{effectiveLastRun && (
<Box paddingTop={2}>
<Typography variant="omega" textColor="neutral600">
Last run: {formatTime(status.lastRun)}
{status.lastResult?.duration &&
Last run: {formatTime(effectiveLastRun)}
{status?.lastResult?.duration &&
` (${formatDuration(status.lastResult.duration)})`}
{!status?.lastRun && status?.persistedLastRun && " (from database)"}
</Typography>
</Box>
)}
Expand Down Expand Up @@ -201,6 +289,13 @@ function SyncCard({
<PhaseResultsTable phases={status.lastResult.phases} />
)}

{!inProgress &&
!status?.lastResult?.phases &&
status?.phaseWatermarks &&
status.phaseWatermarks.length > 0 && (
<PhaseWatermarksTable watermarks={status.phaseWatermarks} />
)}

{error && (
<Box paddingTop={3}>
<Alert variant="danger" title="Sync failed" closeLabel="Close">
Expand All @@ -209,16 +304,18 @@ function SyncCard({
</Box>
)}

<Box paddingTop={4}>
<Button
onClick={onTrigger}
disabled={inProgress || triggering}
loading={triggering}
variant="secondary"
>
Sync Now
</Button>
</Box>
{isProduction && (
<Box paddingTop={4}>
<Button
onClick={onTrigger}
disabled={inProgress || triggering}
loading={triggering}
variant="secondary"
>
Sync Now
</Button>
</Box>
)}
</Box>
)
}
Expand All @@ -236,6 +333,16 @@ function SnapshotCard({
}) {
const inProgress = status?.inProgress ?? false
const error = status?.lastResult?.error
const isProduction = status?.isProduction ?? false

// Use in-memory lastRun, or fall back to persisted S3 metadata
const effectiveLastRun = status?.lastRun ?? status?.persistedLastRun ?? null
const effectiveSize =
status?.lastResult?.sizeBytes ?? status?.latestSnapshot?.sizeBytes ?? null
const effectiveKey =
status?.lastResult?.key ?? status?.latestSnapshot?.key ?? null

const localImport = status?.localImport

return (
<Box background="neutral0" padding={6} shadow="tableShadow" hasRadius>
Expand All @@ -246,24 +353,66 @@ function SnapshotCard({
<StateBadge inProgress={inProgress} error={error} />
</Flex>

{status?.lastRun && (
{!isProduction && (
<Box paddingTop={2}>
<Alert variant="default" title="Development mode" closeLabel="Close">
Snapshot creation is disabled outside production. Use{" "}
<code>pnpm data-import</code> to download a snapshot locally.
</Alert>
</Box>
)}

{isProduction && effectiveLastRun && (
<Box paddingTop={2}>
<Typography variant="omega" textColor="neutral600">
Last snapshot: {formatTime(status.lastRun)}
{status.lastResult?.duration &&
Last snapshot: {formatTime(effectiveLastRun)}
{status?.lastResult?.duration &&
` (${formatDuration(status.lastResult.duration)})`}
{!status?.lastRun && status?.persistedLastRun && " (from S3)"}
</Typography>
</Box>
)}

{isProduction && effectiveKey && (
<Box paddingTop={1}>
<Typography variant="omega" textColor="neutral600">
Key: {effectiveKey}
</Typography>
</Box>
)}

{status?.lastResult?.sizeBytes && (
{effectiveSize && (
<Box paddingTop={1}>
<Typography variant="omega" textColor="neutral600">
Size: {formatBytes(status.lastResult.sizeBytes)}
Size: {formatBytes(effectiveSize)}
</Typography>
</Box>
)}

{!isProduction && localImport && (
<Box paddingTop={2}>
{localImport.lastImportedAt ? (
<>
<Typography variant="omega" textColor="neutral600">
Last local import: {formatTime(localImport.lastImportedAt)}
</Typography>
{localImport.snapshotKey && (
<Box paddingTop={1}>
<Typography variant="omega" textColor="neutral600">
Snapshot: {localImport.snapshotKey}
</Typography>
</Box>
)}
</>
) : (
<Typography variant="omega" textColor="neutral600">
No local import recorded. Run <code>pnpm data-import</code> to
restore production data.
</Typography>
)}
</Box>
)}

{inProgress && (
<Box paddingTop={3}>
<Flex alignItems="center" gap={2}>
Expand All @@ -282,15 +431,17 @@ function SnapshotCard({
)}

<Flex paddingTop={4} gap={2}>
<Button
onClick={onTrigger}
disabled={inProgress || triggering}
loading={triggering}
variant="secondary"
>
Create Snapshot
</Button>
{downloadUrl && !inProgress && (
{isProduction && (
<Button
onClick={onTrigger}
disabled={inProgress || triggering}
loading={triggering}
variant="secondary"
>
Create Snapshot
</Button>
)}
{isProduction && downloadUrl && !inProgress && (
<Button
variant="tertiary"
tag="a"
Expand Down Expand Up @@ -330,6 +481,8 @@ export default function SystemStatusPage() {
if (snapRes.status === "fulfilled") setSnapshotStatus(snapRes.value.data)
}, [get])

const isProduction = syncStatus?.isProduction ?? snapshotStatus?.isProduction

const fetchDownloadUrl = useCallback(async () => {
try {
const res = await get<{ url: string }>(
Expand Down Expand Up @@ -408,7 +561,11 @@ export default function SystemStatusPage() {
<Page.Main>
<Layouts.Header
title="Core Sync Status"
subtitle="Core sync and data snapshot operations"
subtitle={
isProduction === false
? "Development mode — sync and snapshot triggers are disabled"
: "Core sync and data snapshot operations"
}
/>
<Layouts.Content>
<Grid.Root gap={6}>
Expand Down
30 changes: 28 additions & 2 deletions apps/cms/src/api/core-sync/controllers/core-sync.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import type { Core } from "@strapi/strapi"
import { runSync, resolveScope, getSyncStatus } from "../services/core-sync"
import {
runSync,
resolveScope,
getSyncStatus,
getPersistedSyncStatus,
} from "../services/core-sync"
import { formatError } from "../services/strapi-helpers"

type StrapiContext = {
Expand All @@ -10,6 +15,15 @@ type StrapiContext = {

export default ({ strapi }: { strapi: Core.Strapi }) => ({
async trigger(ctx: StrapiContext) {
if (process.env.NODE_ENV !== "production") {
ctx.status = 403
ctx.body = {
error:
"Core sync can only be triggered in production. Use pnpm data-import to restore a snapshot locally.",
}
return
}

const scope = ctx.request.body?.scope
const incremental =
ctx.request.body?.incremental !== undefined
Expand All @@ -34,6 +48,18 @@ export default ({ strapi }: { strapi: Core.Strapi }) => ({
},

async status(ctx: StrapiContext) {
ctx.body = getSyncStatus()
const status = getSyncStatus()
const isProduction = process.env.NODE_ENV === "production"

// 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
// shows when the last sync ran and which phases were included.
if (!status.inProgress && !status.lastRun) {
const persisted = await getPersistedSyncStatus(strapi)
ctx.body = { ...status, ...persisted, isProduction }
return
}

ctx.body = { ...status, isProduction }
},
})
Loading
Loading