diff --git a/contrib/pai_vscode/CHANGELOG.md b/contrib/pai_vscode/CHANGELOG.md index 7a1d5e20e1..2c90cb1dc7 100644 --- a/contrib/pai_vscode/CHANGELOG.md +++ b/contrib/pai_vscode/CHANGELOG.md @@ -4,8 +4,17 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [0.0.1] - 2018-01-09 +## [0.1.0] - 2019-01 ### Added - Open job pages and dashboard pages in VS Code - Submit job to PAI cluster from VS Code - Open PAI's hdfs as a workspace folder + +## [0.2.0] - 2019-03 +### Added +- Generate jsonc job config by default +- Add a PAI view container (sidebar), includes + - Job list view + - Auto refresh enabled + - HDFS explorer + - You can choose where hdfs explorer will be shown (view container or workspace folder) diff --git a/contrib/pai_vscode/README.md b/contrib/pai_vscode/README.md index 7ae088bc48..f224328a4d 100644 --- a/contrib/pai_vscode/README.md +++ b/contrib/pai_vscode/README.md @@ -41,6 +41,21 @@ The extension has a useful feature called "Simulate Job Running". It enables the ![](https://raw.githubusercontent.com/Microsoft/pai/master/contrib/pai_vscode/assets/simulate-job.gif) +## Sidebar +HDFS Explorer and Job List view will be shown in the extension's view container (sidebar). + +### HDFS Explorer +1. You are able to connect to an OpenPAI cluster's HDFS by double click its corresponding tree node. +2. The HDFS directory structure will be expended in the tree view. +3. Right click the folder or file node to perform file system operations. + +### Job List +1. Browse latest jobs in the OpenPAI cluster in job list tree view. +2. Browse recent submitted jobs by local (extension) in job list tree view. +3. Job list will auto refresh every 10 seconds. +4. Double click job node to browse Job details in external browser. + +![](https://raw.githubusercontent.com/Microsoft/pai/master/contrib/pai_vscode/assets/job-list.png) ## Commands ### Command Pallete @@ -61,7 +76,7 @@ The extension has a useful feature called "Simulate Job Running". It enables the |Submit Job...|Submit job to selected cluster| |Simulate Job Running...|Generate Dockerfile to simulate PAI job running| |Edit Configuration...|Edit cluster configuration| -|Open HDFS...|Open selected cluster's HDFS as a VS Code workspace folder| +|Open HDFS...|Open HDFS explorer of selected cluster| ## Settings |ID|Description| @@ -70,6 +85,10 @@ The extension has a useful feature called "Simulate Job Running". It enables the |pai.job.upload.exclude|Glob pattern for excluding files and folders| |pai.job.upload.include|Glob pattern for including files and folders| |pai.job.generateJobName.enabled|Controls whether the extension will add a random suffix to your job name when submitting job| +|pai.job.jobList.recentJobsLength|Controls the number of recently submitted jobs to keep in history for each PAI cluster| +|pai.job.jobList.allJobsPageSize|Controls the page size of list when listing jobs for each PAI cluster| +|pai.job.jobList.refreshInterval|Controls the refresh interval of job list (in seconds)| +|pai.hdfs.location|Location where hdfs explorer will be shown| ## Requirements PAI Cluster Version >= 0.8.0 diff --git a/contrib/pai_vscode/VSCodeExt.md b/contrib/pai_vscode/VSCodeExt.md index 2f05d80622..eabbe9267f 100644 --- a/contrib/pai_vscode/VSCodeExt.md +++ b/contrib/pai_vscode/VSCodeExt.md @@ -1,23 +1,22 @@ # OpenPAI VS Code Client -OpenPAI VS Code Client can submit AI jobs, simulate job running locally, manage HDFS files, and etc. It's an extension of Visual Studio Code. - -Visual Studio Code is a popular, free, lightweight but powerful source code editor which runs on your desktop and is available for Windows, macOS and Linux. - ## Installation -1. Download and install [Visual Studio Code](https://code.visualstudio.com/) by several clicks. +Visual Studio Code is a lightweight but powerful source code editor which runs on your desktop and is available for Windows, macOS and Linux. + +More information please refer to the [Visual Studio Code Official Site](https://code.visualstudio.com/). -2. Install **OpenPAI Client**. +OpenPAI Client is a VS Code extension to connect PAI clusters, submit AI jobs, and manage files on HDFS, etc. You need to install the extension in VS code before using it. - 1. Launch VS Code. - 2. Click the "Extensions" icon in Activity Bar or press **Ctrl+Shift+X** to bring up the Extensions view. - 3. Input **openpai** in the text box, the OpenPAI VS Code Client will appear in the result list. - 4. Click the **Install** button. The extension will be installed. - 5. After a successful installation, you will see an introduction page. Follow the instructions there and try the PAI client. +To install the OpenPAI Client: +1. Launch VS Code. +2. Click the "Extensions" icon in Activity Bar or press **Ctrl+Shift+X** to bring up the Extensions view. +3. Input **openpai** in the text box, the OpenPAI VS Code Client will appear in the result list. +4. Click the **Install** button. The extension will be installed. +5. After a successful installation, you will see an introduction page. Follow the instructions there and try the PAI client. - ![Extension](./assets/ext-install-1.png) +![Extension](./assets/ext-install-1.png) -## Next step +## Next steps Learn how to [use OpenPAI VS Code Client](./README.md) diff --git a/contrib/pai_vscode/assets/job-list.png b/contrib/pai_vscode/assets/job-list.png new file mode 100644 index 0000000000..19c7d43c9c Binary files /dev/null and b/contrib/pai_vscode/assets/job-list.png differ diff --git a/contrib/pai_vscode/i18n/common.json b/contrib/pai_vscode/i18n/common.json index f7ad857859..057d4202b4 100644 --- a/contrib/pai_vscode/i18n/common.json +++ b/contrib/pai_vscode/i18n/common.json @@ -23,10 +23,18 @@ "treeview.node.edit": "Edit Configuration...", "treeview.node.openhdfs": "Open HDFS...", "treeview.node.openPortal": "Open Web Portal...", - "treeview.node.listjob": "List Jobs...", + "treeview.node.listjob": "List Jobs Externally...", "treeview.node.create-config": "Create Job Config...", "treeview.node.submitjob": "Submit Job...", "treeview.node.simulate": "Simulate Job Running...", + "treeview.hdfs.select-cluster.label": "Double click to connect to a PAI cluster's HDFS...", + "treeview.joblist.recent": "Recent Submitted Jobs from VS Code", + "treeview.joblist.all": "All Jobs", + "treeview.joblist.view": "View Job Detail", + "treeview.joblist.more": "View More...", + "treeview.joblist.error": "Failed to load job list: {0}", + "container.hdfs.mkdir.prompt": "Please enter a folder name", + "container.hdfs.mkdir.cancelled": "Cancelled creating new folder", "hdfs.workspace.title": "HDFS Explorer - {0}", "hdfs.progress": "Transfering file - {0}% ({1} bytes / {2} bytes)", "hdfs.downloading": "Downloading {0}", @@ -47,6 +55,7 @@ "job.prepare.status": "PAI: Preparing for job submission", "job.prepare.cluster.cancelled": "No cluster selected, job submission cancelled.", "job.prepare.config.prompt": "Please select a PAI job config json file", + "job.prepare.config.invalid": "Invalid job config json file, job submission cancelled.", "job.prepare.config.cancelled": "No job config selected, job submission cancelled.", "job.prepare.upload.prompt": "Enable auto uploading of code?", "job.prepare.upload.yes.detail": "The extension will upload your project files to PAI job config's code dir automatically.", @@ -57,6 +66,7 @@ "job.upload.status": "PAI: Uploading code", "job.upload.progress": "PAI: Uploading code - {0} / {1}", "job.upload.error": "Error occurred while uploading code: {0}", + "job.upload.invalid-code-dir": "Auto uploading doesn't support code dir with url scheme hdfs:// or webhdfs://. Please use environment variable $PAI_DEFAULT_FS_URI instead.", "job.request.status": "PAI: Submitting job", "job.submission.error": "Error occurred while submitting job: {0}", "job.submission.success": "Successfully submitted job.", @@ -93,10 +103,18 @@ "treeview.node.edit": "编辑配置...", "treeview.node.openhdfs": "打开 HDFS...", "treeview.node.openPortal": "打开 OpenPAI 门户...", - "treeview.node.listjob": "打开任务列表...", + "treeview.node.listjob": "在浏览器里打开任务列表...", "treeview.node.create-config": "创建任务配置文件...", "treeview.node.submitjob": "提交任务...", "treeview.node.simulate": "模拟任务执行...", + "treeview.hdfs.select-cluster.label": "双击以连接到 PAI 集群的 HDFS...", + "treeview.joblist.recent": "近期从 VS Code 提交的任务", + "treeview.joblist.all": "所有任务", + "treeview.joblist.view": "查看任务详情", + "treeview.joblist.more": "显示更多...", + "treeview.joblist.error": "载入任务列表时发生错误:{0}", + "container.hdfs.mkdir.prompt": "请输入文件夹名", + "container.hdfs.mkdir.cancelled": "新建文件夹操作已取消", "hdfs.workspace.title": "HDFS 浏览器 - {0}", "hdfs.progress": "正在传输 - {0}% ({1} 字节 / {2} 字节)", "hdfs.downloading": "正在下载 {0}", @@ -118,6 +136,7 @@ "job.prepare.status": "PAI: 正在准备提交任务", "job.prepare.cluster.cancelled": "未选择集群,任务提交已被取消。", "job.prepare.config.prompt": "请选择一个 PAI 任务配置 JSON", + "job.prepare.config.invalid": "任务配置文件不合法,任务提交已被取消。", "job.prepare.config.cancelled": "未选择任务配置文件,任务提交已被取消。", "job.prepare.upload.prompt": "是否启用代码自动上传功能?", "job.prepare.upload.yes.detail": "插件将会自动上传你的项目文件至 PAI 任务配置中的 code dir", @@ -128,6 +147,7 @@ "job.upload.status": "PAI: 正在上传代码", "job.upload.progress": "PAI: 代码上传 - {0} / {1}", "job.upload.error": "代码上传时发生错误:{0}", + "job.upload.invalid-code-dir": "自动上传不支持 hdfs:// 及 webhdfs:// 形式的 code dir, 请使用环境变量 $PAI_DEFAULT_FS_URI", "job.submission.name-exist": "提交失败,已存在同名任务,是否启用自动生成任务名称后缀功能?", "job.submission.name-exist.enable": "启用并重新提交", "job.submission.error": "提交任务时发生错误:{0}", diff --git a/contrib/pai_vscode/icons/ellipsis.svg b/contrib/pai_vscode/icons/ellipsis.svg new file mode 100644 index 0000000000..9a76b9fab8 --- /dev/null +++ b/contrib/pai_vscode/icons/ellipsis.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + diff --git a/contrib/pai_vscode/icons/error.svg b/contrib/pai_vscode/icons/error.svg new file mode 100644 index 0000000000..8e08d84186 --- /dev/null +++ b/contrib/pai_vscode/icons/error.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/contrib/pai_vscode/icons/history.svg b/contrib/pai_vscode/icons/history.svg new file mode 100644 index 0000000000..9ef41c37cb --- /dev/null +++ b/contrib/pai_vscode/icons/history.svg @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + diff --git a/contrib/pai_vscode/icons/latest.svg b/contrib/pai_vscode/icons/latest.svg new file mode 100644 index 0000000000..3d882c1878 --- /dev/null +++ b/contrib/pai_vscode/icons/latest.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/contrib/pai_vscode/icons/loading.svg b/contrib/pai_vscode/icons/loading.svg new file mode 100644 index 0000000000..e762f06d5e --- /dev/null +++ b/contrib/pai_vscode/icons/loading.svg @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + diff --git a/contrib/pai_vscode/icons/loading_dark.svg b/contrib/pai_vscode/icons/loading_dark.svg new file mode 100644 index 0000000000..7dc1ebd8cf --- /dev/null +++ b/contrib/pai_vscode/icons/loading_dark.svg @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + diff --git a/contrib/pai_vscode/icons/octicon/file.svg b/contrib/pai_vscode/icons/octicon/file.svg index 1bb5df6e03..f4efbc238b 100644 --- a/contrib/pai_vscode/icons/octicon/file.svg +++ b/contrib/pai_vscode/icons/octicon/file.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/contrib/pai_vscode/icons/octicon/file_dark.svg b/contrib/pai_vscode/icons/octicon/file_dark.svg index 4dc8aa60dd..6abbe9f171 100644 --- a/contrib/pai_vscode/icons/octicon/file_dark.svg +++ b/contrib/pai_vscode/icons/octicon/file_dark.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/contrib/pai_vscode/icons/octicon/home.svg b/contrib/pai_vscode/icons/octicon/home.svg new file mode 100644 index 0000000000..8799a322c1 --- /dev/null +++ b/contrib/pai_vscode/icons/octicon/home.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/contrib/pai_vscode/icons/octicon/home_dark.svg b/contrib/pai_vscode/icons/octicon/home_dark.svg new file mode 100644 index 0000000000..1d033a9d85 --- /dev/null +++ b/contrib/pai_vscode/icons/octicon/home_dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/contrib/pai_vscode/icons/octicon/terminal.svg b/contrib/pai_vscode/icons/octicon/terminal.svg index d1aaf41897..b6df312df4 100644 --- a/contrib/pai_vscode/icons/octicon/terminal.svg +++ b/contrib/pai_vscode/icons/octicon/terminal.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/contrib/pai_vscode/icons/octicon/terminal_dark.svg b/contrib/pai_vscode/icons/octicon/terminal_dark.svg index 5fa29ec4ad..fb45d72a62 100644 --- a/contrib/pai_vscode/icons/octicon/terminal_dark.svg +++ b/contrib/pai_vscode/icons/octicon/terminal_dark.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/contrib/pai_vscode/icons/ok.svg b/contrib/pai_vscode/icons/ok.svg new file mode 100644 index 0000000000..3efeb56727 --- /dev/null +++ b/contrib/pai_vscode/icons/ok.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/contrib/pai_vscode/icons/pai_container.png b/contrib/pai_vscode/icons/pai_container.png new file mode 100644 index 0000000000..b071bcc917 Binary files /dev/null and b/contrib/pai_vscode/icons/pai_container.png differ diff --git a/contrib/pai_vscode/icons/queue.svg b/contrib/pai_vscode/icons/queue.svg new file mode 100644 index 0000000000..a0577b519e --- /dev/null +++ b/contrib/pai_vscode/icons/queue.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/contrib/pai_vscode/icons/run.svg b/contrib/pai_vscode/icons/run.svg new file mode 100644 index 0000000000..a37ceb2579 --- /dev/null +++ b/contrib/pai_vscode/icons/run.svg @@ -0,0 +1 @@ +ProgressBar_16x \ No newline at end of file diff --git a/contrib/pai_vscode/icons/stop.svg b/contrib/pai_vscode/icons/stop.svg new file mode 100644 index 0000000000..6b6668df9c --- /dev/null +++ b/contrib/pai_vscode/icons/stop.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/contrib/pai_vscode/package.json b/contrib/pai_vscode/package.json index 5467611c62..99348c0f93 100644 --- a/contrib/pai_vscode/package.json +++ b/contrib/pai_vscode/package.json @@ -2,7 +2,7 @@ "name": "pai-vscode", "displayName": "OpenPAI VS Code Client", "description": "Interact with Open Platform for AI (OpenPAI) from inside your editor", - "version": "0.1.0", + "version": "0.2.0", "publisher": "OpenPAIVSCodeClient", "preview": true, "icon": "assets/pai_logo.png", @@ -85,9 +85,23 @@ "title": "%paiext.hdfs.download%", "category": "PAI" }, + { + "command": "paiext.container.joblist.refresh", + "title": "%paiext.common.refresh%", + "icon": { + "light": "icons/refresh_light.svg", + "dark": "icons/refresh_dark.svg" + }, + "category": "PAI" + }, + { + "command": "paiext.container.joblist.more", + "title": "%paiext.cluster.job.more%", + "category": "PAI" + }, { "command": "paiext.cluster.refresh", - "title": "%paiext.cluster.refresh%", + "title": "%paiext.common.refresh%", "icon": { "light": "icons/refresh_light.svg", "dark": "icons/refresh_dark.svg" @@ -102,14 +116,58 @@ "dark": "icons/add_dark.svg" }, "category": "PAI" + }, + { + "command": "paiext.container.hdfs.refresh", + "title": "%paiext.common.refresh%", + "icon": { + "light": "icons/refresh_light.svg", + "dark": "icons/refresh_dark.svg" + }, + "category": "PAI" + }, + { + "command": "paiext.container.hdfs.back", + "title": "%paiext.container.hdfs.back%", + "icon": { + "light": "icons/octicon/home.svg", + "dark": "icons/octicon/home_dark.svg" + } + }, + { + "command": "paiext.container.hdfs.delete", + "title": "%paiext.container.hdfs.delete%" + }, + { + "command": "paiext.container.hdfs.mkdir", + "title": "%paiext.container.hdfs.mkdir%" } ], + "viewsContainers": { + "activitybar": [ + { + "id": "PAIContainer", + "title": "%container.title%", + "icon": "icons/pai_container.png" + } + ] + }, "views": { "explorer": [ { "id": "PAIExplorer", "name": "%explorer.paiClusterExplorer%" } + ], + "PAIContainer": [ + { + "id": "PAIContainerHDFS", + "name": "%container.hdfs.title%" + }, + { + "id": "PAIContainerJobList", + "name": "%container.joblist.title%" + } ] }, "menus": { @@ -131,7 +189,11 @@ "when": "false" }, { - "command": "paiext.hdfs.open", + "command": "paiext.container.joblist.refresh", + "when": "false" + }, + { + "command": "paiext.container.joblist.more", "when": "false" }, { @@ -145,6 +207,22 @@ { "command": "paiext.hdfs.download", "when": "false" + }, + { + "command": "paiext.container.hdfs.refresh", + "when": "false" + }, + { + "command": "paiext.container.hdfs.back", + "when": "false" + }, + { + "command": "paiext.container.hdfs.delete", + "when": "false" + }, + { + "command": "paiext.container.hdfs.mkdir", + "when": "false" } ], "view/title": [ @@ -157,6 +235,21 @@ "command": "paiext.cluster.add", "when": "view == PAIExplorer", "group": "navigation" + }, + { + "command": "paiext.container.hdfs.refresh", + "when": "view == PAIContainerHDFS", + "group": "navigation" + }, + { + "command": "paiext.container.hdfs.back", + "when": "view == PAIContainerHDFS", + "group": "navigation" + }, + { + "command": "paiext.container.joblist.refresh", + "when": "view == PAIContainerJobList", + "group": "navigation" } ], "explorer/context": [ @@ -207,6 +300,35 @@ { "command": "paiext.cluster.delete", "when": "view == PAIExplorer && viewItem == PAIConfiguration" + }, + { + "command": "paiext.container.hdfs.mkdir", + "when": "view == PAIContainerHDFS && viewItem && viewItem != PAIHdfsFile", + "group": "1@1" + }, + { + "command": "paiext.hdfs.download", + "when": "view == PAIContainerHDFS && viewItem && viewItem != PAIHdfsRoot", + "group": "2@1" + }, + { + "command": "paiext.hdfs.upload.files", + "when": "view == PAIContainerHDFS && viewItem && viewItem != PAIHdfsFile", + "group": "2@2" + }, + { + "command": "paiext.hdfs.upload.folders", + "when": "view == PAIContainerHDFS && viewItem && viewItem != PAIHdfsFile", + "group": "2@3" + }, + { + "command": "paiext.container.hdfs.delete", + "when": "view == PAIContainerHDFS && viewItem && viewItem != PAIHdfsRoot", + "group": "3@1" + }, + { + "command": "paiext.cluster.job.list", + "when": "view == PAIContainerJobList && viewItem && viewItem == PAIJobListCluster" } ] }, @@ -244,6 +366,30 @@ "type": "boolean", "description": "%config.job.generateJobName.enabled%", "default": null + }, + "pai.job.jobList.recentJobsLength": { + "type": "number", + "description": "%config.job.jobList.recentJobsLength%", + "default": 5 + }, + "pai.job.jobList.allJobsPageSize": { + "type": "number", + "description": "%config.job.jobList.allJobsPageSize%", + "default": 20 + }, + "pai.job.jobList.refreshInterval": { + "type": "number", + "description": "%config.job.jobList.refreshInterval%", + "default": 10 + }, + "pai.hdfs.location": { + "type": "string", + "enum": [ + "sidebar", + "explorer" + ], + "default": "sidebar", + "description": "%config.hdfs.location%" } } } diff --git a/contrib/pai_vscode/package.nls.json b/contrib/pai_vscode/package.nls.json index 73cc7308c5..eabb2210c0 100644 --- a/contrib/pai_vscode/package.nls.json +++ b/contrib/pai_vscode/package.nls.json @@ -1,21 +1,33 @@ { "explorer.paiClusterExplorer": "PAI Cluster Explorer", + "container.title": "Open Platform for AI", + "container.hdfs.title": "HDFS Explorer", + "container.joblist.title": "PAI Job List", + "paiext.common.refresh": "Refresh", "paiext.cluster.add": "Add PAI Cluster", "paiext.cluster.edit": "Edit PAI Cluster Configuration", "paiext.cluster.delete": "Delete PAI Cluster Configuration", - "paiext.cluster.refresh": "Refresh", "paiext.hdfs.open": "Open HDFS", - "paiext.hdfs.upload.files": "Upload Files...", - "paiext.hdfs.upload.folders": "Upload Folders...", - "paiext.hdfs.download": "Download...", + "paiext.hdfs.upload.files": "Upload Files", + "paiext.hdfs.upload.folders": "Upload Folders", + "paiext.hdfs.download": "Download", + "paiext.container.hdfs.back": "Go back to cluster selection", + "paiext.container.hdfs.delete": "Delete", + "paiext.container.hdfs.mkdir": "New Folder", "paiext.cluster.dashboard.open": "Open Dashboard", - "paiext.cluster.job.list": "Open Job List", + "paiext.cluster.job.list": "Open Job List Externally", "paiext.cluster.job.submit": "Submit Job to PAI Cluster", "paiext.cluster.job.create-config": "Create PAI Job Config JSON", "paiext.cluster.job.simulate": "Simulate PAI Job Running", + "paiext.cluster.job.view": "View Job Detail", + "paiext.cluster.job.more": "View More...", "config.title": "OpenPAI VS Code Client Settings", "config.job.upload.enabled": "Controls whether the extension will upload your project files to PAI job config's code dir automatically", "config.job.upload.exclude": "Glob pattern for excluding files and folders", "config.job.upload.include": "Glob pattern for including files and folders", - "config.job.generateJobName.enabled": "Controls whether the extension will add a random suffix to your job name when submitting job" + "config.job.generateJobName.enabled": "Controls whether the extension will add a random suffix to your job name when submitting job", + "config.job.jobList.recentJobsLength": "Controls the number of recently submitted jobs to keep in history for each PAI cluster", + "config.job.jobList.allJobsPageSize": "Controls the page size of list when listing jobs for each PAI cluster", + "config.job.jobList.refreshInterval": "Controls the refresh interval of job list (in seconds)", + "config.hdfs.location": "Location where hdfs explorer will be shown" } \ No newline at end of file diff --git a/contrib/pai_vscode/package.nls.zh-cn.json b/contrib/pai_vscode/package.nls.zh-cn.json index 31135fbb12..7a6ec1fb92 100644 --- a/contrib/pai_vscode/package.nls.zh-cn.json +++ b/contrib/pai_vscode/package.nls.zh-cn.json @@ -1,21 +1,33 @@ { "explorer.paiClusterExplorer": "PAI 集群浏览器", + "container.title": "AI 开发平台 (PAI)", + "container.hdfs.title": "HDFS 浏览器", + "container.joblist.title": "PAI 任务列表", + "paiext.common.refresh": "刷新", "paiext.cluster.add": "添加 PAI 集群", "paiext.cluster.edit": "编辑 PAI 集群配置", "paiext.cluster.delete": "删除 PAI 集群配置", - "paiext.cluster.refresh": "刷新", "paiext.hdfs.open": "打开 HDFS", - "paiext.hdfs.upload.files": "上传文件...", - "paiext.hdfs.upload.folders": "上传文件夹...", - "paiext.hdfs.download": "下载...", + "paiext.hdfs.upload.files": "上传文件", + "paiext.hdfs.upload.folders": "上传文件夹", + "paiext.hdfs.download": "下载", + "paiext.container.hdfs.back": "后退至集群选择", + "paiext.container.hdfs.delete": "删除", + "paiext.container.hdfs.mkdir": "新建文件夹", "paiext.cluster.dashboard.open": "打开仪表板", - "paiext.cluster.job.list": "打开任务列表", + "paiext.cluster.job.list": "在浏览器里打开任务列表", "paiext.cluster.job.submit": "在 PAI 集群上提交任务", "paiext.cluster.job.create-config": "创建 PAI 任务配置 JSON 文件", "paiext.cluster.job.simulate": "模拟 PAI 任务执行", + "paiext.cluster.job.view": "查看任务详情", + "paiext.cluster.job.more": "显示更多...", "config.title": "OpenPAI VS Code 客户端设置", "config.job.upload.enabled": "控制插件是否会自动将项目源代码上传至 PAI 任务配置文件的 CodeDir", "config.job.upload.exclude": "控制排除文件、文件夹的 Glob 模式", "config.job.upload.include": "控制包括文件、文件夹的 Glob 模式", - "config.job.generateJobName.enabled": "控制插件是否会在提交任务时,自动在任务名称后添加随机字符串,以避免重复" + "config.job.generateJobName.enabled": "控制插件是否会在提交任务时,自动在任务名称后添加随机字符串,以避免重复", + "config.job.jobList.recentJobsLength": "控制每个 PAI 集群保留最近提交任务的数量", + "config.job.jobList.allJobsPageSize": "控制 PAI 集群任务列表的分页大小", + "config.job.jobList.refreshInterval": "控制任务列表的刷新间隔(秒)", + "config.hdfs.location": "HDFS 浏览器显示的位置" } \ No newline at end of file diff --git a/contrib/pai_vscode/schemas/pai_job_config.schema.json b/contrib/pai_vscode/schemas/pai_job_config.schema.json index 0e6e2a996d..989c9d36e7 100644 --- a/contrib/pai_vscode/schemas/pai_job_config.schema.json +++ b/contrib/pai_vscode/schemas/pai_job_config.schema.json @@ -1,6 +1,6 @@ { "type": "object", - "description": "PAI job config", + "description": "PAI job config\nThis file can be submitted directly on PAI web portal.", "properties": { "jobName": { "type": "string", diff --git a/contrib/pai_vscode/src/common/constants.ts b/contrib/pai_vscode/src/common/constants.ts index c610763221..49d38dca0b 100644 --- a/contrib/pai_vscode/src/common/constants.ts +++ b/contrib/pai_vscode/src/common/constants.ts @@ -15,21 +15,46 @@ export const COMMAND_HDFS_UPLOAD_FOLDERS = 'paiext.hdfs.upload.folders'; export const COMMAND_HDFS_DOWNLOAD = 'paiext.hdfs.download'; export const COMMAND_OPEN_DASHBOARD = 'paiext.cluster.dashboard.open'; export const COMMAND_LIST_JOB = 'paiext.cluster.job.list'; +export const COMMAND_VIEW_JOB = 'paiext.cluster.job.view'; export const COMMAND_TREEVIEW_OPEN_PORTAL = 'paiext.treeview.openPortal'; export const COMMAND_TREEVIEW_DOUBLECLICK = 'paiext.treeview.doubleclick'; export const COMMAND_SUBMIT_JOB = 'paiext.cluster.job.submit'; export const COMMAND_SIMULATE_JOB = 'paiext.cluster.job.simulate'; export const COMMAND_CREATE_JOB_CONFIG = 'paiext.cluster.job.create-config'; +export const COMMAND_CONTAINER_HDFS_BACK = 'paiext.container.hdfs.back'; +export const COMMAND_CONTAINER_HDFS_REFRESH = 'paiext.container.hdfs.refresh'; +export const COMMAND_CONTAINER_HDFS_DELETE = 'paiext.container.hdfs.delete'; +export const COMMAND_CONTAINER_HDFS_MKDIR = 'paiext.container.hdfs.mkdir'; +export const COMMAND_CONTAINER_JOBLIST_REFRESH = 'paiext.container.joblist.refresh'; +export const COMMAND_CONTAINER_JOBLIST_MORE = 'paiext.container.joblist.more'; export const VIEW_CONFIGURATION_TREE = 'PAIExplorer'; export const CONTEXT_CONFIGURATION_ITEM = 'PAIConfiguration'; -export const CONTEXT_CONFIGURATION_ITEM_WEBPAGE = 'PAIWebpage'; +export const VIEW_CONTAINER_HDFS = 'PAIContainerHDFS'; +export const CONTEXT_HDFS_FILE = 'PAIHdfsFile'; +export const CONTEXT_HDFS_FOLDER = 'PAIHdfsFolder'; +export const CONTEXT_HDFS_ROOT = 'PAIHdfsRoot'; +export const CONTEXT_HDFS_SELECT_CLUSTER_ROOT = 'PAIHdfsSelectRoot'; +export const CONTEXT_HDFS_SELECT_CLUSTER = 'PAIHdfsSelect'; + +export const VIEW_CONTAINER_JOBLIST = 'PAIContainerJobList'; +export const CONTEXT_JOBLIST_CLUSTER = 'PAIJobListCluster'; + +export const SETTING_SECTION_HDFS = 'pai.hdfs'; +export const SETTING_HDFS_EXPLORER_LOCATION = 'location'; +export const ENUM_HDFS_EXPLORER_LOCATION = { + sidebar: 'sidebar', + explorer: 'explorer' +}; export const SETTING_SECTION_JOB = 'pai.job'; export const SETTING_JOB_UPLOAD_ENABLED = 'upload.enabled'; export const SETTING_JOB_UPLOAD_EXCLUDE = 'upload.exclude'; export const SETTING_JOB_UPLOAD_INCLUDE = 'upload.include'; export const SETTING_JOB_GENERATEJOBNAME_ENABLED = 'generateJobName.enabled'; +export const SETTING_JOB_JOBLIST_RECENTJOBSLENGTH = 'jobList.recentJobsLength'; +export const SETTING_JOB_JOBLIST_ALLJOBSPAGESIZE = 'jobList.allJobsPageSize'; +export const SETTING_JOB_JOBLIST_REFERSHINTERVAL = 'jobList.refreshInterval'; export const ICON_PAI = { light: 'icons/PAI_light.png', @@ -57,6 +82,18 @@ export const ICON_CREATE_CONFIG = { light: 'icons/octicon/file.svg', dark: 'icons/octicon/file_dark.svg' }; +export const ICON_QUEUE = 'icons/queue.svg'; +export const ICON_STOP = 'icons/stop.svg'; +export const ICON_ERROR = 'icons/error.svg'; +export const ICON_RUN = 'icons/run.svg'; +export const ICON_OK = 'icons/ok.svg'; +export const ICON_HISTORY = 'icons/history.svg'; +export const ICON_LATEST = 'icons/latest.svg'; +export const ICON_ELLIPSIS = 'icons/ellipsis.svg'; +export const ICON_LOADING = { + light: 'icons/loading.svg', + dark: 'icons/loading_dark.svg' +}; export const OCTICON_CLOUDUPLOAD = '$(cloud-upload)'; diff --git a/contrib/pai_vscode/src/common/singleton.ts b/contrib/pai_vscode/src/common/singleton.ts index 2240d862c8..d3cc09b07c 100644 --- a/contrib/pai_vscode/src/common/singleton.ts +++ b/contrib/pai_vscode/src/common/singleton.ts @@ -6,7 +6,7 @@ import 'reflect-metadata'; // tslint:disable-line -import { Container, injectable } from 'inversify'; +import { injectable, Container } from 'inversify'; import * as vscode from 'vscode'; import { __ } from './i18n'; diff --git a/contrib/pai_vscode/src/common/treeViewHelper.ts b/contrib/pai_vscode/src/common/treeViewHelper.ts new file mode 100644 index 0000000000..525bf8d4f0 --- /dev/null +++ b/contrib/pai_vscode/src/common/treeViewHelper.ts @@ -0,0 +1,47 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License in the project root for license information. + * @author Microsoft + */ + +import { injectable } from 'inversify'; +import { isNil } from 'lodash'; +import { commands, workspace } from 'vscode'; + +import { COMMAND_TREEVIEW_DOUBLECLICK } from '../common/constants'; +import { __ } from '../common/i18n'; +import { Singleton } from '../common/singleton'; + +/** + * Supports double click commands for tree view + */ +@injectable() +export class TreeViewHelper extends Singleton { + + private lastClick?: { command: string, time: number }; + private readonly doubleClickInterval: number = 300; + + constructor() { + super(); + this.context.subscriptions.push( + commands.registerCommand(COMMAND_TREEVIEW_DOUBLECLICK, (command: string, ...args: string[]) => { + const mode: string | undefined = workspace.getConfiguration('workbench.list').get('openMode'); + if (mode === 'doubleClick') { + void commands.executeCommand(command, ...args); + } else { + // Single Click + if ( + !isNil(this.lastClick) && + this.lastClick.command === command && + Date.now() - this.lastClick.time < this.doubleClickInterval + ) { + this.lastClick = undefined; + void commands.executeCommand(command, ...args); + } else { + this.lastClick = { command, time: Date.now() }; + } + } + }) + ); + } +} \ No newline at end of file diff --git a/contrib/pai_vscode/src/extension.ts b/contrib/pai_vscode/src/extension.ts index dcc3bc919c..138e3b37de 100644 --- a/contrib/pai_vscode/src/extension.ts +++ b/contrib/pai_vscode/src/extension.ts @@ -5,6 +5,7 @@ */ import * as vscode from 'vscode'; + import * as Singleton from './common/singleton'; import { allSingletonClasses } from './root'; diff --git a/contrib/pai_vscode/src/pai/clusterManager.ts b/contrib/pai_vscode/src/pai/clusterManager.ts index 21382072bc..38dcaa48e4 100644 --- a/contrib/pai_vscode/src/pai/clusterManager.ts +++ b/contrib/pai_vscode/src/pai/clusterManager.ts @@ -10,20 +10,28 @@ import * as request from 'request-promise-native'; import * as vscode from 'vscode'; import { - COMMAND_ADD_CLUSTER, COMMAND_DELETE_CLUSTER, COMMAND_EDIT_CLUSTER -} from '../common/constants'; + COMMAND_ADD_CLUSTER, COMMAND_DELETE_CLUSTER, COMMAND_EDIT_CLUSTER} from '../common/constants'; import { __ } from '../common/i18n'; import { getSingleton, Singleton } from '../common/singleton'; import { Util } from '../common/util'; -import { ConfigurationNode, ConfigurationTreeDataProvider } from './configurationTreeDataProvider'; + +import { ClusterExplorerChildNode, ConfigurationTreeDataProvider, ITreeData } from './configurationTreeDataProvider'; import { IPAICluster } from './paiInterface'; import semverCompare = require('semver-compare'); // tslint:disable-line + export interface IConfiguration { readonly version: string; pais: IPAICluster[]; } +export type IClusterModification = { + index: number; + type: 'EDIT' | 'REMOVE'; +} | { + type: 'RESET'; +}; + export function getClusterIdentifier(conf: IPAICluster): string { return `${conf.username}@${conf.rest_server_uri}`; } @@ -32,10 +40,6 @@ export function getClusterName(conf: IPAICluster): string { return conf.name || getClusterIdentifier(conf); } -export function getClusterWebPortalUri(conf: IPAICluster): string { - return conf.web_portal_uri || conf.rest_server_uri.split(':')[0]; -} - /** * Manager class for cluster configurations */ @@ -57,6 +61,9 @@ export class ClusterManager extends Singleton { k8s_dashboard_uri: '127.0.0.1:9090' }; + private onDidChangeEmitter: vscode.EventEmitter = new vscode.EventEmitter(); + public onDidChange: vscode.Event = this.onDidChangeEmitter.event; // tslint:disable-line + private readonly EDIT: string = __('common.edit'); private readonly DISCARD: string = __('cluster.activate.fix.discard'); @@ -65,8 +72,20 @@ export class ClusterManager extends Singleton { public async onActivate(): Promise { this.context.subscriptions.push( vscode.commands.registerCommand(COMMAND_ADD_CLUSTER, () => this.add()), - vscode.commands.registerCommand(COMMAND_EDIT_CLUSTER, (node: ConfigurationNode) => this.edit(node.index)), - vscode.commands.registerCommand(COMMAND_DELETE_CLUSTER, (node: ConfigurationNode) => this.delete(node.index)) + vscode.commands.registerCommand(COMMAND_EDIT_CLUSTER, async (node: ClusterExplorerChildNode | ITreeData) => { + if (node instanceof ClusterExplorerChildNode) { + await this.edit(node.index); + } else { + await this.edit(node.clusterIndex); + } + }), + vscode.commands.registerCommand(COMMAND_DELETE_CLUSTER, async (node: ClusterExplorerChildNode | ITreeData) => { + if (node instanceof ClusterExplorerChildNode) { + await this.delete(node.index); + } else { + await this.delete(node.clusterIndex); + } + }) ); this.configuration = this.context.globalState.get(ClusterManager.CONF_KEY) || ClusterManager.default; try { @@ -126,7 +145,7 @@ export class ClusterManager extends Singleton { cluster.rest_server_uri = `${host}/rest-server`; cluster.k8s_dashboard_uri = `${host}/kubernetes-dashboard`; cluster.grafana_uri = `${host}/grafana`; - cluster.web_portal_uri = `${host}/`; + cluster.web_portal_uri = `${host}`; cluster.hdfs_uri = `hdfs://${host}:9000`; cluster.webhdfs_uri = `${host}/webhdfs/api/v1`; } catch { @@ -138,39 +157,40 @@ export class ClusterManager extends Singleton { cluster.hdfs_uri = `hdfs://${host}:9000`; cluster.k8s_dashboard_uri = `${host}:9090`; } - return this.edit(this.configuration!.pais.length, cluster); + return this.edit(this.allConfigurations.length, cluster); } public async edit(index: number, defaultConfiguration: IPAICluster = ClusterManager.paiDefault): Promise { - const original: IPAICluster = this.configuration!.pais[index] || defaultConfiguration; + const original: IPAICluster = this.allConfigurations[index] || defaultConfiguration; const editResult: IPAICluster | undefined = await Util.editJSON( original, `pai_cluster_${original.rest_server_uri}.json`, 'pai_cluster.schema.json' ); if (editResult) { - this.configuration!.pais[index] = editResult; - await (await getSingleton(ConfigurationTreeDataProvider)).refresh(index); + this.allConfigurations[index] = editResult; + this.onDidChangeEmitter.fire({ type: 'EDIT', index }); await this.save(); } } public async delete(index: number): Promise { - this.configuration!.pais.splice(index, 1); - await (await getSingleton(ConfigurationTreeDataProvider)).refresh(); + this.allConfigurations.splice(index, 1); + this.onDidChangeEmitter.fire({ type: 'REMOVE', index }); await this.save(); } - public save(): PromiseLike { - return this.context.globalState.update(ClusterManager.CONF_KEY, this.configuration!); + public async save(): Promise { + await this.context.globalState.update(ClusterManager.CONF_KEY, this.configuration!); + await (await getSingleton(ConfigurationTreeDataProvider)).refresh(); } public async pick(): Promise { - if (this.configuration!.pais.length === 1) { + if (this.allConfigurations.length === 1) { return 0; } - return await Util.pick(range(this.configuration!.pais.length), __('cluster.pick.prompt'), (index: number) => { - const conf: IPAICluster = this.allConfigurations![index]; + return await Util.pick(range(this.allConfigurations.length), __('cluster.pick.prompt'), (index: number) => { + const conf: IPAICluster = this.allConfigurations[index]; return { label: getClusterName(conf), detail: getClusterIdentifier(conf) @@ -195,7 +215,6 @@ export class ClusterManager extends Singleton { ); if (editResult) { this.configuration = editResult; - await (await getSingleton(ConfigurationTreeDataProvider)).refresh(); await this.save(); } break; @@ -205,5 +224,6 @@ export class ClusterManager extends Singleton { default: break; } + this.onDidChangeEmitter.fire({ type: 'RESET' }); } } \ No newline at end of file diff --git a/contrib/pai_vscode/src/pai/configurationTreeDataProvider.ts b/contrib/pai_vscode/src/pai/configurationTreeDataProvider.ts index 90b2c73f42..f9b8e2a87e 100644 --- a/contrib/pai_vscode/src/pai/configurationTreeDataProvider.ts +++ b/contrib/pai_vscode/src/pai/configurationTreeDataProvider.ts @@ -5,69 +5,43 @@ */ import { injectable } from 'inversify'; -import { isNil } from 'lodash'; import { - commands, Event, EventEmitter, TreeDataProvider, TreeItem, TreeItemCollapsibleState, window, - workspace + commands, window, + Event, EventEmitter, TreeDataProvider, TreeItem, TreeItemCollapsibleState } from 'vscode'; import { COMMAND_CREATE_JOB_CONFIG, COMMAND_EDIT_CLUSTER, COMMAND_LIST_JOB, COMMAND_OPEN_HDFS, COMMAND_REFRESH_CLUSTER, COMMAND_SIMULATE_JOB, COMMAND_SUBMIT_JOB, COMMAND_TREEVIEW_DOUBLECLICK, COMMAND_TREEVIEW_OPEN_PORTAL, - CONTEXT_CONFIGURATION_ITEM, CONTEXT_CONFIGURATION_ITEM_WEBPAGE, + CONTEXT_CONFIGURATION_ITEM, ICON_CREATE_CONFIG, ICON_DASHBOARD, ICON_EDIT, ICON_HDFS, ICON_LIST_JOB, ICON_PAI, ICON_SIMULATE_JOB, ICON_SUBMIT_JOB, VIEW_CONFIGURATION_TREE } from '../common/constants'; import { __ } from '../common/i18n'; import { getSingleton, Singleton } from '../common/singleton'; import { Util } from '../common/util'; -import { ClusterManager, getClusterName } from './clusterManager'; + +import { getClusterName, ClusterManager } from './clusterManager'; import { IPAICluster } from './paiInterface'; interface IChildNodeDefinition { title: string; command: string; icon: string | { light: string, dark: string }; - type?: { new(...args: any[]): TreeNode }; condition?(conf: IPAICluster): boolean; } -/** - * General tree node recording its parent - */ -class TreeNode extends TreeItem { - constructor(title: string, public readonly parent?: TreeNode) { - super(title, parent ? TreeItemCollapsibleState.None : TreeItemCollapsibleState.Expanded); - } -} - -/** - * Tree node representing external link - */ -class TreeNodeWithLink extends TreeNode { - constructor(title: string, parent?: TreeNode) { - super(title, parent); - this.contextValue = CONTEXT_CONFIGURATION_ITEM_WEBPAGE; - } - - public get realCommand(): string | undefined { - return this.command && this.command.arguments && this.command.arguments[1]; - } -} - const childNodeDefinitions: IChildNodeDefinition[] = [ { title: 'treeview.node.openPortal', command: COMMAND_TREEVIEW_OPEN_PORTAL, - icon: ICON_DASHBOARD, - type: TreeNodeWithLink + icon: ICON_DASHBOARD }, { title: 'treeview.node.listjob', command: COMMAND_LIST_JOB, - icon: ICON_LIST_JOB, - type: TreeNodeWithLink + icon: ICON_LIST_JOB }, { title: 'treeview.node.create-config', @@ -97,43 +71,40 @@ const childNodeDefinitions: IChildNodeDefinition[] = [ } ]; +export interface ITreeData { + clusterIndex: number; + childDef?: IChildNodeDefinition; +} + /** * Root nodes representing cluster configuration */ -export class ConfigurationNode extends TreeNode { - public children: TreeNode[] = []; +export class ClusterExplorerRootNode extends TreeItem { + public readonly index: number; - public constructor(private _configuration: IPAICluster, public readonly index: number) { - super('...'); + public constructor(configuration: IPAICluster, index: number) { + super(getClusterName(configuration), TreeItemCollapsibleState.Expanded); this.iconPath = Util.resolvePath(ICON_PAI); - this.configuration = this._configuration; + this.index = index; this.contextValue = CONTEXT_CONFIGURATION_ITEM; } +} - public get configuration(): IPAICluster { - return this._configuration; - } - public set configuration(to: IPAICluster) { - this.label = getClusterName(to); - this._configuration = to; - this.initializeChildren(); - } - - private initializeChildren(): void { - this.children = []; - for (const def of childNodeDefinitions) { - if (def.condition && !def.condition(this.configuration)) { - continue; - } - const node: TreeNode = new (def.type || TreeNode)(__(def.title), this); - node.command = { - title: __(def.title), - command: COMMAND_TREEVIEW_DOUBLECLICK, - arguments: [this, def.command] - }; - node.iconPath = Util.resolvePath(def.icon); - this.children.push(node); - } +/** + * Child nodes representing operation + */ +export class ClusterExplorerChildNode extends TreeItem { + public readonly index: number; + + public constructor(clusterIndex: number, def: IChildNodeDefinition) { + super(__(def.title), TreeItemCollapsibleState.None); + this.iconPath = Util.resolvePath(def.icon); + this.index = clusterIndex; + this.command = { + title: __(def.title), + command: COMMAND_TREEVIEW_DOUBLECLICK, + arguments: [def.command, this] + }; } } @@ -141,71 +112,54 @@ export class ConfigurationNode extends TreeNode { * Contributes to the tree view of cluster configurations */ @injectable() -export class ConfigurationTreeDataProvider extends Singleton implements TreeDataProvider { - private onDidChangeTreeDataEmitter: EventEmitter = new EventEmitter(); - public onDidChangeTreeData: Event = this.onDidChangeTreeDataEmitter.event; // tslint:disable-line - - private configurationNodes: ConfigurationNode[] = []; - private lastClick?: { command: string, time: number }; - private readonly doubleClickInterval: number = 300; +export class ConfigurationTreeDataProvider extends Singleton implements TreeDataProvider { + private onDidChangeTreeDataEmitter: EventEmitter = new EventEmitter(); + public onDidChangeTreeData: Event = this.onDidChangeTreeDataEmitter.event; // tslint:disable-line - constructor() { - super(); - this.context.subscriptions.push( - commands.registerCommand(COMMAND_REFRESH_CLUSTER, index => this.refresh(index)), - commands.registerCommand(COMMAND_TREEVIEW_DOUBLECLICK, (node: TreeNode, command: string) => { - const mode: string | undefined = workspace.getConfiguration('workbench.list').get('openMode'); - if (mode === 'doubleClick') { - void commands.executeCommand(command, node); - } else { - // Single Click - if ( - !isNil(this.lastClick) && - this.lastClick.command === command && - Date.now() - this.lastClick.time < this.doubleClickInterval - ) { - this.lastClick = undefined; - void commands.executeCommand(command, node); - } else { - this.lastClick = { command, time: Date.now() }; - } - } - }), - window.registerTreeDataProvider(VIEW_CONFIGURATION_TREE, this) - ); + public async refresh(): Promise { + this.onDidChangeTreeDataEmitter.fire(); } - public async refresh(index: number = -1): Promise { - const allConfigurations: IPAICluster[] = (await getSingleton(ClusterManager)).allConfigurations; - if (index === -1 || !this.configurationNodes[index]) { - this.configurationNodes = allConfigurations.map((conf, i) => new ConfigurationNode(conf, i)); - this.onDidChangeTreeDataEmitter.fire(); + public async getTreeItem(data: ITreeData): Promise { + if (!data.childDef) { + const cluster: IPAICluster = (await getSingleton(ClusterManager)).allConfigurations[data.clusterIndex]; + return new ClusterExplorerRootNode(cluster, data.clusterIndex); } else { - this.configurationNodes[index].configuration = allConfigurations[index]; - this.onDidChangeTreeDataEmitter.fire(this.configurationNodes[index]); + return new ClusterExplorerChildNode(data.clusterIndex, data.childDef); } } - public getTreeItem(element: TreeNode): TreeNode { - return element; - } - - public getChildren(element?: TreeNode): TreeNode[] | undefined { - if (!element) { - // Root nodes: configurations - return this.configurationNodes; - } - if (element instanceof ConfigurationNode) { - return element.children; + public async getChildren(data?: ITreeData): Promise { + if (!data) { + const allConfigurations: IPAICluster[] = (await getSingleton(ClusterManager)).allConfigurations; + return allConfigurations.map((c, i) => ({ + clusterIndex: i + })); + } else if (!data.childDef) { + const cluster: IPAICluster = (await getSingleton(ClusterManager)).allConfigurations[data.clusterIndex]; + return childNodeDefinitions.filter((def) => !def.condition || def.condition(cluster)).map(def => ({ + clusterIndex: data.clusterIndex, + childDef: def + })); + } else { + return undefined; } - return; } - public getParent(element: TreeNode): TreeNode | undefined { - return element.parent; + public getParent(data: ITreeData): ITreeData | undefined { + if (data.childDef) { + return { + clusterIndex: data.clusterIndex + }; + } else { + return undefined; + } } - public onActivate(): Promise { - return this.refresh(); + public async onActivate(): Promise { + this.context.subscriptions.push( + commands.registerCommand(COMMAND_REFRESH_CLUSTER, () => this.refresh()), + window.registerTreeDataProvider(VIEW_CONFIGURATION_TREE, this) + ); } } \ No newline at end of file diff --git a/contrib/pai_vscode/src/pai/container/hdfsTreeView.ts b/contrib/pai_vscode/src/pai/container/hdfsTreeView.ts new file mode 100644 index 0000000000..6639da7912 --- /dev/null +++ b/contrib/pai_vscode/src/pai/container/hdfsTreeView.ts @@ -0,0 +1,218 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License in the project root for license information. + * @author Microsoft + */ +/* tslint:disable:max-classes-per-file */ + +import { injectable } from 'inversify'; +import { + commands, window, Event, EventEmitter, FileType, + TreeDataProvider, TreeItem, TreeItemCollapsibleState, + TreeView, Uri +} from 'vscode'; + +import { + COMMAND_CONTAINER_HDFS_BACK, COMMAND_CONTAINER_HDFS_DELETE, COMMAND_CONTAINER_HDFS_MKDIR, COMMAND_CONTAINER_HDFS_REFRESH, + COMMAND_OPEN_HDFS, COMMAND_TREEVIEW_DOUBLECLICK, + CONTEXT_HDFS_FILE, CONTEXT_HDFS_FOLDER, CONTEXT_HDFS_ROOT, CONTEXT_HDFS_SELECT_CLUSTER, CONTEXT_HDFS_SELECT_CLUSTER_ROOT, + ICON_PAI, VIEW_CONTAINER_HDFS +} from '../../common/constants'; +import { __ } from '../../common/i18n'; +import { getSingleton, Singleton } from '../../common/singleton'; +import { Util } from '../../common/util'; + +import { getClusterName, ClusterManager } from '../clusterManager'; +import { HDFS, HDFSFileSystemProvider } from '../hdfs'; +import { IPAICluster } from '../paiInterface'; + +type IFileList = [string, FileType][]; + +/** + * Abstract tree node + */ +abstract class TreeNode extends TreeItem { + public parent?: TreeNode; +} + +/** + * File node + */ +class FileNode extends TreeNode { + public readonly contextValue: string = CONTEXT_HDFS_FILE; + constructor(uri: Uri, parent: TreeNode) { + super(uri, TreeItemCollapsibleState.None); + this.parent = parent; + } +} + +/** + * Folder node + */ +class FolderNode extends TreeNode { + public readonly contextValue: string = CONTEXT_HDFS_FOLDER; + constructor(uri: Uri, parent: TreeNode) { + super(uri, TreeItemCollapsibleState.Collapsed); + this.parent = parent; + } +} + +/** + * Root node + */ +class RootNode extends TreeNode { + public readonly contextValue: string = CONTEXT_HDFS_ROOT; + constructor(uri: Uri) { + super(uri, TreeItemCollapsibleState.Expanded); + this.label = uri.toString(); + this.iconPath = Util.resolvePath(ICON_PAI); + } +} + +/** + * Cluster root node + */ +class SelectClusterRootNode extends TreeNode { + public readonly contextValue: string = CONTEXT_HDFS_SELECT_CLUSTER_ROOT; + constructor() { + super(__('treeview.hdfs.select-cluster.label'), TreeItemCollapsibleState.Expanded); + } +} + +/** + * Cluster node (when no cluster is selected) + */ +class SelectClusterNode extends TreeNode { + public readonly contextValue: string = CONTEXT_HDFS_SELECT_CLUSTER; + public readonly cluster: IPAICluster; + constructor(cluster: IPAICluster, parent: TreeNode) { + super(getClusterName(cluster)); + this.cluster = cluster; + this.parent = parent; + this.command = { + title: __('treeview.node.openhdfs'), + command: COMMAND_TREEVIEW_DOUBLECLICK, + arguments: [COMMAND_OPEN_HDFS, this.cluster] + }; + this.iconPath = Util.resolvePath(ICON_PAI); + } +} + +/** + * Contributes to the tree view of cluster configurations + */ +@injectable() +export class HDFSTreeDataProvider extends Singleton implements TreeDataProvider { + public readonly view: TreeView; + public root: TreeNode; + + private onDidChangeTreeDataEmitter: EventEmitter = new EventEmitter(); + public onDidChangeTreeData: Event = this.onDidChangeTreeDataEmitter.event; // tslint:disable-line + + private uri?: Uri; + + constructor() { + super(); + this.root = new SelectClusterRootNode(); + this.view = window.createTreeView(VIEW_CONTAINER_HDFS, { treeDataProvider: this }); + } + + public async onActivate(): Promise { + this.context.subscriptions.push( + commands.registerCommand(COMMAND_CONTAINER_HDFS_REFRESH, () => this.refresh()), + commands.registerCommand(COMMAND_CONTAINER_HDFS_BACK, () => this.reset()), + commands.registerCommand(COMMAND_CONTAINER_HDFS_DELETE, async (node: TreeItem) => { + await (await getSingleton(HDFS)).provider!.delete(node.resourceUri!, { recursive: true }); + }), + commands.registerCommand(COMMAND_CONTAINER_HDFS_MKDIR, async (node: TreeItem) => { + const res: string | undefined = await window.showInputBox({ + prompt: __('container.hdfs.mkdir.prompt') + }); + if (res === undefined) { + Util.warn('container.hdfs.mkdir.cancelled'); + } else { + await (await getSingleton(HDFS)).provider!.createDirectory(Util.uriPathAppend(node.resourceUri!, res)); + } + }) + ); + this.refresh(); + (await getSingleton(HDFS)).provider!.onDidChangeFile(() => this.refresh()); + } + + public reset(): void { + this.uri = undefined; + this.root = new SelectClusterRootNode(); + this.refresh(); + void this.view.reveal(this.root); + } + + public setUri(uri?: Uri): void { + if (uri === undefined) { + this.reset(); + } else { + this.uri = uri; + this.root = new RootNode(this.uri); + this.refresh(); + void this.view.reveal(this.root); + } + } + + public refresh(node?: TreeNode): void { + this.onDidChangeTreeDataEmitter.fire(node); + } + + public getTreeItem(element: TreeNode): TreeNode { + return element; + } + + public async getChildren(element?: TreeNode): Promise { + if (!this.uri) { + if (!element) { + return [this.root]; + } else if (element.contextValue === CONTEXT_HDFS_SELECT_CLUSTER_ROOT) { + const allConfigurations: IPAICluster[] = (await getSingleton(ClusterManager)).allConfigurations; + return allConfigurations.filter( + conf => !!conf.webhdfs_uri + ).map( + conf => new SelectClusterNode(conf, element) + ); + } else { + return; + } + } else { + if (!element) { + return [this.root]; + } else if (element.contextValue === CONTEXT_HDFS_FOLDER || element.contextValue === CONTEXT_HDFS_ROOT) { + const provider: HDFSFileSystemProvider | undefined = (await getSingleton(HDFS)).provider; + if (!provider) { + return; + } + const uri: Uri = element.resourceUri!; + const res: IFileList = await provider.readDirectory(uri); + return [ + ...res.filter( + ([name, type]) => type === FileType.Directory + ).sort( + ([name1, type1], [name2, type2]) => name1.localeCompare(name2) + ).map( + ([name, type]) => new FolderNode(Util.uriPathAppend(uri, name), element) + ), + ...res.filter( + ([name, type]) => type === FileType.File + ).sort( + ([name1, type1], [name2, type2]) => name1.localeCompare(name2) + ).map( + ([name, type]) => new FileNode(Util.uriPathAppend(uri, name), element) + ) + ]; + } else { + return; + } + + } + } + + public getParent(element: TreeNode): TreeNode | undefined { + return element.parent; + } +} \ No newline at end of file diff --git a/contrib/pai_vscode/src/pai/container/jobListTreeView.ts b/contrib/pai_vscode/src/pai/container/jobListTreeView.ts new file mode 100644 index 0000000000..60f8390d5b --- /dev/null +++ b/contrib/pai_vscode/src/pai/container/jobListTreeView.ts @@ -0,0 +1,360 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License in the project root for license information. + * @author Microsoft + */ +/* tslint:disable:max-classes-per-file */ + +import { injectable } from 'inversify'; +import * as request from 'request-promise-native'; +import { + commands, window, workspace, Event, EventEmitter, TreeDataProvider, + TreeItem, TreeItemCollapsibleState, TreeView, WorkspaceConfiguration +} from 'vscode'; + +import { + COMMAND_CONTAINER_JOBLIST_MORE, COMMAND_CONTAINER_JOBLIST_REFRESH, + COMMAND_TREEVIEW_DOUBLECLICK, COMMAND_VIEW_JOB, + CONTEXT_JOBLIST_CLUSTER, + ICON_ELLIPSIS, + ICON_ERROR, + ICON_HISTORY, + ICON_LATEST, + ICON_LOADING, + ICON_OK, + ICON_PAI, + ICON_QUEUE, + ICON_RUN, + ICON_STOP, + SETTING_JOB_JOBLIST_ALLJOBSPAGESIZE, + SETTING_JOB_JOBLIST_RECENTJOBSLENGTH, + SETTING_JOB_JOBLIST_REFERSHINTERVAL, + SETTING_SECTION_JOB, + VIEW_CONTAINER_JOBLIST +} from '../../common/constants'; +import { __ } from '../../common/i18n'; +import { getSingleton, Singleton } from '../../common/singleton'; +import { Util } from '../../common/util'; +import { getClusterName, ClusterManager } from '../clusterManager'; +import { IPAICluster, IPAIJobInfo } from '../paiInterface'; +import { PAIRestUri } from '../paiUri'; +import { RecentJobManager } from '../recentJobManager'; + +enum FilterType { + Recent = 0, + All = 1 +} + +enum LoadingState { + Finished = 0, + Loading = 1, + Error = 2 +} + +enum TreeDataType { + Cluster = 0, + Filter = 1, + Job = 2, + More = 3 +} + +/** + * Leaf node representing job on PAI + */ +export class JobNode extends TreeItem { + private static statusIcons: { [status in IPAIJobInfo['state']]: string | undefined } = { + SUCCEEDED: ICON_OK, + FAILED: ICON_ERROR, + WAITING: ICON_QUEUE, + STOPPED: ICON_STOP, + RUNNING: ICON_RUN, + UNKNOWN: undefined + }; + + public constructor(jobInfo: IPAIJobInfo, config: IPAICluster) { + super(jobInfo.name); + this.command = { + title: __('treeview.joblist.view'), + command: COMMAND_TREEVIEW_DOUBLECLICK, + arguments: [COMMAND_VIEW_JOB, jobInfo, config] + }; + const icon: string | undefined = JobNode.statusIcons[jobInfo.state]; + if (icon) { + this.iconPath = Util.resolvePath(icon); + } + } +} + +/** + * Expand job list when chosen + */ +class ShowMoreNode extends TreeItem { + public constructor(cluster: IClusterData) { + super(__('treeview.joblist.more')); + this.command = { + title: __('treeview.joblist.more'), + command: COMMAND_TREEVIEW_DOUBLECLICK, + arguments: [COMMAND_CONTAINER_JOBLIST_MORE, cluster] + }; + this.iconPath = Util.resolvePath(ICON_ELLIPSIS); + } +} + +/** + * Secondary node containing filtered job list + */ +class FilterNode extends TreeItem { + public constructor(type: FilterType, loadingState: LoadingState) { + if (type === FilterType.Recent) { + super(__('treeview.joblist.recent'), TreeItemCollapsibleState.Expanded); + } else { + super(__('treeview.joblist.all'), TreeItemCollapsibleState.Collapsed); + } + this.iconPath = Util.resolvePath( + loadingState === LoadingState.Loading ? ICON_LOADING : + loadingState === LoadingState.Error ? ICON_ERROR : + type === FilterType.Recent ? ICON_LATEST : ICON_HISTORY); + } +} + +/** + * Root node representing PAI cluster + */ +export class ClusterNode extends TreeItem { + public readonly index: number; + public constructor(configuration: IPAICluster, index: number) { + super(getClusterName(configuration), TreeItemCollapsibleState.Collapsed); + this.index = index; + this.iconPath = Util.resolvePath(ICON_PAI); + this.contextValue = CONTEXT_JOBLIST_CLUSTER; + } +} + +interface IClusterData { + type: TreeDataType.Cluster; + config: IPAICluster; + index: number; + shownAmount: number; + loadingState: LoadingState; + jobs: IPAIJobInfo[]; + lastShownAmount?: number; +} + +interface IFilterData { + type: TreeDataType.Filter; + filterType: FilterType; + clusterIndex: number; +} + +interface IJobData { + type: TreeDataType.Job; + job: IPAIJobInfo; + clusterIndex: number; + filterType: FilterType; +} + +interface IMoreData { + type: TreeDataType.More; + clusterIndex: number; +} + +type ITreeData = IClusterData | IFilterData | IJobData | IMoreData; + +/** + * Contributes to the tree view of cluster job list + */ +@injectable() +export class JobListTreeDataProvider extends Singleton implements TreeDataProvider { + private onDidChangeTreeDataEmitter: EventEmitter = new EventEmitter(); + public onDidChangeTreeData: Event = this.onDidChangeTreeDataEmitter.event; // tslint:disable-line + + private clusters: IClusterData[] = []; + private readonly treeView: TreeView; + private refreshTimer: NodeJS.Timer | undefined; + + constructor() { + super(); + this.treeView = window.createTreeView(VIEW_CONTAINER_JOBLIST, { treeDataProvider: this }); + this.context.subscriptions.push( + commands.registerCommand(COMMAND_CONTAINER_JOBLIST_REFRESH, () => this.refresh()), + commands.registerCommand( + COMMAND_CONTAINER_JOBLIST_MORE, + (cluster: IClusterData) => { + if (cluster.jobs.length <= cluster.shownAmount) { + return; + } + const settings: WorkspaceConfiguration = workspace.getConfiguration(SETTING_SECTION_JOB); + cluster.lastShownAmount = cluster.shownAmount; + cluster.shownAmount += settings.get(SETTING_JOB_JOBLIST_ALLJOBSPAGESIZE)!; + void this.refresh(cluster.index, false); + } + ) + ); + } + + public async refresh(index: number = -1, reload: boolean = true): Promise { + if (index === -1 || !this.clusters[index]) { + const settings: WorkspaceConfiguration = workspace.getConfiguration(SETTING_SECTION_JOB); + const allConfigurations: IPAICluster[] = (await getSingleton(ClusterManager)).allConfigurations; + this.clusters = allConfigurations.map((config, i) => ({ + type: TreeDataType.Cluster, + index: i, + config, + loadingState: LoadingState.Finished, + jobs: [], + shownAmount: settings.get(SETTING_JOB_JOBLIST_ALLJOBSPAGESIZE)! + })); + this.onDidChangeTreeDataEmitter.fire(); + if (reload) { + void this.reloadJobs(); + } + } else { + this.onDidChangeTreeDataEmitter.fire(this.clusters[index]); + if (reload) { + void this.reloadJobs(index); + } + } + } + + public getTreeItem(element: ITreeData): TreeItem { + switch (element.type) { + case TreeDataType.Cluster: + return new ClusterNode(element.config, element.index); + case TreeDataType.Filter: + return new FilterNode(element.filterType, this.clusters[element.clusterIndex].loadingState); + case TreeDataType.Job: + return new JobNode(element.job, this.clusters[element.clusterIndex].config); + case TreeDataType.More: + return new ShowMoreNode(this.clusters[element.clusterIndex]); + default: + throw new Error('Unexpected node type'); + } + } + + public async getChildren(element?: ITreeData): Promise { + if (!element) { + // Root nodes: configurations + return this.clusters; + } + switch (element.type) { + case TreeDataType.Cluster: + { + return [ + { type: TreeDataType.Filter, filterType: FilterType.Recent, clusterIndex: element.index }, + { type: TreeDataType.Filter, filterType: FilterType.All, clusterIndex: element.index } + ]; + } + case TreeDataType.Filter: + if (element.filterType === FilterType.Recent) { + const cluster: IClusterData = this.clusters[element.clusterIndex]; + const settings: WorkspaceConfiguration = workspace.getConfiguration(SETTING_SECTION_JOB); + const recentMaxLen: number = settings.get(SETTING_JOB_JOBLIST_RECENTJOBSLENGTH)!; + const recentJobs: string[] | undefined = (await getSingleton(RecentJobManager)).allRecentJobs[cluster.index] || []; + const result: IJobData[] = []; + for (const name of recentJobs.slice(0, recentMaxLen)) { + const foundJob: IPAIJobInfo | undefined = cluster.jobs.find(job => job.name === name); + if (foundJob) { + result.push({ + type: TreeDataType.Job, + job: foundJob, + clusterIndex: element.clusterIndex, + filterType: element.filterType + }); + } + } + return result; + } else { + const cluster: IClusterData = this.clusters[element.clusterIndex]; + const result: (IJobData | IMoreData)[] = cluster.jobs.slice(0, cluster.shownAmount).map( + job => ({ + type: TreeDataType.Job, + job, + clusterIndex: element.clusterIndex, + filterType: element.filterType + }) + ); + if (cluster.lastShownAmount && cluster.lastShownAmount !== cluster.shownAmount) { + setImmediate(i => this.treeView.reveal(result[i]), cluster.lastShownAmount - 1); + cluster.lastShownAmount = cluster.shownAmount; + } + if (cluster.jobs.length > cluster.shownAmount) { + result.push({ type: TreeDataType.More, clusterIndex: element.clusterIndex }); + } + return result; + } + case TreeDataType.Job: + case TreeDataType.More: + return undefined; + default: + } + } + + public getParent(element: ITreeData): ITreeData | undefined { + if (element.type === TreeDataType.Job) { + return { + type: TreeDataType.Filter, + filterType: element.filterType, + clusterIndex: element.clusterIndex + }; + } + if (element.type === TreeDataType.Filter) { + return this.clusters[element.clusterIndex]; + } + } + + public async revealLatestJob(clusterIndex: number, jobName: string): Promise { + const job: IPAIJobInfo | undefined = this.clusters[clusterIndex].jobs.find(j => j.name === jobName); + if (job) { + /** + * Note: treeView.reveal() will obtain the node's parent and + * traverse to ancestors (upwards) until a expanded node has been found, + * then go back down and expand nodes via getChildren() on demand + */ + await this.treeView.reveal( + { + type: TreeDataType.Job, + job: job, + clusterIndex: clusterIndex, + filterType: FilterType.Recent + }, + { focus: true } + ); + } + } + + public onActivate(): Promise { + return this.refresh(); + } + + public async onDeactivate(): Promise { + if (this.refreshTimer) { + clearTimeout(this.refreshTimer); + } + this.treeView.dispose(); + } + + private async reloadJobs(index: number = -1): Promise { + if (this.refreshTimer) { + clearTimeout(this.refreshTimer); + } + const clusters: IClusterData[] = index !== -1 ? [this.clusters[index]] : this.clusters; + await Promise.all(clusters.map(async cluster => { + cluster.loadingState = LoadingState.Loading; + this.onDidChangeTreeDataEmitter.fire(cluster); + try { + cluster.jobs = await request.get( + PAIRestUri.jobs(cluster.config), + { json: true } + ); + cluster.loadingState = LoadingState.Finished; + } catch (e) { + Util.err('treeview.joblist.error', [e.message || e]); + cluster.loadingState = LoadingState.Error; + } + this.onDidChangeTreeDataEmitter.fire(cluster); + })); + const settings: WorkspaceConfiguration = workspace.getConfiguration(SETTING_SECTION_JOB); + const interval: number = settings.get(SETTING_JOB_JOBLIST_REFERSHINTERVAL)!; + this.refreshTimer = setTimeout(this.reloadJobs.bind(this), interval * 1000); + } +} \ No newline at end of file diff --git a/contrib/pai_vscode/src/pai/hdfs.ts b/contrib/pai_vscode/src/pai/hdfs.ts index 0ce4a71dac..6161b28c84 100644 --- a/contrib/pai_vscode/src/pai/hdfs.ts +++ b/contrib/pai_vscode/src/pai/hdfs.ts @@ -15,13 +15,16 @@ import { promisify } from 'util'; import * as vscode from 'vscode'; import { - COMMAND_HDFS_DOWNLOAD, COMMAND_HDFS_UPLOAD_FILES, COMMAND_HDFS_UPLOAD_FOLDERS, COMMAND_OPEN_HDFS, OCTICON_CLOUDUPLOAD + COMMAND_HDFS_DOWNLOAD, COMMAND_HDFS_UPLOAD_FILES, COMMAND_HDFS_UPLOAD_FOLDERS, COMMAND_OPEN_HDFS, + ENUM_HDFS_EXPLORER_LOCATION, OCTICON_CLOUDUPLOAD, SETTING_HDFS_EXPLORER_LOCATION, SETTING_SECTION_HDFS } from '../common/constants'; import { __ } from '../common/i18n'; import { getSingleton, Singleton } from '../common/singleton'; import { Util } from '../common/util'; + import { ClusterManager } from './clusterManager'; -import { ConfigurationNode } from './configurationTreeDataProvider'; +import { ClusterExplorerChildNode } from './configurationTreeDataProvider'; +import { HDFSTreeDataProvider } from './container/hdfsTreeView'; import { IPAICluster } from './paiInterface'; import { createWebHDFSClient, IHDFSClient, IHDFSStatResult } from './webhdfs-workaround'; @@ -94,11 +97,17 @@ export class HDFSFileSystemProvider implements vscode.FileSystemProvider { statResult = await this.stat(uri); if (statResult.type !== vscode.FileType.Directory) { throw vscode.FileSystemError.FileExists(uri); + } else { + // pass + } + } catch (e) { + if (uri.path === '/') { + throw e; } - } catch { await this.createDirectory(Util.uriPathPop(uri)); try { await (await this.getClient(uri)).mkdir(path.join('/', uri.path)); + this.onDidChangeFileEmitter.fire([{ type: vscode.FileChangeType.Created, uri }]); } catch (ex) { throw new vscode.FileSystemError(ex); } @@ -108,6 +117,7 @@ export class HDFSFileSystemProvider implements vscode.FileSystemProvider { public async delete(uri: vscode.Uri, options: {recursive: boolean}): Promise { try { await (await this.getClient(uri)).unlink(path.join('/', uri.path), options.recursive); + this.onDidChangeFileEmitter.fire([{ type: vscode.FileChangeType.Deleted, uri }]); } catch (ex) { throw new vscode.FileSystemError(ex); } @@ -248,79 +258,81 @@ export class HDFSFileSystemProvider implements vscode.FileSystemProvider { const client: IHDFSClient = (await this.getClient(uri)); const filePath: string = path.join('/', uri.path); await vscode.window.withProgress( - { - location: vscode.ProgressLocation.Notification, - title: __('hdfs.uploading', [filePath]), - cancellable: true - }, - (progress, cancellationToken) => new Promise(async (resolve, reject) => { - try { - if ((await client.stat(filePath)).type === 'DIRECTORY') { - reject(vscode.FileSystemError.FileIsADirectory(uri)); - return; - } - if (!options.overwrite) { - reject(vscode.FileSystemError.FileExists(uri)); - return; - } - } catch { - if (!options.create) { - reject(vscode.FileSystemError.FileNotFound(uri)); - return; + { + location: vscode.ProgressLocation.Notification, + title: __('hdfs.uploading', [filePath]), + cancellable: true + }, + (progress, cancellationToken) => new Promise(async (resolve, reject) => { + try { + if ((await client.stat(filePath)).type === 'DIRECTORY') { + reject(vscode.FileSystemError.FileIsADirectory(uri)); + return; + } + if (!options.overwrite) { + reject(vscode.FileSystemError.FileExists(uri)); + return; + } + } catch { + if (!options.create) { + reject(vscode.FileSystemError.FileNotFound(uri)); + return; + } } - } - const local: fs.ReadStream = streamifier.createReadStream(content); - let writeAmount: number = 0; - const transform: Transform = new Transform({ - transform: (chunk: string | Buffer, encoding: string, callback: Function) => { - writeAmount += chunk.length; - progress.report({ - message: __('hdfs.progress', [ - (writeAmount / content.length * 100).toFixed(0), writeAmount, content.length - ]), - increment: chunk.length / content.length * 100 - }); - callback(null, chunk); + const local: fs.ReadStream = streamifier.createReadStream(content); + let writeAmount: number = 0; + const transform: Transform = new Transform({ + transform: (chunk: string | Buffer, encoding: string, callback: Function) => { + writeAmount += chunk.length; + progress.report({ + message: __('hdfs.progress', [ + (writeAmount / content.length * 100).toFixed(0), writeAmount, content.length + ]), + increment: chunk.length / content.length * 100 + }); + callback(null, chunk); + } + }); + const stream: Request = await client.createRobustWriteStream(filePath); + let error: any; + + function cleanup(): void { + local.unpipe(); + transform.unpipe(); + local.destroy(); + transform.destroy(); + stream.destroy(); } - }); - const stream: Request = await client.createRobustWriteStream(filePath); - let error: any; - - function cleanup(): void { - local.unpipe(); - transform.unpipe(); - local.destroy(); - transform.destroy(); - stream.destroy(); - } - cancellationToken.onCancellationRequested(() => { - error = true; - cleanup(); - reject(__('hdfs.write.cancelled')); - }); + cancellationToken.onCancellationRequested(() => { + error = true; + cleanup(); + reject(__('hdfs.write.cancelled')); + }); - local.once('error', err => { - error = err; - cleanup(); - reject(new vscode.FileSystemError(err)); - }); - stream.once('error', err => { - error = err; - cleanup(); - reject(new vscode.FileSystemError(err)); - }); + local.once('error', err => { + error = err; + cleanup(); + reject(new vscode.FileSystemError(err)); + }); + stream.once('error', err => { + error = err; + cleanup(); + reject(new vscode.FileSystemError(err)); + }); - stream.once('finish', () => { - cleanup(); - if (!error) { - resolve(); - } - }); + stream.once('finish', () => { + cleanup(); + if (!error) { + resolve(); + } + }); - // TODO: local.pipe(transform).pipe(stream); is not working due to unknown reason...maybe a bug in node-webhdfs? - local.pipe(transform).pipe(stream); - })); + // TODO: local.pipe(transform).pipe(stream); is not working due to unknown reason...maybe a bug in node-webhdfs? + local.pipe(transform).pipe(stream); + }) + ); + this.onDidChangeFileEmitter.fire([{ type: vscode.FileChangeType.Created, uri }]); } public async copy(source: vscode.Uri, destination: vscode.Uri, options: { overwrite: boolean }): Promise { @@ -460,65 +472,81 @@ export class HDFSFileSystemProvider implements vscode.FileSystemProvider { */ @injectable() export class HDFS extends Singleton { + public readonly provider: HDFSFileSystemProvider; private UPLOADFILES: string = __('hdfs.dialog.label.upload-files'); private UPLOADFOLDER: string = __('hdfs.dialog.label.upload-folders'); private DOWNLOADHERE: string = __('hdfs.dialog.label.download'); - private _provider: HDFSFileSystemProvider | undefined; - public get provider(): HDFSFileSystemProvider | undefined { - return this._provider; - } - constructor() { super(); console.log('Registering HDFS...'); - try { - this._provider = new HDFSFileSystemProvider(); - this.context.subscriptions.push( - vscode.workspace.registerFileSystemProvider('webhdfs', this._provider, { isCaseSensitive: true }) - ); - console.log('HDFS registered as webhdfs:/...'); - } catch (ex) { - Util.err('hdfs.initialization.error', [ex]); - } + this.provider = new HDFSFileSystemProvider(); + this.context.subscriptions.push( + vscode.workspace.registerFileSystemProvider('webhdfs', this.provider, { isCaseSensitive: true }) + ); + console.log('HDFS registered as webhdfs:/...'); this.context.subscriptions.push( vscode.commands.registerCommand( COMMAND_OPEN_HDFS, - (node: ConfigurationNode) => this.open(node.index) + async (node?: ClusterExplorerChildNode | IPAICluster) => { + if (!node) { + const manager: ClusterManager = await getSingleton(ClusterManager); + const index: number | undefined = await manager.pick(); + if (index === undefined) { + return; + } + await this.open(manager.allConfigurations[index]); + } else if (node instanceof ClusterExplorerChildNode) { + await this.open((await getSingleton(ClusterManager)).allConfigurations[node.index]); + } else { + await this.open(node); + } + } ), - vscode.commands.registerCommand(COMMAND_HDFS_UPLOAD_FILES, this.uploadFiles.bind(this)), - vscode.commands.registerCommand(COMMAND_HDFS_UPLOAD_FOLDERS, this.uploadFolders.bind(this)), - vscode.commands.registerCommand(COMMAND_HDFS_DOWNLOAD, this.download.bind(this)) + vscode.commands.registerCommand(COMMAND_HDFS_UPLOAD_FILES, async (param: vscode.Uri | vscode.TreeItem) => { + await this.uploadFiles(this.unpackParam(param)); + }), + vscode.commands.registerCommand(COMMAND_HDFS_UPLOAD_FOLDERS, async (param: vscode.Uri | vscode.TreeItem) => { + await this.uploadFolders(this.unpackParam(param)); + }), + vscode.commands.registerCommand(COMMAND_HDFS_DOWNLOAD, async (param: vscode.Uri | vscode.TreeItem) => { + await this.download(this.unpackParam(param)); + }) ); } - public async open(index: number): Promise { - const configuration: IPAICluster = (await getSingleton(ClusterManager)).allConfigurations[index]; - if (!configuration.webhdfs_uri) { + public async open(conf: IPAICluster): Promise { + if (!conf.webhdfs_uri) { Util.err('hdfs.initialization.missingconfiguration'); return; } - let start: number = 0; - let deleteCount: number = 0; - if (vscode.workspace.workspaceFolders) { - start = vscode.workspace.workspaceFolders.findIndex(folder => folder.uri.scheme === 'webhdfs'); - if (start >= 0) { - deleteCount = 1; - } else { - start = vscode.workspace.workspaceFolders.length; + const setting: string | undefined = vscode.workspace.getConfiguration(SETTING_SECTION_HDFS).get(SETTING_HDFS_EXPLORER_LOCATION); + if (setting === ENUM_HDFS_EXPLORER_LOCATION.explorer) { + let start: number = 0; + let deleteCount: number = 0; + if (vscode.workspace.workspaceFolders) { + start = vscode.workspace.workspaceFolders.findIndex(folder => folder.uri.scheme === 'webhdfs'); + if (start >= 0) { + deleteCount = 1; + } else { + start = vscode.workspace.workspaceFolders.length; + } } - } - try { - // this._provider!.addClient(getHDFSUriAuthority(configuration)); - await vscode.commands.executeCommand('workbench.view.explorer'); - void vscode.window.showInformationMessage(__('hdfs.open.prompt', [configuration.webhdfs_uri])); - vscode.workspace.updateWorkspaceFolders(start, deleteCount, { - uri: vscode.Uri.parse(`webhdfs://${getHDFSUriAuthority(configuration)}/`), - name: __('hdfs.workspace.title', [configuration.webhdfs_uri]) - }); - // Extension may be reloaded at this point due to workspace changes - } catch (ex) { - Util.err('hdfs.open.error', [ex]); + try { + // this.provider!.addClient(getHDFSUriAuthority(configuration)); + await vscode.commands.executeCommand('workbench.view.explorer'); + void vscode.window.showInformationMessage(__('hdfs.open.prompt', [conf.webhdfs_uri])); + vscode.workspace.updateWorkspaceFolders(start, deleteCount, { + uri: vscode.Uri.parse(`webhdfs://${getHDFSUriAuthority(conf)}/`), + name: __('hdfs.workspace.title', [conf.webhdfs_uri]) + }); + // Extension may be reloaded at this point due to workspace changes + } catch (ex) { + Util.err('hdfs.open.error', [ex]); + } + } else { + const provider: HDFSTreeDataProvider = await getSingleton(HDFSTreeDataProvider); + provider.setUri(vscode.Uri.parse(`webhdfs://${getHDFSUriAuthority(conf)}/`)); } } @@ -534,7 +562,19 @@ export class HDFS extends Singleton { } } - public async download(from: vscode.Uri): Promise { + private unpackParam(param: vscode.Uri | vscode.TreeItem): vscode.Uri { + if (param instanceof vscode.TreeItem) { + if (param.resourceUri !== undefined) { + return param.resourceUri; + } else { + throw new Error('Invalid HDFS operation param'); + } + } else { + return param; + } + } + + private async download(from: vscode.Uri): Promise { const files: vscode.Uri[] | undefined = await vscode.window.showOpenDialog({ canSelectFiles: false, canSelectFolders: true, @@ -547,7 +587,7 @@ export class HDFS extends Singleton { await this.provider!.copy(from, files[0], { overwrite: true }); } - public async uploadFiles(target: vscode.Uri): Promise { + private async uploadFiles(target: vscode.Uri): Promise { const files: vscode.Uri[] | undefined = await vscode.window.showOpenDialog({ canSelectFiles: true, canSelectMany: true, @@ -559,7 +599,7 @@ export class HDFS extends Singleton { return this.upload(files, target); } - public async uploadFolders(target: vscode.Uri): Promise { + private async uploadFolders(target: vscode.Uri): Promise { const folders: vscode.Uri[] | undefined = await vscode.window.showOpenDialog({ canSelectFiles: false, canSelectFolders: true, diff --git a/contrib/pai_vscode/src/pai/paiInterface.ts b/contrib/pai_vscode/src/pai/paiInterface.ts index 5ca4a2faf3..c0ef9fe468 100644 --- a/contrib/pai_vscode/src/pai/paiInterface.ts +++ b/contrib/pai_vscode/src/pai/paiInterface.ts @@ -33,4 +33,17 @@ export interface IPAIJobConfig { codeDir: string; outputDir: string; taskRoles: IPAITaskRole[]; +} + +export interface IPAIJobInfo { + name: string; + username: string; + state: 'SUCCEEDED' | 'FAILED' | 'WAITING' | 'STOPPED' | 'RUNNING' | 'UNKNOWN'; + subState: 'FRAMEWORK_COMPLETED' | 'FRAMEWORK_WAITING'; + executionType: 'START' | 'STOP'; + retries: number; + createdTime: number; + completedTime: number; + appExitCode: number; + virtualCluster: string; } \ No newline at end of file diff --git a/contrib/pai_vscode/src/pai/paiJobManager.ts b/contrib/pai_vscode/src/pai/paiJobManager.ts index 52b6dac74f..0cdbfb5cea 100644 --- a/contrib/pai_vscode/src/pai/paiJobManager.ts +++ b/contrib/pai_vscode/src/pai/paiJobManager.ts @@ -8,10 +8,9 @@ import * as fs from 'fs-extra'; import * as globby from 'globby'; import { injectable } from 'inversify'; import * as JSONC from 'jsonc-parser'; -import { isEmpty } from 'lodash'; +import { isEmpty, isNil } from 'lodash'; import * as os from 'os'; import * as path from 'path'; -import * as querystring from 'querystring'; import * as request from 'request-promise-native'; import * as uuid from 'uuid'; import * as vscode from 'vscode'; @@ -27,13 +26,16 @@ import { import { __ } from '../common/i18n'; import { getSingleton, Singleton } from '../common/singleton'; import { Util } from '../common/util'; -import { ClusterManager, getClusterIdentifier, getClusterWebPortalUri } from './clusterManager'; -import { ConfigurationNode } from './configurationTreeDataProvider'; + +import { getClusterIdentifier, ClusterManager } from './clusterManager'; +import { ClusterExplorerChildNode } from './configurationTreeDataProvider'; import { getHDFSUriAuthority, HDFS, HDFSFileSystemProvider } from './hdfs'; import { IPAICluster, IPAIJobConfig, IPAITaskRole } from './paiInterface'; import opn = require('opn'); // tslint:disable-line import unixify = require('unixify'); // tslint:disable-line +import { PAIRestUri, PAIWebPortalUri } from './paiUri'; +import { RecentJobManager } from './recentJobManager'; interface ITokenItem { token: string; expireTime: number; @@ -60,7 +62,6 @@ interface IJobInput { */ @injectable() export class PAIJobManager extends Singleton { - private static readonly API_PREFIX: string = 'api/v1'; private static readonly TIMEOUT: number = 60 * 1000; private static readonly SIMULATION_DOCKERFILE_FOLDER: string = '.pai_simulator'; private static readonly propertiesToBeReplaced: (keyof IPAIJobConfig)[] = [ @@ -84,7 +85,7 @@ export class PAIJobManager extends Singleton { this.context.subscriptions.push( vscode.commands.registerCommand( COMMAND_CREATE_JOB_CONFIG, - async (input?: ConfigurationNode | vscode.Uri) => { + async (input?: ClusterExplorerChildNode | vscode.Uri) => { if (input instanceof vscode.Uri) { await PAIJobManager.generateJobConfig(input.fsPath); } else { @@ -94,10 +95,10 @@ export class PAIJobManager extends Singleton { ), vscode.commands.registerCommand( COMMAND_SIMULATE_JOB, - async (input?: ConfigurationNode | vscode.Uri) => { + async (input?: ClusterExplorerChildNode | vscode.Uri) => { if (input instanceof vscode.Uri) { await this.simulate({ jobConfigPath: input.fsPath }); - } else if (input instanceof ConfigurationNode) { + } else if (input instanceof ClusterExplorerChildNode) { await this.simulate({ clusterIndex: input.index }); } else { await this.simulate(); @@ -106,10 +107,10 @@ export class PAIJobManager extends Singleton { ), vscode.commands.registerCommand( COMMAND_SUBMIT_JOB, - async (input?: ConfigurationNode | vscode.Uri) => { + async (input?: ClusterExplorerChildNode | vscode.Uri) => { if (input instanceof vscode.Uri) { await this.submitJob({ jobConfigPath: input.fsPath }); - } else if (input instanceof ConfigurationNode) { + } else if (input instanceof ClusterExplorerChildNode) { await this.submitJob({ clusterIndex: input.index }); } else { await this.submitJob(); @@ -132,7 +133,7 @@ export class PAIJobManager extends Singleton { parent = fileFolders[0].uri.fsPath; } } - defaultSaveDir = path.join(parent, `${name}.pai.json`); + defaultSaveDir = path.join(parent, `${name}.pai.jsonc`); config = { jobName: '', image: 'aiplatform/pai.build.base', @@ -160,7 +161,7 @@ export class PAIJobManager extends Singleton { } script = path.relative(parent, script); const jobName: string = path.basename(script, path.extname(script)); - defaultSaveDir = path.join(parent, `${jobName}.pai.json`); + defaultSaveDir = path.join(parent, `${jobName}.pai.jsonc`); config = { jobName, image: 'aiplatform/pai.build.base', @@ -181,10 +182,17 @@ export class PAIJobManager extends Singleton { } const saveDir: vscode.Uri | undefined = await vscode.window.showSaveDialog({ - defaultUri: vscode.Uri.file(defaultSaveDir) + defaultUri: vscode.Uri.file(defaultSaveDir), + filters: { + JSON: ['json', 'jsonc'] + } }); if (saveDir) { - await fs.writeJSON(saveDir.fsPath, config, { spaces: 4 }); + if (saveDir.fsPath.endsWith('.jsonc')) { + await fs.writeFile(saveDir.fsPath, await Util.generateCommentedJSON(config, 'pai_job_config.schema.json')); + } else { + await fs.writeJSON(saveDir.fsPath, config, { spaces: 4 }); + } await vscode.window.showTextDocument(saveDir); } } @@ -282,7 +290,7 @@ export class PAIJobManager extends Singleton { param.config.jobName = `${param.config.jobName}_${uuid().substring(0, 8)}`; } else { try { - await request.get(`${this.getRestUrl(param.cluster)}/user/${param.cluster.username}/jobs/${param.config.jobName}`, { + await request.get(PAIRestUri.jobDetail(param.cluster, param.cluster.username, param.config.jobName), { headers: { Authorization: `Bearer ${await this.getToken(param.cluster)}` }, timeout: PAIJobManager.TIMEOUT, json: true @@ -325,21 +333,19 @@ export class PAIJobManager extends Singleton { // send job submission request statusBarItem.text = `${OCTICON_CLOUDUPLOAD} ${__('job.request.status')}`; try { - await request.post(`${this.getRestUrl(param.cluster)}/user/${param.cluster.username}/jobs`, { + await request.post(PAIRestUri.jobs(param.cluster, param.cluster.username), { headers: { Authorization: `Bearer ${await this.getToken(param.cluster)}` }, form: param.config, timeout: PAIJobManager.TIMEOUT, json: true }); + void (await getSingleton(RecentJobManager)).enqueueRecentJobs(param.cluster, param.config.jobName); const open: string = __('job.submission.success.open'); void vscode.window.showInformationMessage( __('job.submission.success'), open ).then(async res => { - const url: string = `${getClusterWebPortalUri(param.cluster!)}/view.html?${querystring.stringify({ - username: param.cluster!.username, - jobName: param.config.jobName - })}`; + const url: string = await PAIWebPortalUri.jobDetail(param.cluster!, param.cluster!.username, param.config.jobName); if (res === open) { await Util.openExternally(url); } @@ -542,6 +548,9 @@ export class PAIJobManager extends Singleton { jobConfigPath = jobConfigUrl![0].fsPath; } const config: IPAIJobConfig = JSONC.parse(await fs.readFile(jobConfigPath, 'utf8')); + if (isNil(config)) { + Util.err('job.prepare.config.invalid'); + } const error: string | undefined = await Util.validateJSON(config, SCHEMA_JOB_CONFIG); if (error) { throw new Error(error); @@ -571,21 +580,11 @@ export class PAIJobManager extends Singleton { return result; } - private getRestUrl(cluster: IPAICluster): string { - let url: string = cluster.rest_server_uri; - if (!url.endsWith('/')) { - url += '/'; - } - url += PAIJobManager.API_PREFIX; - - return Util.fixURL(url); - } - private async getToken(cluster: IPAICluster): Promise { const id: string = getClusterIdentifier(cluster); let item: ITokenItem | undefined = this.cachedTokens.get(id); if (!item || Date.now() > item.expireTime) { - const result: any = await request.post(`${this.getRestUrl(cluster)}/token`, { + const result: any = await request.post(PAIRestUri.token(cluster), { form: { username: cluster.username, password: cluster.password, @@ -618,9 +617,15 @@ export class PAIJobManager extends Singleton { }); const fsProvider: HDFSFileSystemProvider = (await getSingleton(HDFS)).provider!; let codeDir: string = param.config.codeDir; - if (codeDir.startsWith('$PAI_DEFAULT_FS_URI')) { - codeDir = codeDir.substring('$PAI_DEFAULT_FS_URI'.length); + if (codeDir.startsWith('hdfs://') || codeDir.startsWith('webhdfs://')) { + throw new Error(__('job.upload.invalid-code-dir')); + } else { + if (codeDir.startsWith('$PAI_DEFAULT_FS_URI')) { + codeDir = codeDir.substring('$PAI_DEFAULT_FS_URI'.length); + } + codeDir = path.posix.resolve('/', codeDir); } + const codeUri: vscode.Uri = vscode.Uri.parse(`webhdfs://${getHDFSUriAuthority(param.cluster!)}${codeDir}`); const total: number = projectFiles.length; @@ -654,7 +659,7 @@ export class PAIJobManager extends Singleton { return true; } catch (e) { - Util.err('job.upload.error', [e]); + Util.err('job.upload.error', [e.message]); return false; } } diff --git a/contrib/pai_vscode/src/pai/paiUri.ts b/contrib/pai_vscode/src/pai/paiUri.ts new file mode 100644 index 0000000000..72b4ea6003 --- /dev/null +++ b/contrib/pai_vscode/src/pai/paiUri.ts @@ -0,0 +1,70 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License in the project root for license information. + * @author Microsoft + */ + +import * as querystring from 'querystring'; +import * as request from 'request-promise-native'; + +import { Util } from '../common/util'; + +import { IPAICluster } from './paiInterface'; + +export namespace PAIRestUri { + const API_PREFIX: string = 'api/v1'; + + export function getRestUrl(cluster: IPAICluster): string { + let url: string = cluster.rest_server_uri; + if (!url.endsWith('/')) { + url += '/'; + } + url += API_PREFIX; + + return Util.fixURL(url); + } + + export function token(cluster: IPAICluster): string { + return `${getRestUrl(cluster)}/token`; + } + + export function jobDetail(cluster: IPAICluster, username: string, jobName: string): string { + return `${getRestUrl(cluster)}/user/${username}/jobs/${jobName}`; + } + + export function jobs(cluster: IPAICluster, username?: string): string { + if (username) { + return `${getRestUrl(cluster)}/user/${username}/jobs`; + } + return `${getRestUrl(cluster)}/jobs`; + } +} + +export namespace PAIWebPortalUri { + export function getClusterWebPortalUri(conf: IPAICluster): string { + return conf.web_portal_uri || conf.rest_server_uri.split(':')[0]; + } + + export async function isOldJobLinkAvailable(cluster: IPAICluster): Promise { + const link: string = `${getClusterWebPortalUri(cluster)}/view.html`; + try { + await request.get(Util.fixURL(link)); + return true; + } catch { + return false; + } + } + + export async function jobDetail(cluster: IPAICluster, username: string, jobName: string): Promise { + const oldLink: boolean = await isOldJobLinkAvailable(cluster); + return `${getClusterWebPortalUri(cluster)}/${oldLink ? 'view' : 'job-detail'}.html?${querystring.stringify({ + username, + jobName + })}`; + } + + export async function jobs(cluster: IPAICluster): Promise { + const oldLink: boolean = await isOldJobLinkAvailable(cluster); + return `${getClusterWebPortalUri(cluster)}/${oldLink ? 'view' : 'job-list'}.html`; + } +} \ No newline at end of file diff --git a/contrib/pai_vscode/src/pai/paiWebpages.ts b/contrib/pai_vscode/src/pai/paiWebpages.ts index 4e10233c2d..cc366cdbf7 100644 --- a/contrib/pai_vscode/src/pai/paiWebpages.ts +++ b/contrib/pai_vscode/src/pai/paiWebpages.ts @@ -8,14 +8,17 @@ import { injectable } from 'inversify'; import * as vscode from 'vscode'; import { - COMMAND_LIST_JOB, COMMAND_OPEN_DASHBOARD, COMMAND_TREEVIEW_OPEN_PORTAL + COMMAND_LIST_JOB, COMMAND_OPEN_DASHBOARD, COMMAND_TREEVIEW_OPEN_PORTAL, COMMAND_VIEW_JOB } from '../common/constants'; import { __ } from '../common/i18n'; import { getSingleton, Singleton } from '../common/singleton'; import { Util } from '../common/util'; -import { ClusterManager, getClusterName, getClusterWebPortalUri } from './clusterManager'; -import { ConfigurationNode } from './configurationTreeDataProvider'; -import { IPAICluster } from './paiInterface'; + +import { getClusterName, ClusterManager } from './clusterManager'; +import { ClusterExplorerChildNode } from './configurationTreeDataProvider'; +import { ClusterNode } from './container/jobListTreeView'; +import { IPAICluster, IPAIJobInfo } from './paiInterface'; +import { PAIWebPortalUri } from './paiUri'; const paiDashboardPropertyLabelMapping: { [propertyName: string]: string } = { grafana_uri: 'Grafana', @@ -38,11 +41,15 @@ export class PAIWebpages extends Singleton { ), vscode.commands.registerCommand( COMMAND_TREEVIEW_OPEN_PORTAL, - (node: ConfigurationNode) => this.openDashboardFromTreeView(node.index) + (node: ClusterExplorerChildNode) => this.openDashboardFromTreeView(node.index) ), vscode.commands.registerCommand( COMMAND_LIST_JOB, - (node: ConfigurationNode) => this.listJobs(node.index) + (node: ClusterExplorerChildNode | ClusterNode) => this.listJobs(node.index) + ), + vscode.commands.registerCommand( + COMMAND_VIEW_JOB, + this.viewJob.bind(this) ) ); } @@ -55,7 +62,7 @@ export class PAIWebpages extends Singleton { const config: IPAICluster = (await getSingleton(ClusterManager)).allConfigurations![index]; const options: vscode.QuickPickItem[] = []; - const paiUrl: string = getClusterWebPortalUri(config); + const paiUrl: string = PAIWebPortalUri.getClusterWebPortalUri(config); options.push({ label: __('webpage.dashboard.webportal', [getClusterName(config)]), detail: paiUrl @@ -83,13 +90,18 @@ export class PAIWebpages extends Singleton { public async openDashboardFromTreeView(index: number): Promise { const config: IPAICluster = (await getSingleton(ClusterManager)).allConfigurations![index]; - const url: string = getClusterWebPortalUri(config); + const url: string = PAIWebPortalUri.getClusterWebPortalUri(config); await Util.openExternally(url); } public async listJobs(index: number): Promise { const config: IPAICluster = (await getSingleton(ClusterManager)).allConfigurations![index]; - const url: string = getClusterWebPortalUri(config) + '/view.html'; + const url: string = await PAIWebPortalUri.jobs(config); + await Util.openExternally(url); + } + + public async viewJob(jobInfo: IPAIJobInfo, config: IPAICluster): Promise { + const url: string = await PAIWebPortalUri.jobDetail(config, jobInfo.username, jobInfo.name); await Util.openExternally(url); } } diff --git a/contrib/pai_vscode/src/pai/recentJobManager.ts b/contrib/pai_vscode/src/pai/recentJobManager.ts new file mode 100644 index 0000000000..502f0e1b33 --- /dev/null +++ b/contrib/pai_vscode/src/pai/recentJobManager.ts @@ -0,0 +1,84 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License in the project root for license information. + * @author Microsoft + */ + +import { injectable } from 'inversify'; +import * as vscode from 'vscode'; + +import { + SETTING_JOB_JOBLIST_RECENTJOBSLENGTH, SETTING_SECTION_JOB +} from '../common/constants'; +import { __ } from '../common/i18n'; +import { getSingleton, Singleton } from '../common/singleton'; + +import { IPAICluster } from './paiInterface'; + +import { ClusterManager } from './clusterManager'; +import { JobListTreeDataProvider } from './container/jobListTreeView'; + +/** + * Manager class for cluster configurations + */ +@injectable() +export class RecentJobManager extends Singleton { + private static readonly RECENT_JOBS_KEY: string = 'openpai.recentJobs'; + + private onClusterChangeDisposable: vscode.Disposable | undefined; + private recentJobs: (string[] | undefined)[] | undefined; + + public async onActivate(): Promise { + this.onClusterChangeDisposable = (await getSingleton(ClusterManager)).onDidChange(modification => { + switch (modification.type) { + case 'EDIT': + this.allRecentJobs[modification.index] = []; + break; + case 'REMOVE': + this.allRecentJobs.splice(modification.index, 1); + break; + case 'RESET': + this.recentJobs = []; + break; + default: + } + void this.saveRecentJobs(); + }); + this.recentJobs = this.context.globalState.get(RecentJobManager.RECENT_JOBS_KEY) || []; + } + + public get allRecentJobs(): (string[] | undefined)[] { + return this.recentJobs!; + } + + public async saveRecentJobs(index: number = -1): Promise { + await this.context.globalState.update(RecentJobManager.RECENT_JOBS_KEY, this.allRecentJobs); + const provider: JobListTreeDataProvider = await getSingleton(JobListTreeDataProvider); + await provider.refresh(index); + if (index !== -1) { + const latestJobName: string | undefined = (this.allRecentJobs[index] || [])[0]; + if (latestJobName) { + await provider.revealLatestJob(index, latestJobName); + } + } + } + + public async enqueueRecentJobs(cluster: IPAICluster, jobName: string): Promise { + const index: number = (await getSingleton(ClusterManager)).allConfigurations.findIndex(c => c === cluster); + if (index === -1) { + return; + } + const list: string[] = this.allRecentJobs[index] = this.allRecentJobs[index] || []; + list.unshift(jobName); + const settings: vscode.WorkspaceConfiguration = vscode.workspace.getConfiguration(SETTING_SECTION_JOB); + const maxLen: number = settings.get(SETTING_JOB_JOBLIST_RECENTJOBSLENGTH)!; + list.splice(maxLen); // Make sure not longer than maxLen + await this.saveRecentJobs(index); + } + + public onDeactivate(): void { + if (this.onClusterChangeDisposable) { + this.onClusterChangeDisposable.dispose(); + } + } +} \ No newline at end of file diff --git a/contrib/pai_vscode/src/root.ts b/contrib/pai_vscode/src/root.ts index 954f6d76ba..c1ca6856e4 100644 --- a/contrib/pai_vscode/src/root.ts +++ b/contrib/pai_vscode/src/root.ts @@ -5,18 +5,26 @@ */ import { Singleton } from './common/singleton'; +import { TreeViewHelper } from './common/treeViewHelper'; import { UtilClass } from './common/util'; import { ClusterManager } from './pai/clusterManager'; import { ConfigurationTreeDataProvider } from './pai/configurationTreeDataProvider'; +import { HDFSTreeDataProvider } from './pai/container/hdfsTreeView'; +import { JobListTreeDataProvider } from './pai/container/jobListTreeView'; import { HDFS } from './pai/hdfs'; import { PAIJobManager } from './pai/paiJobManager'; import { PAIWebpages } from './pai/paiWebpages'; +import { RecentJobManager } from './pai/recentJobManager'; export const allSingletonClasses: { new(...arg: any[]): Singleton }[] = [ UtilClass, ClusterManager, + RecentJobManager, + TreeViewHelper, ConfigurationTreeDataProvider, PAIJobManager, PAIWebpages, - HDFS + HDFS, + HDFSTreeDataProvider, + JobListTreeDataProvider ]; \ No newline at end of file diff --git a/contrib/pai_vscode/src/test/clusterConfiguration.test.ts b/contrib/pai_vscode/src/test/clusterConfiguration.test.ts index f855c6d668..631f87a125 100644 --- a/contrib/pai_vscode/src/test/clusterConfiguration.test.ts +++ b/contrib/pai_vscode/src/test/clusterConfiguration.test.ts @@ -5,6 +5,7 @@ */ // tslint:disable:align import * as assert from 'assert'; + import { getSingleton } from '../common/singleton'; import { ClusterManager } from '../pai/clusterManager'; diff --git a/contrib/pai_vscode/tslint.json b/contrib/pai_vscode/tslint.json index 690b0fc847..1ee738e7f3 100644 --- a/contrib/pai_vscode/tslint.json +++ b/contrib/pai_vscode/tslint.json @@ -141,7 +141,7 @@ "no-inferrable-types": false, "no-multiline-string": true, "no-null-keyword": false, - "no-parameter-properties": false, + "no-parameter-properties": true, "no-relative-imports": false, "no-require-imports": true, "no-shadowed-variable": true, @@ -158,7 +158,13 @@ "object-literal-sort-keys": false, "one-variable-per-declaration": true, "only-arrow-functions": false, - "ordered-imports": true, + "ordered-imports": [ + true, + { + "named-imports-order": "lowercase-first", + "grouped-imports": true + } + ], "prefer-array-literal": false, "prefer-const": true, "prefer-for-of": true, @@ -201,7 +207,8 @@ "import-spacing": true, "indent": [ true, - "spaces" + "spaces", + 4 ], "linebreak-style": true, "newline-before-return": false,