1+ 'use client' ;
2+
3+ import { Button } from '@heroui/button' ;
4+ import { SelectItem } from '@heroui/select' ;
15import { BOOLEAN_TYPES } from '@data/booleanTypes' ;
6+ import { getTunnels } from '@lib/api/tunnels' ;
7+ import { TunnelsContextType , useTunnelsContext } from '@lib/contexts/tunnels' ;
28import InputWithLabel from '@shared/InputWithLabel' ;
9+ import Label from '@shared/Label' ;
310import DeeployInfoTag from '@shared/jobs/DeeployInfoTag' ;
411import NumberInputWithLabel from '@shared/NumberInputWithLabel' ;
512import SelectWithLabel from '@shared/SelectWithLabel' ;
13+ import StyledSelect from '@shared/StyledSelect' ;
14+ import { useCallback , useEffect , useMemo , useState } from 'react' ;
615import { useFormContext } from 'react-hook-form' ;
16+ import { RiCodeSSlashLine } from 'react-icons/ri' ;
17+
18+ type TunnelGenerationResult = {
19+ token ?: string ;
20+ url ?: string ;
21+ } ;
22+
23+ type ExistingTunnelOption = {
24+ id : string ;
25+ alias : string ;
26+ token : string ;
27+ url : string ;
28+ } ;
29+
30+ type TunnelSelectOption = ExistingTunnelOption & {
31+ isCustom ?: boolean ;
32+ } ;
33+
34+ const CUSTOM_TUNNEL_OPTION = 'custom' ;
735
836export default function AppParametersSection ( {
937 baseName = 'deployment' ,
@@ -13,6 +41,10 @@ export default function AppParametersSection({
1341 forceTunnelingEnabled = false ,
1442 disableTunneling = false ,
1543 tunnelingDisabledNote,
44+ enableTunnelSelector = false ,
45+ onGenerateTunnel,
46+ isTunnelGenerationDisabled = false ,
47+ onTunnelUrlChange,
1648} : {
1749 baseName ?: string ;
1850 isCreatingTunnel ?: boolean ;
@@ -21,10 +53,140 @@ export default function AppParametersSection({
2153 forceTunnelingEnabled ?: boolean ;
2254 disableTunneling ?: boolean ;
2355 tunnelingDisabledNote ?: string ;
56+ enableTunnelSelector ?: boolean ;
57+ onGenerateTunnel ?: ( ) => Promise < TunnelGenerationResult | undefined > ;
58+ isTunnelGenerationDisabled ?: boolean ;
59+ onTunnelUrlChange ?: ( url ?: string ) => void ;
2460} ) {
2561 const { watch, trigger, setValue, clearErrors } = useFormContext ( ) ;
62+ const { tunnelingSecrets } = useTunnelsContext ( ) as TunnelsContextType ;
2663
2764 const enableTunneling : ( typeof BOOLEAN_TYPES ) [ number ] = watch ( `${ baseName } .enableTunneling` ) ;
65+ const tunnelingToken : string | undefined = watch ( `${ baseName } .tunnelingToken` ) ;
66+
67+ const [ existingTunnels , setExistingTunnels ] = useState < ExistingTunnelOption [ ] > ( [ ] ) ;
68+ const [ selectedTunnelId , setSelectedTunnelId ] = useState < string > ( CUSTOM_TUNNEL_OPTION ) ;
69+ const [ isFetchingTunnels , setFetchingTunnels ] = useState < boolean > ( false ) ;
70+ const tunnelSelectOptions = useMemo < TunnelSelectOption [ ] > (
71+ ( ) => [
72+ {
73+ id : CUSTOM_TUNNEL_OPTION ,
74+ alias : 'Custom' ,
75+ token : '' ,
76+ url : 'Enter token manually' ,
77+ isCustom : true ,
78+ } ,
79+ ...existingTunnels ,
80+ ] ,
81+ [ existingTunnels ] ,
82+ ) ;
83+
84+ const shouldShowTunnelAlternatives = enableTunnelSelector && enableTunneling === BOOLEAN_TYPES [ 0 ] ;
85+
86+ const fetchExistingTunnels = useCallback ( async ( ) : Promise < ExistingTunnelOption [ ] > => {
87+ if ( ! tunnelingSecrets ) {
88+ setExistingTunnels ( [ ] ) ;
89+ return [ ] ;
90+ }
91+
92+ setFetchingTunnels ( true ) ;
93+
94+ try {
95+ const data = await getTunnels ( tunnelingSecrets . cloudflareAccountId , tunnelingSecrets . cloudflareApiKey ) ;
96+ const tunnelResults = Array . isArray ( data . result ) ? data . result : Object . values ( data . result || { } ) ;
97+
98+ const tunnels = ( tunnelResults as any [ ] )
99+ . filter ( ( tunnel ) => tunnel ?. metadata ?. creator === 'ratio1' && tunnel ?. metadata ?. tunnel_token )
100+ . map ( ( tunnel ) => ( {
101+ id : tunnel . id as string ,
102+ alias : ( tunnel . metadata . alias || tunnel . metadata . dns_name ) as string ,
103+ token : tunnel . metadata . tunnel_token as string ,
104+ url : tunnel . metadata . dns_name as string ,
105+ } ) )
106+ . sort ( ( a , b ) => a . alias . localeCompare ( b . alias ) ) ;
107+
108+ setExistingTunnels ( tunnels ) ;
109+ return tunnels ;
110+ } catch ( error ) {
111+ console . error ( 'Error fetching existing tunnels:' , error ) ;
112+ setExistingTunnels ( [ ] ) ;
113+ return [ ] ;
114+ } finally {
115+ setFetchingTunnels ( false ) ;
116+ }
117+ } , [ tunnelingSecrets ] ) ;
118+
119+ useEffect ( ( ) => {
120+ if ( ! shouldShowTunnelAlternatives ) {
121+ setExistingTunnels ( [ ] ) ;
122+ setSelectedTunnelId ( CUSTOM_TUNNEL_OPTION ) ;
123+ return ;
124+ }
125+
126+ if ( ! tunnelingSecrets ) {
127+ setExistingTunnels ( [ ] ) ;
128+ setSelectedTunnelId ( CUSTOM_TUNNEL_OPTION ) ;
129+ return ;
130+ }
131+
132+ void fetchExistingTunnels ( ) ;
133+ } , [ shouldShowTunnelAlternatives , tunnelingSecrets , fetchExistingTunnels ] ) ;
134+
135+ useEffect ( ( ) => {
136+ if ( ! shouldShowTunnelAlternatives ) {
137+ return ;
138+ }
139+
140+ if ( ! tunnelingToken ) {
141+ setSelectedTunnelId ( CUSTOM_TUNNEL_OPTION ) ;
142+ return ;
143+ }
144+
145+ const matchedTunnel = existingTunnels . find ( ( tunnel ) => tunnel . token === tunnelingToken ) ;
146+ setSelectedTunnelId ( matchedTunnel ?. id || CUSTOM_TUNNEL_OPTION ) ;
147+ } , [ shouldShowTunnelAlternatives , tunnelingToken , existingTunnels ] ) ;
148+
149+ const selectExistingTunnel = ( tunnelId : string ) => {
150+ if ( tunnelId === CUSTOM_TUNNEL_OPTION ) {
151+ setSelectedTunnelId ( CUSTOM_TUNNEL_OPTION ) ;
152+ setValue ( `${ baseName } .tunnelingToken` , undefined , { shouldDirty : true , shouldValidate : true } ) ;
153+ clearErrors ( `${ baseName } .tunnelingToken` ) ;
154+ onTunnelUrlChange ?.( undefined ) ;
155+ return ;
156+ }
157+
158+ const selectedTunnel = existingTunnels . find ( ( tunnel ) => tunnel . id === tunnelId ) ;
159+
160+ if ( ! selectedTunnel ) {
161+ return ;
162+ }
163+
164+ setSelectedTunnelId ( selectedTunnel . id ) ;
165+ setValue ( `${ baseName } .tunnelingToken` , selectedTunnel . token , { shouldDirty : true , shouldValidate : true } ) ;
166+ clearErrors ( `${ baseName } .tunnelingToken` ) ;
167+ onTunnelUrlChange ?.( selectedTunnel . url ) ;
168+ } ;
169+
170+ const handleGenerateTunnel = async ( ) => {
171+ if ( ! onGenerateTunnel ) {
172+ return ;
173+ }
174+
175+ const generatedTunnel = await onGenerateTunnel ( ) ;
176+
177+ if ( ! generatedTunnel ?. token ) {
178+ return ;
179+ }
180+
181+ setValue ( `${ baseName } .tunnelingToken` , generatedTunnel . token , { shouldDirty : true , shouldValidate : true } ) ;
182+ clearErrors ( `${ baseName } .tunnelingToken` ) ;
183+
184+ const refreshedTunnels = tunnelingSecrets ? await fetchExistingTunnels ( ) : existingTunnels ;
185+ const matchedTunnel = refreshedTunnels . find ( ( tunnel ) => tunnel . token === generatedTunnel . token ) ;
186+
187+ setSelectedTunnelId ( matchedTunnel ?. id || CUSTOM_TUNNEL_OPTION ) ;
188+ onTunnelUrlChange ?.( matchedTunnel ?. url || generatedTunnel . url ) ;
189+ } ;
28190
29191 return (
30192 < div className = "col gap-4" >
@@ -41,6 +203,7 @@ export default function AppParametersSection({
41203 trigger ( `${ baseName } .port` ) ;
42204
43205 if ( value === BOOLEAN_TYPES [ 1 ] ) {
206+ setSelectedTunnelId ( CUSTOM_TUNNEL_OPTION ) ;
44207 setValue ( `${ baseName } .tunnelingToken` , undefined ) ;
45208 clearErrors ( `${ baseName } .tunnelingToken` ) ;
46209 }
@@ -57,28 +220,93 @@ export default function AppParametersSection({
57220 ) }
58221 </ div >
59222
60- { disableTunneling && tunnelingDisabledNote && (
61- < DeeployInfoTag text = { tunnelingDisabledNote } />
62- ) }
223+ { disableTunneling && tunnelingDisabledNote && < DeeployInfoTag text = { tunnelingDisabledNote } /> }
63224 </ div >
64225 ) }
65226
66227 { enableTunneling === BOOLEAN_TYPES [ 0 ] && (
67- < div className = "flex gap-4" >
68- < InputWithLabel
69- name = { `${ baseName } .tunnelingToken` }
70- label = "Tunnel Token"
71- placeholder = "Starts with 'ey'"
72- isDisabled = { isCreatingTunnel }
73- />
74-
75- { enableTunnelingLabel && (
76- < InputWithLabel
77- name = { `${ baseName } .tunnelingLabel` }
78- label = "Tunnel Label"
79- placeholder = "My_Tunnel"
80- isOptional
81- />
228+ < div className = "col gap-4" >
229+ { shouldShowTunnelAlternatives && (
230+ < div className = "col w-full gap-1.5" >
231+ < Label value = "Select Tunnel" />
232+
233+ < div className = "row items-end gap-2" >
234+ < StyledSelect
235+ items = { tunnelSelectOptions }
236+ selectedKeys = { [ selectedTunnelId ] }
237+ onSelectionChange = { ( keys ) => {
238+ const selectedKey = Array . from ( keys ) [ 0 ] as string ;
239+ selectExistingTunnel ( selectedKey ) ;
240+ } }
241+ placeholder = { isFetchingTunnels ? 'Loading tunnels...' : 'Select an existing tunnel' }
242+ isDisabled = { isFetchingTunnels }
243+ >
244+ { ( option : object ) => {
245+ const tunnel = option as TunnelSelectOption ;
246+
247+ return (
248+ < SelectItem
249+ key = { tunnel . id }
250+ textValue = { tunnel . isCustom ? tunnel . alias : `${ tunnel . alias } | ${ tunnel . url } ` }
251+ >
252+ < div className = "row items-center gap-2 py-1" >
253+ < div className = "font-medium" > { tunnel . alias } </ div >
254+ < div className = "font-roboto-mono text-xs text-slate-500" > { tunnel . url } </ div >
255+ </ div >
256+ </ SelectItem >
257+ ) ;
258+ } }
259+ </ StyledSelect >
260+
261+ < Button
262+ className = "h-[38px] rounded-lg"
263+ color = "primary"
264+ size = "lg"
265+ onPress = { handleGenerateTunnel }
266+ isLoading = { isCreatingTunnel }
267+ isDisabled = { isTunnelGenerationDisabled || ! tunnelingSecrets }
268+ >
269+ < div className = "row gap-1.5" >
270+ < RiCodeSSlashLine className = "text-base" />
271+ < div className = "compact" > Generate Tunnel</ div >
272+ </ div >
273+ </ Button >
274+ </ div >
275+
276+ { ! tunnelingSecrets && (
277+ < DeeployInfoTag text = "Please add your Cloudflare secrets to enable tunnel generation." />
278+ ) }
279+ </ div >
280+ ) }
281+
282+ { ( ! shouldShowTunnelAlternatives || selectedTunnelId === CUSTOM_TUNNEL_OPTION ) && (
283+ < div className = "flex gap-4" >
284+ < InputWithLabel
285+ name = { `${ baseName } .tunnelingToken` }
286+ label = "Tunnel Token"
287+ placeholder = "Starts with 'ey'"
288+ isDisabled = { isCreatingTunnel }
289+ />
290+ { enableTunnelingLabel && (
291+ < InputWithLabel
292+ name = { `${ baseName } .tunnelingLabel` }
293+ label = "Tunnel Label"
294+ placeholder = "My_Tunnel"
295+ isOptional
296+ />
297+ ) }
298+ </ div >
299+ ) }
300+
301+ { shouldShowTunnelAlternatives && selectedTunnelId !== CUSTOM_TUNNEL_OPTION && enableTunnelingLabel && (
302+ < div className = "flex gap-4" >
303+ < InputWithLabel
304+ name = { `${ baseName } .tunnelingLabel` }
305+ label = "Tunnel Label"
306+ placeholder = "My_Tunnel"
307+ isOptional
308+ />
309+ </ div >
82310 ) }
83311 </ div >
84312 ) }
0 commit comments