Skip to content

Commit cbb733e

Browse files
committed
feat(panel): deploy logic
1 parent 3fd38a0 commit cbb733e

File tree

8 files changed

+414
-7
lines changed

8 files changed

+414
-7
lines changed
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
'use client'
2+
3+
import { useCallback, useState } from 'react'
4+
import { Loader2 } from 'lucide-react'
5+
import { Button, Rocket } from '@/components/emcn'
6+
import { DeployModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components'
7+
import type { WorkspaceUserPermissions } from '@/hooks/use-user-permissions'
8+
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
9+
import { useChangeDetection, useDeployedState, useDeployment } from './hooks'
10+
11+
interface DeployProps {
12+
activeWorkflowId: string | null
13+
userPermissions: WorkspaceUserPermissions
14+
className?: string
15+
}
16+
17+
/**
18+
* Deploy component that handles workflow deployment
19+
* Manages deployed state, change detection, and deployment operations
20+
*/
21+
export function Deploy({ activeWorkflowId, userPermissions, className }: DeployProps) {
22+
const [isModalOpen, setIsModalOpen] = useState(false)
23+
const { isLoading: isRegistryLoading } = useWorkflowRegistry()
24+
25+
// Get deployment status from registry
26+
const deploymentStatus = useWorkflowRegistry((state) =>
27+
state.getWorkflowDeploymentStatus(activeWorkflowId)
28+
)
29+
const isDeployed = deploymentStatus?.isDeployed || false
30+
31+
// Fetch and manage deployed state
32+
const { deployedState, isLoadingDeployedState, refetchDeployedState } = useDeployedState({
33+
workflowId: activeWorkflowId,
34+
isDeployed,
35+
isRegistryLoading,
36+
})
37+
38+
// Detect changes between current and deployed state
39+
const { changeDetected, setChangeDetected } = useChangeDetection({
40+
workflowId: activeWorkflowId,
41+
deployedState,
42+
isLoadingDeployedState,
43+
})
44+
45+
// Handle deployment operations
46+
const { isDeploying, handleDeployClick } = useDeployment({
47+
workflowId: activeWorkflowId,
48+
isDeployed,
49+
refetchDeployedState,
50+
})
51+
52+
const canDeploy = userPermissions.canAdmin
53+
const isDisabled = isDeploying || !canDeploy
54+
const isPreviousVersionActive = isDeployed && changeDetected
55+
56+
/**
57+
* Handle deploy button click
58+
*/
59+
const onDeployClick = useCallback(async () => {
60+
if (!canDeploy || !activeWorkflowId) return
61+
62+
const result = await handleDeployClick()
63+
if (result.shouldOpenModal) {
64+
setIsModalOpen(true)
65+
}
66+
}, [canDeploy, activeWorkflowId, handleDeployClick])
67+
68+
const refetchWithErrorHandling = async () => {
69+
if (!activeWorkflowId) return
70+
71+
try {
72+
await refetchDeployedState()
73+
} catch (error) {
74+
// Error already logged in hook
75+
}
76+
}
77+
78+
return (
79+
<>
80+
<Button
81+
className='h-[32px] gap-[8px] px-[10px]'
82+
variant='active'
83+
onClick={onDeployClick}
84+
disabled={isDisabled}
85+
>
86+
{isDeploying ? (
87+
<Loader2 className='h-[13px] w-[13px] animate-spin' />
88+
) : (
89+
<Rocket className='h-[13px] w-[13px]' />
90+
)}
91+
{changeDetected ? 'Update' : isDeployed ? 'Active' : 'Deploy'}
92+
</Button>
93+
94+
<DeployModal
95+
open={isModalOpen}
96+
onOpenChange={setIsModalOpen}
97+
workflowId={activeWorkflowId}
98+
needsRedeployment={changeDetected}
99+
setNeedsRedeployment={setChangeDetected}
100+
deployedState={deployedState!}
101+
isLoadingDeployedState={isLoadingDeployedState}
102+
refetchDeployedState={refetchWithErrorHandling}
103+
/>
104+
</>
105+
)
106+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export { useChangeDetection } from './use-change-detection'
2+
export { useDeployedState } from './use-deployed-state'
3+
export { useDeployment } from './use-deployment'
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import { useEffect, useMemo, useState } from 'react'
2+
import { createLogger } from '@/lib/logs/console/logger'
3+
import { useDebounce } from '@/hooks/use-debounce'
4+
import { useOperationQueueStore } from '@/stores/operation-queue/store'
5+
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
6+
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
7+
import type { WorkflowState } from '@/stores/workflows/workflow/types'
8+
9+
const logger = createLogger('useChangeDetection')
10+
11+
interface UseChangeDetectionProps {
12+
workflowId: string | null
13+
deployedState: WorkflowState | null
14+
isLoadingDeployedState: boolean
15+
}
16+
17+
/**
18+
* Hook to detect changes between current workflow state and deployed state
19+
* Uses API-based change detection for accuracy
20+
*/
21+
export function useChangeDetection({
22+
workflowId,
23+
deployedState,
24+
isLoadingDeployedState,
25+
}: UseChangeDetectionProps) {
26+
const [changeDetected, setChangeDetected] = useState(false)
27+
const [blockStructureVersion, setBlockStructureVersion] = useState(0)
28+
const [edgeStructureVersion, setEdgeStructureVersion] = useState(0)
29+
const [subBlockStructureVersion, setSubBlockStructureVersion] = useState(0)
30+
31+
// Get current store state for change detection
32+
const currentBlocks = useWorkflowStore((state) => state.blocks)
33+
const currentEdges = useWorkflowStore((state) => state.edges)
34+
const lastSaved = useWorkflowStore((state) => state.lastSaved)
35+
const subBlockValues = useSubBlockStore((state) =>
36+
workflowId ? state.workflowValues[workflowId] : null
37+
)
38+
39+
// Track structure changes
40+
useEffect(() => {
41+
setBlockStructureVersion((version) => version + 1)
42+
}, [currentBlocks])
43+
44+
useEffect(() => {
45+
setEdgeStructureVersion((version) => version + 1)
46+
}, [currentEdges])
47+
48+
useEffect(() => {
49+
setSubBlockStructureVersion((version) => version + 1)
50+
}, [subBlockValues])
51+
52+
// Reset version counters when workflow changes
53+
useEffect(() => {
54+
setBlockStructureVersion(0)
55+
setEdgeStructureVersion(0)
56+
setSubBlockStructureVersion(0)
57+
}, [workflowId])
58+
59+
// Create trigger for status check
60+
const statusCheckTrigger = useMemo(() => {
61+
return JSON.stringify({
62+
lastSaved: lastSaved ?? 0,
63+
blockVersion: blockStructureVersion,
64+
edgeVersion: edgeStructureVersion,
65+
subBlockVersion: subBlockStructureVersion,
66+
})
67+
}, [lastSaved, blockStructureVersion, edgeStructureVersion, subBlockStructureVersion])
68+
69+
const debouncedStatusCheckTrigger = useDebounce(statusCheckTrigger, 500)
70+
71+
useEffect(() => {
72+
// Avoid off-by-one false positives: wait until operation queue is idle
73+
const { operations, isProcessing } = useOperationQueueStore.getState()
74+
const hasPendingOps =
75+
isProcessing || operations.some((op) => op.status === 'pending' || op.status === 'processing')
76+
77+
if (!workflowId || !deployedState) {
78+
setChangeDetected(false)
79+
return
80+
}
81+
82+
if (isLoadingDeployedState || hasPendingOps) {
83+
return
84+
}
85+
86+
// Use the workflow status API to get accurate change detection
87+
// This uses the same logic as the deployment API (reading from normalized tables)
88+
const checkForChanges = async () => {
89+
try {
90+
const response = await fetch(`/api/workflows/${workflowId}/status`)
91+
if (response.ok) {
92+
const data = await response.json()
93+
setChangeDetected(data.needsRedeployment || false)
94+
} else {
95+
logger.error('Failed to fetch workflow status:', response.status, response.statusText)
96+
setChangeDetected(false)
97+
}
98+
} catch (error) {
99+
logger.error('Error fetching workflow status:', error)
100+
setChangeDetected(false)
101+
}
102+
}
103+
104+
checkForChanges()
105+
}, [workflowId, deployedState, debouncedStatusCheckTrigger, isLoadingDeployedState])
106+
107+
return {
108+
changeDetected,
109+
setChangeDetected,
110+
}
111+
}
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import { useEffect, useState } from 'react'
2+
import { createLogger } from '@/lib/logs/console/logger'
3+
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
4+
import type { WorkflowState } from '@/stores/workflows/workflow/types'
5+
6+
const logger = createLogger('useDeployedState')
7+
8+
interface UseDeployedStateProps {
9+
workflowId: string | null
10+
isDeployed: boolean
11+
isRegistryLoading: boolean
12+
}
13+
14+
/**
15+
* Hook to fetch and manage deployed workflow state
16+
* Includes race condition protection for workflow changes
17+
*/
18+
export function useDeployedState({
19+
workflowId,
20+
isDeployed,
21+
isRegistryLoading,
22+
}: UseDeployedStateProps) {
23+
const [deployedState, setDeployedState] = useState<WorkflowState | null>(null)
24+
const [isLoadingDeployedState, setIsLoadingDeployedState] = useState<boolean>(false)
25+
26+
const setNeedsRedeploymentFlag = useWorkflowRegistry(
27+
(state) => state.setWorkflowNeedsRedeployment
28+
)
29+
30+
/**
31+
* Fetches the deployed state of the workflow from the server
32+
* This is the single source of truth for deployed workflow state
33+
*/
34+
const fetchDeployedState = async () => {
35+
if (!workflowId || !isDeployed) {
36+
setDeployedState(null)
37+
return
38+
}
39+
40+
// Store the workflow ID at the start of the request to prevent race conditions
41+
const requestWorkflowId = workflowId
42+
43+
// Helper to get current active workflow ID for race condition checks
44+
const getCurrentActiveWorkflowId = () => useWorkflowRegistry.getState().activeWorkflowId
45+
46+
try {
47+
setIsLoadingDeployedState(true)
48+
49+
const response = await fetch(`/api/workflows/${requestWorkflowId}/deployed`)
50+
51+
// Check if the workflow ID changed during the request (user navigated away)
52+
if (requestWorkflowId !== getCurrentActiveWorkflowId()) {
53+
logger.debug('Workflow changed during deployed state fetch, ignoring response')
54+
return
55+
}
56+
57+
if (!response.ok) {
58+
if (response.status === 404) {
59+
setDeployedState(null)
60+
return
61+
}
62+
throw new Error(`Failed to fetch deployed state: ${response.statusText}`)
63+
}
64+
65+
const data = await response.json()
66+
67+
if (requestWorkflowId === getCurrentActiveWorkflowId()) {
68+
setDeployedState(data.deployedState || null)
69+
} else {
70+
logger.debug('Workflow changed after deployed state response, ignoring result')
71+
}
72+
} catch (error) {
73+
logger.error('Error fetching deployed state:', { error })
74+
if (requestWorkflowId === getCurrentActiveWorkflowId()) {
75+
setDeployedState(null)
76+
}
77+
} finally {
78+
if (requestWorkflowId === getCurrentActiveWorkflowId()) {
79+
setIsLoadingDeployedState(false)
80+
}
81+
}
82+
}
83+
84+
useEffect(() => {
85+
if (!workflowId) {
86+
setDeployedState(null)
87+
setIsLoadingDeployedState(false)
88+
return
89+
}
90+
91+
if (isRegistryLoading) {
92+
setDeployedState(null)
93+
setIsLoadingDeployedState(false)
94+
return
95+
}
96+
97+
if (isDeployed) {
98+
setNeedsRedeploymentFlag(workflowId, false)
99+
fetchDeployedState()
100+
} else {
101+
setDeployedState(null)
102+
setIsLoadingDeployedState(false)
103+
}
104+
// eslint-disable-next-line react-hooks/exhaustive-deps
105+
}, [workflowId, isDeployed, isRegistryLoading, setNeedsRedeploymentFlag])
106+
107+
return {
108+
deployedState,
109+
isLoadingDeployedState,
110+
refetchDeployedState: fetchDeployedState,
111+
}
112+
}

0 commit comments

Comments
 (0)