Skip to content

Commit 71ff4ba

Browse files
authored
feat: unify tunnel setup UI for generic and service deployments (#59)
* feat: add tunnel setup alternatives for app params * refactor: compact tunnel selector with custom option * fix: restore missing cloudflare secrets helper text * fixes * fix: resolve tunnel dropdown select typing * style: align tunnel refresh control with selector * fix * fix
1 parent bd83997 commit 71ff4ba

File tree

3 files changed

+319
-61
lines changed

3 files changed

+319
-61
lines changed

src/components/create-job/sections/AppParametersSection.tsx

Lines changed: 246 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,37 @@
1+
'use client';
2+
3+
import { Button } from '@heroui/button';
4+
import { SelectItem } from '@heroui/select';
15
import { BOOLEAN_TYPES } from '@data/booleanTypes';
6+
import { getTunnels } from '@lib/api/tunnels';
7+
import { TunnelsContextType, useTunnelsContext } from '@lib/contexts/tunnels';
28
import InputWithLabel from '@shared/InputWithLabel';
9+
import Label from '@shared/Label';
310
import DeeployInfoTag from '@shared/jobs/DeeployInfoTag';
411
import NumberInputWithLabel from '@shared/NumberInputWithLabel';
512
import SelectWithLabel from '@shared/SelectWithLabel';
13+
import StyledSelect from '@shared/StyledSelect';
14+
import { useCallback, useEffect, useMemo, useState } from 'react';
615
import { 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

836
export 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
)}

src/components/create-job/steps/deployment/GenericDeployment.tsx

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
1+
'use client';
2+
13
import AppParametersSection from '@components/create-job/sections/AppParametersSection';
24
import PoliciesSection from '@components/create-job/sections/PoliciesSection';
5+
import { createTunnel } from '@lib/api/tunnels';
6+
import { DeploymentContextType, useDeploymentContext } from '@lib/contexts/deployment';
7+
import { TunnelsContextType, useTunnelsContext } from '@lib/contexts/tunnels';
8+
import { stripToAlphanumeric } from '@lib/utils';
39
import { SlateCard } from '@shared/cards/SlateCard';
410
import InputWithLabel from '@shared/InputWithLabel';
511
import DeploymentTypeSectionCard from '@shared/jobs/deployment-type/DeploymentTypeSectionCard';
@@ -10,8 +16,55 @@ import KeyValueEntriesSection from '@shared/jobs/KeyValueEntriesSection';
1016
import CustomParametersSection from '@shared/jobs/native/CustomParametersSection';
1117
import TargetNodesCard from '@shared/jobs/target-nodes/TargetNodesCard';
1218
import PortMappingSection from '@shared/PortMappingSection';
19+
import { useParams } from 'next/navigation';
20+
import { useState } from 'react';
21+
import { useFormContext } from 'react-hook-form';
22+
import { toast } from 'react-hot-toast';
1323

1424
function GenericDeployment({ isEditingRunningJob }: { isEditingRunningJob?: boolean }) {
25+
const { setFormSubmissionDisabled, getProjectName } = useDeploymentContext() as DeploymentContextType;
26+
const { tunnelingSecrets } = useTunnelsContext() as TunnelsContextType;
27+
const { watch } = useFormContext();
28+
const { projectHash } = useParams<{ projectHash?: string }>();
29+
30+
const alias: string = watch('deployment.jobAlias');
31+
const [isCreatingTunnel, setCreatingTunnel] = useState<boolean>(false);
32+
33+
const onGenerateTunnel = async () => {
34+
if (!tunnelingSecrets) {
35+
toast.error('Missing Cloudflare secrets.');
36+
return;
37+
}
38+
39+
setFormSubmissionDisabled(true);
40+
setCreatingTunnel(true);
41+
42+
try {
43+
const projectName = projectHash ? getProjectName(projectHash) : '';
44+
const tunnelAliasSuffix = alias || 'app';
45+
const tunnelAlias = projectName
46+
? `${stripToAlphanumeric(projectName).toLowerCase()}-${tunnelAliasSuffix}`
47+
: tunnelAliasSuffix;
48+
const response = await createTunnel(tunnelAlias, tunnelingSecrets);
49+
50+
if (!response.result.id || !response.result.metadata?.tunnel_token) {
51+
throw new Error('Failed to create tunnel.');
52+
}
53+
54+
return {
55+
token: response.result.metadata.tunnel_token,
56+
url: response.result.metadata.dns_name,
57+
};
58+
} catch (error) {
59+
console.error(error);
60+
toast.error('Failed to create tunnel.');
61+
return;
62+
} finally {
63+
setCreatingTunnel(false);
64+
setFormSubmissionDisabled(false);
65+
}
66+
};
67+
1568
return (
1669
<div className="col gap-6">
1770
<SlateCard title="App Identity">
@@ -30,7 +83,12 @@ function GenericDeployment({ isEditingRunningJob }: { isEditingRunningJob?: bool
3083
<DeploymentTypeSectionCard isEditingRunningJob={isEditingRunningJob} />
3184

3285
<SlateCard title="App Parameters">
33-
<AppParametersSection />
86+
<AppParametersSection
87+
isCreatingTunnel={isCreatingTunnel}
88+
enableTunnelSelector
89+
onGenerateTunnel={onGenerateTunnel}
90+
isTunnelGenerationDisabled={!tunnelingSecrets || isEditingRunningJob}
91+
/>
3492
</SlateCard>
3593

3694
<SlateCard title="Port Mapping">

0 commit comments

Comments
 (0)