Skip to content

Commit 1d4b0c8

Browse files
committed
feature(FR-1471): migrate the GitHub/GitLab importing feature to React
1 parent 4c0bf66 commit 1d4b0c8

File tree

28 files changed

+463
-495
lines changed

28 files changed

+463
-495
lines changed

packages/backend.ai-ui/src/hooks/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,3 +37,4 @@ export { default as useErrorMessageResolver } from './useErrorMessageResolver';
3737
export { default as useViewer } from './useViewer';
3838
export type { ErrorResponse } from './useErrorMessageResolver';
3939
export type { ESMClientErrorResponse } from './useErrorMessageResolver';
40+
export { default as useGetAvailableFolderName } from './useGetAvailableFolderName';
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { useGetAvailableFolderNameQuery } from '../__generated__/useGetAvailableFolderNameQuery.graphql';
2+
import { generateRandomString } from '../helper';
3+
import { fetchQuery, graphql, useRelayEnvironment } from 'react-relay';
4+
5+
export const useGetAvailableFolderName = () => {
6+
'use memo';
7+
const relayEnv = useRelayEnvironment();
8+
return async (seedName: string) => {
9+
// Limit folder name length to 64 characters
10+
const targetName = seedName.substring(0, 64);
11+
const count = await fetchQuery<useGetAvailableFolderNameQuery>(
12+
relayEnv,
13+
graphql`
14+
query useGetAvailableFolderNameQuery($filter: String!) {
15+
vfolder_nodes(filter: $filter, permission: "read_attribute") {
16+
edges {
17+
node {
18+
name
19+
status
20+
}
21+
}
22+
count
23+
}
24+
}
25+
`,
26+
{
27+
filter: `(name == "${targetName}") & (status != "delete-complete")`,
28+
},
29+
)
30+
.toPromise()
31+
.then((data) => data?.vfolder_nodes?.count)
32+
.catch(() => 0);
33+
34+
const hash = generateRandomString(5);
35+
console.log(targetName, count);
36+
return count === 0 ? targetName : `${targetName.substring(0, 58)}_${hash}`;
37+
};
38+
};
39+
40+
export default useGetAvailableFolderName;

react/src/components/ImportNotebook.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ const ImportNotebook: React.FC<FormProps> = (props) => {
5454
} as PrimaryAppOption,
5555
},
5656
]);
57+
webuiNavigate('/session');
5758
}
5859

5960
if (results?.rejected && results.rejected.length > 0) {
@@ -68,7 +69,7 @@ const ImportNotebook: React.FC<FormProps> = (props) => {
6869
};
6970

7071
return (
71-
<Form ref={formRef} layout="inline" {...props}>
72+
<Form ref={formRef} layout="vertical" {...props}>
7273
<Form.Item
7374
name="url"
7475
label={t('import.NotebookURL')}
@@ -94,6 +95,7 @@ const ImportNotebook: React.FC<FormProps> = (props) => {
9495
<BAIButton
9596
icon={<CloudDownloadOutlined />}
9697
type="primary"
98+
block
9799
action={async () => {
98100
const values = await formRef.current
99101
?.validateFields()
Lines changed: 327 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,327 @@
1+
import { useSuspendedBackendaiClient } from '../hooks';
2+
import StorageSelect from './StorageSelect';
3+
import { CloudDownloadOutlined } from '@ant-design/icons';
4+
import { App, Form, FormInstance, FormProps, Input, message } from 'antd';
5+
import {
6+
BAIButton,
7+
useErrorMessageResolver,
8+
useGetAvailableFolderName,
9+
} from 'backend.ai-ui';
10+
import _ from 'lodash';
11+
import { useRef } from 'react';
12+
import { useTranslation } from 'react-i18next';
13+
import { useSetBAINotification } from 'src/hooks/useBAINotification';
14+
import {
15+
StartSessionResults,
16+
StartSessionWithDefaultValue,
17+
useStartSession,
18+
} from 'src/hooks/useStartSession';
19+
20+
type URLType = 'github' | 'gitlab';
21+
22+
interface ImportFromURLFormValues {
23+
url: string;
24+
storageHost: string;
25+
gitlabBranch?: string;
26+
}
27+
28+
interface ImportFromURLFormProps extends FormProps {
29+
urlType: URLType;
30+
}
31+
32+
const createRepoBootstrapScript = (
33+
archiveUrl: string,
34+
folderName: string,
35+
extractedDirectory?: string,
36+
) => {
37+
const scriptLines = [
38+
'#!/bin/sh',
39+
'# Create folder and download repository',
40+
`curl -o /tmp/repo.zip ${archiveUrl}`,
41+
`mkdir -p /home/work/${folderName}`,
42+
`cd /home/work/${folderName}`,
43+
'unzip -o /tmp/repo.zip',
44+
extractedDirectory
45+
? [
46+
'# Move contents if in subfolder',
47+
`if [ -d "${extractedDirectory}" ]; then`,
48+
` mv ${extractedDirectory}/* .`,
49+
` rm -rf ${extractedDirectory}`,
50+
'fi',
51+
].join('\n')
52+
: undefined,
53+
'rm -f /tmp/repo.zip',
54+
];
55+
56+
return scriptLines.filter(Boolean).join('\n');
57+
};
58+
59+
const ImportRepoForm: React.FC<ImportFromURLFormProps> = ({
60+
urlType,
61+
...formProps
62+
}) => {
63+
'use memo';
64+
const formRef = useRef<FormInstance<ImportFromURLFormValues> | null>(null);
65+
const baiClient = useSuspendedBackendaiClient();
66+
const { upsertNotification } = useSetBAINotification();
67+
const app = App.useApp();
68+
const { getErrorMessage } = useErrorMessageResolver();
69+
70+
const { t } = useTranslation();
71+
72+
const getAvailableFolderName = useGetAvailableFolderName();
73+
74+
const { startSessionWithDefault, upsertSessionNotification } =
75+
useStartSession();
76+
77+
const handleStartSessionRejectedResults = (results: StartSessionResults) => {
78+
if (results?.rejected && results.rejected.length > 0) {
79+
const error = results.rejected[0].reason;
80+
app.modal.error({
81+
title: error?.title,
82+
content: getErrorMessage(error),
83+
});
84+
}
85+
};
86+
87+
const prepareGitHubArchive = async (inputUrl: string) => {
88+
const sanitizedUrl = inputUrl.trim().replace(/\.git$/, '');
89+
let parsedUrl: URL;
90+
91+
try {
92+
parsedUrl = new URL(sanitizedUrl);
93+
} catch (error) {
94+
message.error(
95+
'Invalid GitHub URL. Must be a valid GitHub repository URL',
96+
);
97+
return null;
98+
}
99+
100+
const pathname = parsedUrl.pathname.replace(/\/$/, '');
101+
const segments = pathname.split('/').filter(Boolean);
102+
103+
if (segments.length < 2) {
104+
message.error(
105+
'Invalid GitHub URL. Must be a valid GitHub repository URL',
106+
);
107+
return null;
108+
}
109+
110+
const owner = segments[0];
111+
const repo = segments[1];
112+
const branchMatch = pathname.match(/\/tree\/([.\w-]+)/);
113+
let branch = branchMatch?.[1];
114+
115+
if (!branch) {
116+
const repoApiUrl = `https://api.github.com/repos/${owner}/${repo}`;
117+
118+
try {
119+
const response = await fetch(repoApiUrl);
120+
121+
if (response.status === 200) {
122+
const data = await response.json();
123+
branch = data.default_branch;
124+
} else if (response.status === 404) {
125+
message.error('Repository not found');
126+
return null;
127+
} else {
128+
message.error('Failed to fetch repository information');
129+
return null;
130+
}
131+
} catch (error) {
132+
message.error('Failed to import GitHub repository');
133+
return null;
134+
}
135+
}
136+
137+
const resolvedBranch = branch ?? 'master';
138+
const encodedBranch = encodeURIComponent(resolvedBranch);
139+
const sanitizedBranch = resolvedBranch.replace(/\//g, '-');
140+
141+
return {
142+
archiveUrl: `https://codeload.github.com/${owner}/${repo}/zip/${encodedBranch}`,
143+
repoName: repo,
144+
branch: resolvedBranch,
145+
extractedDirectory: `${repo}-${sanitizedBranch}`,
146+
};
147+
};
148+
149+
const prepareGitLabArchive = (inputUrl: string, branchInput?: string) => {
150+
const sanitizedUrl = inputUrl.trim().replace(/\.git$/, '');
151+
let parsedUrl: URL;
152+
153+
try {
154+
parsedUrl = new URL(sanitizedUrl);
155+
} catch (error) {
156+
message.error(
157+
'Invalid GitLab URL. Must be a valid GitLab repository URL',
158+
);
159+
return null;
160+
}
161+
162+
const pathname = parsedUrl.pathname.replace(/\/$/, '');
163+
const segments = pathname.split('/').filter(Boolean);
164+
165+
if (segments.length < 2) {
166+
message.error(
167+
'Invalid GitLab URL. Must be a valid GitLab repository URL',
168+
);
169+
return null;
170+
}
171+
172+
const repoName = segments[segments.length - 1];
173+
const branchMatch = pathname.match(/\/-\/tree\/([.\w-]+)/);
174+
const branchFromUrl = branchMatch?.[1];
175+
const branch =
176+
(branchInput && branchInput.trim()) || branchFromUrl || 'master';
177+
178+
const encodedBranch = encodeURIComponent(branch);
179+
const sanitizedBranch = branch.replace(/\//g, '-');
180+
const basePath = branchMatch
181+
? pathname.slice(0, pathname.indexOf('/-/tree/'))
182+
: pathname;
183+
184+
return {
185+
archiveUrl: `${parsedUrl.origin}${basePath}/-/archive/${encodedBranch}/${repoName}-${sanitizedBranch}.zip`,
186+
repoName,
187+
branch,
188+
extractedDirectory: `${repoName}-${sanitizedBranch}`,
189+
};
190+
};
191+
192+
const handleRepoImport = async (values: ImportFromURLFormValues) => {
193+
try {
194+
const repoInfo =
195+
urlType === 'github'
196+
? await prepareGitHubArchive(values.url)
197+
: urlType === 'gitlab'
198+
? await prepareGitLabArchive(values.url, values.gitlabBranch)
199+
: null;
200+
if (!repoInfo) return;
201+
202+
const folderName = await getAvailableFolderName(
203+
repoInfo.repoName || 'imported-from-repo',
204+
);
205+
206+
// create virtual folder
207+
const vfolderInfo = await baiClient.vfolder.create(
208+
folderName,
209+
values.storageHost,
210+
'', // group
211+
'general', // usage mode
212+
'rw', // permission
213+
);
214+
215+
upsertNotification({
216+
key: `folder-create-success-${vfolderInfo.id}`,
217+
icon: 'folder',
218+
message: `${vfolderInfo.name}: ${t('data.folders.FolderCreated')}`,
219+
toText: t('data.folders.OpenAFolder'),
220+
to: {
221+
search: new URLSearchParams({
222+
folder: vfolderInfo.id,
223+
}).toString(),
224+
},
225+
open: true,
226+
duration: 0,
227+
});
228+
229+
const launcherValue: StartSessionWithDefaultValue = {
230+
sessionName: `importing-files-to-${folderName}`,
231+
environments: {
232+
version: baiClient._config.default_import_environment,
233+
},
234+
sessionType: 'batch',
235+
batch: {
236+
command: createRepoBootstrapScript(
237+
repoInfo.archiveUrl,
238+
folderName,
239+
repoInfo.extractedDirectory,
240+
),
241+
enabled: true,
242+
},
243+
mount_ids: [vfolderInfo.id],
244+
};
245+
246+
const results = await startSessionWithDefault(launcherValue);
247+
248+
if (results.fulfilled && results.fulfilled.length > 0) {
249+
// Handle successful result
250+
upsertSessionNotification(results.fulfilled);
251+
formRef.current?.resetFields();
252+
}
253+
254+
handleStartSessionRejectedResults(results);
255+
} catch (error: any) {
256+
app.message.error(getErrorMessage(error));
257+
}
258+
};
259+
260+
return (
261+
<Form ref={formRef} layout="vertical" {...formProps}>
262+
<Form.Item>
263+
{urlType === 'github'
264+
? t('import.RepoWillBeFolder')
265+
: t('import.GitlabRepoWillBeFolder')}
266+
</Form.Item>
267+
<Form.Item
268+
name="url"
269+
label={
270+
urlType == 'github' ? t('import.GitHubURL') : t('import.GitlabURL')
271+
}
272+
rules={[
273+
{ required: true },
274+
{ type: 'string', max: 2048 },
275+
{
276+
pattern:
277+
urlType === 'github'
278+
? /^(https?):\/\/github\.com\/([\w./-]{1,})$/
279+
: /^(https?):\/\/gitlab\.com\/([\w./-]{1,})$/,
280+
message: t('import.WrongURLType'),
281+
},
282+
]}
283+
>
284+
<Input />
285+
</Form.Item>
286+
287+
{urlType === 'gitlab' && (
288+
<>
289+
<Form.Item
290+
name="gitlabBranch"
291+
label={t('import.GitlabDefaultBranch')}
292+
>
293+
<Input placeholder="master" maxLength={200} />
294+
</Form.Item>
295+
</>
296+
)}
297+
298+
<Form.Item
299+
name="storageHost"
300+
label="Storage Host"
301+
rules={[{ required: true, message: 'Please select a storage host' }]}
302+
>
303+
<StorageSelect showUsageStatus autoSelectType="usage" />
304+
</Form.Item>
305+
<Form.Item>
306+
<BAIButton
307+
icon={<CloudDownloadOutlined />}
308+
action={async () => {
309+
try {
310+
const values = await formRef.current?.validateFields();
311+
if (!values) return;
312+
await handleRepoImport(values);
313+
} catch (error) {
314+
console.error('Form validation failed:', error);
315+
}
316+
}}
317+
block
318+
type="primary"
319+
>
320+
{t('import.GetToFolder')}
321+
</BAIButton>
322+
</Form.Item>
323+
</Form>
324+
);
325+
};
326+
327+
export default ImportRepoForm;

0 commit comments

Comments
 (0)