diff --git a/src/database-controller/sdk/index.js b/src/database-controller/sdk/index.js index e9e88f1d3b..dc6bebcade 100644 --- a/src/database-controller/sdk/index.js +++ b/src/database-controller/sdk/index.js @@ -290,7 +290,7 @@ class DatabaseModel { allowNull: false, }, name: { - type: Sequelize.STRING(64), + type: Sequelize.STRING(512), allowNull: false, }, }, diff --git a/src/webportal/config/webpack.common.js b/src/webportal/config/webpack.common.js index 78b8ab3c4a..667632585b 100644 --- a/src/webportal/config/webpack.common.js +++ b/src/webportal/config/webpack.common.js @@ -72,6 +72,7 @@ const config = (env, argv) => ({ jobDetail: './src/app/job/job-view/fabric/job-detail.jsx', taskAttempt: './src/app/job/job-view/fabric/task-attempt.jsx', jobEvent: './src/app/job/job-view/fabric/job-event.jsx', + jobTransfer: './src/app/job/job-view/fabric/job-transfer.jsx', virtualClusters: './src/app/vc/vc.component.js', services: './src/app/cluster-view/services/services.component.js', hardware: './src/app/cluster-view/hardware/hardware.component.js', @@ -343,6 +344,10 @@ const config = (env, argv) => ({ filename: 'job-event.html', chunks: ['layout', 'jobEvent'], }), + generateHtml({ + filename: 'job-transfer.html', + chunks: ['layout', 'jobTransfer'], + }), generateHtml({ filename: 'virtual-clusters.html', chunks: ['layout', 'virtualClusters'], diff --git a/src/webportal/config/webportal.py b/src/webportal/config/webportal.py index 6b604f067e..c3ac4ddca9 100644 --- a/src/webportal/config/webportal.py +++ b/src/webportal/config/webportal.py @@ -63,6 +63,7 @@ def apply_config(plugin): 'uri': uri, 'plugins': json.dumps([apply_config(plugin) for plugin in plugins]), 'webportal-address': master_ip, + 'enable-job-transfer': self.service_configuration['enable-job-transfer'], } #### All service and main module (kubrenetes, machine) is generated. And in this check steps, you could refer to the service object model which you will used in your own service, and check its existence and correctness. diff --git a/src/webportal/config/webportal.yaml b/src/webportal/config/webportal.yaml index 490002d545..c40ab50024 100644 --- a/src/webportal/config/webportal.yaml +++ b/src/webportal/config/webportal.yaml @@ -18,3 +18,5 @@ service_type: "common" server-port: 9286 + +enable-job-transfer: false diff --git a/src/webportal/deploy/webportal.yaml.template b/src/webportal/deploy/webportal.yaml.template index c6dee251bf..7c5495ac72 100644 --- a/src/webportal/deploy/webportal.yaml.template +++ b/src/webportal/deploy/webportal.yaml.template @@ -81,6 +81,13 @@ spec: {%- endif %} - name: PROM_SCRAPE_TIME value: {{ cluster_cfg['prometheus']['scrape_interval'] * 10 }}s +{% if cluster_cfg['webportal']['enable-job-transfer'] %} + - name: ENABLE_JOB_TRANSFER + value: "true" +{% else %} + - name: ENABLE_JOB_TRANSFER + value: "false" +{% endif %} - name: WEBPORTAL_PLUGINS # A raw JSON formatted value is required here. value: | diff --git a/src/webportal/src/app/env.js.template b/src/webportal/src/app/env.js.template index b9260f3617..b6c3de969f 100644 --- a/src/webportal/src/app/env.js.template +++ b/src/webportal/src/app/env.js.template @@ -13,6 +13,7 @@ window.ENV = { alertManagerUri: '${ALERT_MANAGER_URI}/alert-manager', launcherType: '${LAUNCHER_TYPE}', launcherScheduler: '${LAUNCHER_SCHEDULER}', + enableJobTransfer: '${ENABLE_JOB_TRANSFER}', }; window.PAI_PLUGINS = [${WEBPORTAL_PLUGINS}][0] || []; diff --git a/src/webportal/src/app/job/job-view/fabric/job-detail.jsx b/src/webportal/src/app/job/job-view/fabric/job-detail.jsx index 9ed39fc3c6..82d827368b 100644 --- a/src/webportal/src/app/job/job-view/fabric/job-detail.jsx +++ b/src/webportal/src/app/job/job-view/fabric/job-detail.jsx @@ -52,6 +52,14 @@ import TaskRoleContainerList from './job-detail/components/task-role-container-l import TaskRoleCount from './job-detail/components/task-role-count'; import MonacoPanel from '../../../components/monaco-panel'; +const params = new URLSearchParams(window.location.search); +// the user who is viewing this page +const userName = cookies.get('user'); +// the user of the job +const userNameOfTheJob = params.get('username'); +// is the user viewing his/her own job? +const isViewingSelf = userName === userNameOfTheJob; + class JobDetail extends React.Component { constructor(props) { super(props); @@ -70,6 +78,7 @@ class JobDetail extends React.Component { loadingAttempt: false, monacoProps: null, modalTitle: '', + jobTransferInfo: null, }; this.stop = this.stop.bind(this); this.reload = this.reload.bind(this); @@ -157,6 +166,9 @@ class JobDetail extends React.Component { if (isNil(this.state.selectedAttemptIndex)) { nextState.selectedAttemptIndex = nextState.jobInfo.jobStatus.retries; } + nextState.jobTransferInfo = this.generateTransferState( + nextState.jobInfo.tags, + ); this.setState(nextState); } @@ -278,6 +290,53 @@ class JobDetail extends React.Component { } } + generateTransferState(tags) { + try { + // find out successfully transferred beds + const transferredPrefix = 'pai-transferred-to-'; + const transferredURLs = []; + const transferredClusterSet = new Set(); + for (let tag of tags) { + if (tag.startsWith(transferredPrefix)) { + tag = tag.substr(transferredPrefix.length); + const urlPosition = tag.lastIndexOf('-url-'); + if (urlPosition !== -1) { + transferredClusterSet.add(tag.substr(0, urlPosition)); + transferredURLs.push(tag.substr(urlPosition + 5)); + } + } + } + // find out failed transfer attempts + const transferAttemptPrefix = 'pai-transfer-attempt-to-'; + const transferFailedClusters = []; + for (let tag of tags) { + if (tag.startsWith(transferAttemptPrefix)) { + tag = tag.substr(transferAttemptPrefix.length); + const urlPosition = tag.lastIndexOf('-url-'); + if (urlPosition !== -1) { + const cluster = tag.substr(0, urlPosition); + const clusterURL = tag.substr(urlPosition + 5); + if (!transferredClusterSet.has(cluster)) { + transferFailedClusters.push({ + alias: cluster, + uri: clusterURL, + }); + } + } + } + } + + return { transferredURLs, transferFailedClusters }; + } catch (err) { + // in case there is error with the tag parsing + console.error(err); + return { + transferredURLs: [], + transferFailedClusters: [], + }; + } + } + render() { const { loading, @@ -289,7 +348,14 @@ class JobDetail extends React.Component { sshInfo, selectedAttemptIndex, loadingAttempt, + jobTransferInfo, } = this.state; + const transferredURLs = get(jobTransferInfo, 'transferredURLs', []); + const transferFailedClusters = get( + jobTransferInfo, + 'transferFailedClusters', + [], + ); const attemptIndexOptions = []; if (!isNil(jobInfo)) { @@ -305,7 +371,9 @@ class JobDetail extends React.Component { return ; } else { return ( - + {!isEmpty(error) && ( @@ -315,6 +383,54 @@ class JobDetail extends React.Component { )} + {transferredURLs.length > 0 && ( +
+ + + This job has been transferred to{' '} + {transferredURLs + .map(url => ( + + {url} + + )) + .reduce((prev, curr) => [prev, ', ', curr])} + .{' '} + + +
+ )} + {isViewingSelf && transferFailedClusters.length > 0 && ( +
+ + + You have transfer attempts to cluster{' '} + {transferFailedClusters + .map(item => ( + + {item.alias} + + )) + .reduce((prev, curr) => [prev, ', ', curr])} + . Please go to{' '} + {transferFailedClusters.length > 1 + ? 'these clusters' + : 'the cluster'}{' '} + to check whether the transfer is successful. + + +
+ )} { +const CloneButton = ({ rawJobConfig, namespace, jobName, enableTransfer }) => { const [href, onClick] = useMemo(() => { // TODO: align same format of jobname with each submit ways const queryOld = { @@ -41,9 +42,11 @@ const CloneButton = ({ rawJobConfig, namespace, jobName }) => { // default if (isNil(pluginId)) { if (isJobV2(rawJobConfig)) { - return [`/submit.html?${qs.stringify(queryNew)}`, undefined]; + // give a dummy function for onClick because split button depends on it to work + return [`/submit.html?${qs.stringify(queryNew)}`, () => {}]; } else { - return [`/submit_v1.html?${qs.stringify(queryNew)}`, undefined]; + // give a dummy function for onClick because split button depends on it to work + return [`/submit_v1.html?${qs.stringify(queryNew)}`, () => {}]; } } // plugin @@ -84,14 +87,50 @@ const CloneButton = ({ rawJobConfig, namespace, jobName }) => { ]; }, [rawJobConfig]); - return ( - - ); + let cloneButton; + // Only when transfer job is enabled, and the owner of this job is the one + // who is viewing it, show the transfer option. + const { isViewingSelf } = useContext(Context); + if (enableTransfer && isViewingSelf) { + cloneButton = ( + { + const query = { + userName: namespace, + jobName: jobName, + }; + window.location.href = `job-transfer.html?${qs.stringify( + query, + )}`; + }, + }, + ], + }} + href={href} + onClick={onClick} + disabled={!isClonable(rawJobConfig)} + /> + ); + } else { + cloneButton = ( + + ); + } + + return cloneButton; }; CloneButton.propTypes = { diff --git a/src/webportal/src/app/job/job-view/fabric/job-detail/components/context.jsx b/src/webportal/src/app/job/job-view/fabric/job-detail/components/context.jsx index c10e5aa165..280f49131f 100644 --- a/src/webportal/src/app/job/job-view/fabric/job-detail/components/context.jsx +++ b/src/webportal/src/app/job/job-view/fabric/job-detail/components/context.jsx @@ -21,6 +21,7 @@ const Context = React.createContext({ jobConfig: null, rawJobConfig: null, sshInfo: null, + isViewingSelf: null, }); export default Context; diff --git a/src/webportal/src/app/job/job-view/fabric/job-detail/components/summary.jsx b/src/webportal/src/app/job/job-view/fabric/job-detail/components/summary.jsx index 55c99a69ab..e46e00bb7d 100644 --- a/src/webportal/src/app/job/job-view/fabric/job-detail/components/summary.jsx +++ b/src/webportal/src/app/job/job-view/fabric/job-detail/components/summary.jsx @@ -454,6 +454,7 @@ export default class Summary extends React.Component { namespace={namespace} jobName={jobName} rawJobConfig={rawJobConfig} + enableTransfer={config.enableJobTransfer === 'true'} /> diff --git a/src/webportal/src/app/job/job-view/fabric/job-transfer.jsx b/src/webportal/src/app/job/job-view/fabric/job-transfer.jsx new file mode 100644 index 0000000000..c85150f729 --- /dev/null +++ b/src/webportal/src/app/job/job-view/fabric/job-transfer.jsx @@ -0,0 +1,434 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import React, { useEffect, useState, useRef } from 'react'; +import { + Stack, + StackItem, + Text, + TextField, + Dropdown, + PrimaryButton, + DefaultButton, + ColorClassNames, + getTheme, + mergeStyleSets, +} from 'office-ui-fabric-react'; +import { Label } from 'office-ui-fabric-react/lib/Label'; +import { Spinner, SpinnerSize } from 'office-ui-fabric-react/lib/Spinner'; +import ReactDOM from 'react-dom'; +import { jobProtocolSchema } from '../../../job-submission/models/protocol-schema'; +import qs from 'querystring'; +import Joi from 'joi-browser'; +import yaml from 'js-yaml'; + +import { SpinnerLoading } from '../../../components/loading'; +import MonacoPanel from '../../../components/monaco-panel'; + +import _ from 'lodash'; +import InfoBox from './job-transfer/info-box'; +import StopBox from './job-transfer/stop-box'; +import { + fetchBoundedClusters, + fetchJobConfig, + fetchJobState, + transferJob, +} from './job-transfer/conn'; + +const params = new URLSearchParams(window.location.search); +// the user who is viewing this page +const userName = cookies.get('user'); +// the user of the job +const userNameOfTheJob = params.get('userName'); +const jobName = params.get('jobName'); + +const JOB_PROTOCOL_SCHEMA_URL = + 'https://github.com/microsoft/openpai-protocol/blob/master/schemas/v2/schema.yaml'; + +const { palette } = getTheme(); + +const styles = mergeStyleSets({ + form: { + width: '35%', + marginTop: '30px', + alignSelf: 'center', + boxSizing: 'border-box', + boxShadow: '0 5px 15px rgba(0, 0, 0, 0.2)', + borderStyle: '1px solid rgba(0, 0, 0, 0.2)', + borderRadius: '6px', + backgroundColor: palette.white, + }, + + title: { + fontWeight: '500', + }, + + subTitle: { + fontWeight: '200', + textAlign: 'center', + }, + + header: { + width: '80%', + paddingBottom: '20px', + borderBottom: `1px solid ${palette.neutralLight}`, + }, + + footer: { + width: '80%', + paddingTop: '20px', + borderTop: `1px solid ${palette.neutralLight}`, + }, + + item: { + width: '100%', + paddingLeft: '20%', + paddingRight: '20%', + }, +}); + +// light-weight helper class for job config +class JobConfig { + constructor(jobConfig) { + if (_.isObject(jobConfig)) { + this._jobConfig = _.cloneDeep(jobConfig); + } else { + throw new Error('The job config is not a valid!'); + } + } + + getObject() { + return this._jobConfig; + } + + validate() { + const result = Joi.validate(this._jobConfig, jobProtocolSchema); + if (result.error === null) { + return [true, '']; + } else { + return [false, result.error.message]; + } + } + + static validateFromYAML(yamlText) { + try { + const jobConfig = new JobConfig(yaml.safeLoad(yamlText)); + return jobConfig.validate(); + } catch (err) { + return [false, err.message]; + } + } + + getYAML() { + return yaml.safeDump(this._jobConfig); + } +} + +const JobTransferPage = () => { + const [loading, setLoading] = useState(true); + const [boundedClusters, setBoundedClusters] = useState(''); + const [selectedCluster, setSelectedCluster] = useState(''); + const [jobConfig, setJobConfig] = useState(new JobConfig({})); + const [transferring, setTransferring] = useState(false); + const [showInfoBox, setShowInfoBox] = useState(false); + const [infoBoxProps, setInfoBoxProps] = useState({}); + const [showStopBox, setShowStopBox] = useState(false); + const [jobStateInStopBox, setJobStateInStopBox] = useState('UNKNOWN'); + const [showEditor, setShowEditor] = useState(false); + const [editorYAML, setEditorYAML] = useState(''); + const [isConfigValid, configValidationError] = jobConfig.validate(); + const [ + isEditorYAMLValid, + editorYAMLValidationError, + ] = JobConfig.validateFromYAML(editorYAML); + + const onDismissInfoBox = () => { + setShowInfoBox(false); + setInfoBoxProps({}); + }; + + const onDismissStopBox = () => { + setShowStopBox(false); + }; + + const monaco = useRef(null); + + if (userName !== userNameOfTheJob) { + // currently, we only allow user transfer his own job. + setShowInfoBox(true); + setInfoBoxProps({ + title: 'Notice', + message: 'You can only transfer your job.', + onDismiss: onDismissInfoBox, + redirectURL: `job-detail.html?${qs.stringify({ + username: userNameOfTheJob, + jobName: jobName, + })}`, + }); + } + + const fetchInfo = async () => { + const [boundedClustersInfo, jobConfigInfo] = await Promise.all([ + fetchBoundedClusters(userName), + fetchJobConfig(userName, jobName), + ]); + if (_.isEmpty(boundedClustersInfo)) { + setShowInfoBox(true); + setInfoBoxProps({ + title: 'Notice', + message: + "You haven't set up any bounded clusters yet, so the job cannot be transferred. " + + 'Please click OK to go to your profile page to set up some bounded clusters.', + onDismiss: onDismissInfoBox, + redirectURL: '/user-profile.html', + }); + return; + } + setBoundedClusters(boundedClustersInfo); + setJobConfig(new JobConfig(jobConfigInfo)); + setLoading(false); + }; + + const showError = e => { + setShowInfoBox(true); + setInfoBoxProps({ + title: 'Error', + message: e.message, + onDismiss: onDismissInfoBox, + }); + }; + + useEffect(() => { + fetchInfo().catch(showError); + }, []); + + const onClickTransfer = () => { + (async () => { + setTransferring(true); + if (isConfigValid === false) { + throw new Error( + `There is an error in your job config. Please check. Details: ${configValidationError}.`, + ); + } + await transferJob( + userName, + jobName, + _.merge(boundedClusters[selectedCluster], { alias: selectedCluster }), + jobConfig.getObject(), + jobConfig.getYAML(), + ); + const currentJobState = await fetchJobState(userName, jobName); + if (currentJobState === 'RUNNING' || currentJobState === 'WAITING') { + // show stop box + setShowStopBox(true); + setJobStateInStopBox(currentJobState); + } else { + // show normal info box + setShowInfoBox(true); + setInfoBoxProps({ + title: 'Notice', + message: + 'Your job has been successfully transferred! Please click OK to return to the job detail page.', + onDismiss: onDismissInfoBox, + redirectURL: `job-detail.html?${qs.stringify({ + username: userName, + jobName: jobName, + })}`, + }); + } + })() + .catch(showError) + .finally(() => { + setTransferring(false); + }); + }; + + const onOpenEditor = () => { + setEditorYAML(jobConfig.getYAML()); + setShowEditor(true); + }; + + const onCloseEditor = () => { + setShowEditor(false); + }; + + const onSaveEditor = () => { + try { + const newJobConfig = new JobConfig(yaml.safeLoad(editorYAML)); + setJobConfig(newJobConfig); + setShowEditor(false); + } catch (err) { + // This shouldn't happen because we have validated the YAML before saving. + alert(err.message); + } + }; + + const onEditorYAMLChange = text => { + setEditorYAML(text); + }; + + return ( +
+
+ ); +}; + +ReactDOM.render( + , + document.getElementById('content-wrapper'), +); diff --git a/src/webportal/src/app/job/job-view/fabric/job-transfer/conn.js b/src/webportal/src/app/job/job-view/fabric/job-transfer/conn.js new file mode 100644 index 0000000000..517ab13ca8 --- /dev/null +++ b/src/webportal/src/app/job/job-view/fabric/job-transfer/conn.js @@ -0,0 +1,300 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import config from '../../../../config/webportal.config'; +import _ from 'lodash'; +import qs from 'querystring'; +import urljoin from 'url-join'; + +const token = cookies.get('token'); + +// A simple wrapper for rest-server api calls. +// It will throw error if there is any: e.g. Network Error, Failed Response Code +const requestApi = async (url, params) => { + const response = await fetch(url, params); + const result = await response.json(); + // node-fetch will throw error like network error. + // node-fetch won't throw error if the response is not successful (e.g. 404, 500) + // In such case, we throw the error manually + if (!response.ok) { + if (_.has(result, 'message')) { + throw new Error(result.message); + } else { + throw new Error( + `Unknown response error happens when request url ${url}.`, + ); + } + } + return result; +}; + +// Use a different function to provide more friendly error message +const requestBoundedClusterApi = async (alias, url, params) => { + const response = await fetch(url, params); + const result = await response.json(); + // node-fetch will throw error like network error + // node-fetch won't throw error if the response is not 20X (e.g. 404, 500) + // In such case, we throw the error manually + if (!response.ok) { + if (_.has(result, 'message')) { + throw new Error( + `There is an error during api call to bounded cluster ${alias}. Detail message: ${result.message}`, + ); + } else { + throw new Error( + `There is a unknown error during api call to bounded cluster ${alias}. URL: ${url}.`, + ); + } + } + return result; +}; + +export async function fetchBoundedClusters(userName) { + const restServerUri = new URL(config.restServerUri, window.location.href); + const url = urljoin(restServerUri.toString(), `/api/v2/users/${userName}`); + const result = await requestApi(url, { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + return _.get(result, 'extension.boundedClusters', {}); +} + +export async function fetchJobConfig(userName, jobName) { + const restServerUri = new URL(config.restServerUri, window.location.href); + const url = urljoin( + restServerUri.toString(), + `/api/v2/jobs/${userName}~${jobName}/config`, + ); + const result = await requestApi(url, { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + return result; +} + +export async function fetchJobState(userName, jobName) { + const restServerUri = new URL(config.restServerUri, window.location.href); + const url = urljoin( + restServerUri.toString(), + `/api/v2/jobs/${userName}~${jobName}`, + ); + const result = await requestApi(url, { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + return _.get(result, 'jobStatus.state', 'UNKNOWN'); +} + +export async function stopJob(userName, jobName) { + const restServerUri = new URL(config.restServerUri, window.location.href); + const url = urljoin( + restServerUri.toString(), + `/api/v2/jobs/${userName}~${jobName}/executionType`, + ); + await requestApi(url, { + method: 'PUT', + body: JSON.stringify({ value: 'STOP' }), + headers: { + Authorization: `Bearer ${token}`, + }, + }); +} + +// confirm the VC is available to the user +async function confirmVC(clusterConfig, jobConfig) { + const vcName = _.get(jobConfig, 'defaults.virtualCluster'); + // get all available VCs + const url = urljoin( + clusterConfig.uri, + '/rest-server/api/v2/virtual-clusters', + ); + const result = await requestBoundedClusterApi(clusterConfig.alias, url, { + headers: { + Authorization: `Bearer ${clusterConfig.token}`, + }, + }); + if (!_.has(result, vcName)) { + throw new Error( + `The bounded cluster ${clusterConfig.alias} doesn't have the virtual cluster ${vcName}, ` + + "or you don't have permission to it. Please modify your job config. " + + `Available virtual clusters include: ${_.keys(result).join(', ')}`, + ); + } +} + +// confirm the sku is available +async function confirmSKU(clusterConfig, jobConfig) { + const vcName = _.get(jobConfig, 'defaults.virtualCluster'); + const usedSKUs = []; + const taskroleSettings = _.get(jobConfig, 'extras.hivedScheduler.taskRoles'); + if (taskroleSettings) { + for (const taskrole in taskroleSettings) { + const sku = _.get(taskroleSettings[taskrole], 'skuType'); + if (sku) { + usedSKUs.push(sku); + } + } + if (usedSKUs.length > 0) { + const url = urljoin( + clusterConfig.uri, + `/rest-server/api/v2/cluster/sku-types?${qs.stringify({ vc: vcName })}`, + ); + const result = await requestBoundedClusterApi(clusterConfig.alias, url, { + headers: { + Authorization: `Bearer ${clusterConfig.token}`, + }, + }); + for (const usedSKU of usedSKUs) { + if (!_.has(result, usedSKU)) { + throw new Error( + `The virtual cluster ${vcName} in bounded cluster ${clusterConfig.alias} doesn't have the SKU ${usedSKU}. ` + + 'Please modify your job config. ' + + `Available SKUs include: ${_.keys(result).join(', ')}`, + ); + } + } + } + } +} + +// confirm the storage settings +async function confirmStorage(clusterConfig, jobConfig) { + const pluginSettings = _.get(jobConfig, 'extras', {})[ + 'com.microsoft.pai.runtimeplugin' + ]; + const usedStorages = []; + if (pluginSettings) { + for (const pluginSetting of pluginSettings) { + if (pluginSetting.plugin === 'teamwise_storage') { + for (const storage of _.get( + pluginSetting, + 'parameters.storageConfigNames', + [], + )) { + usedStorages.push(storage); + } + } + } + if (usedStorages.length > 0) { + const url = urljoin(clusterConfig.uri, `/rest-server/api/v2/storages`); + const result = await requestBoundedClusterApi(clusterConfig.alias, url, { + headers: { + Authorization: `Bearer ${clusterConfig.token}`, + }, + }); + const availableStorages = new Set(result.storages.map(item => item.name)); + for (const usedStorage of usedStorages) { + if (!availableStorages.has(usedStorage)) { + const availableStorageHint = + result.storages.length === 0 + ? '' + : `Available storages include ${result.storages + .map(item => item.name) + .join(', ')}`; + throw new Error( + `We cannot find storage ${usedStorage} in bounded cluster ${clusterConfig.alias}. ` + + "Maybe the storage doesn't exist, or you don't have permission to it. " + + 'Please modify your job config. ' + + availableStorageHint, + ); + } + } + } + } +} + +// confirm the job name is not duplicate +async function confirmJobName(clusterConfig, jobConfig) { + const userName = clusterConfig.username; + const jobName = _.get(jobConfig, 'name'); + const url = urljoin( + clusterConfig.uri, + `/rest-server/api/v2/jobs/${userName}~${jobName}/config`, + ); + const response = await fetch(url, { + headers: { + Authorization: `Bearer ${clusterConfig.token}`, + }, + }); + const result = await response.json(); + if (!response.ok) { + if (_.get(result, 'code') === 'NoJobError') { + // OK, the job name doesn't exist + return; + } + throw new Error( + `There is an error during api call to bounded cluster ${clusterConfig.alias}. Detail message: ${result.message}.`, + ); + } else { + throw new Error( + `There is already a job with the name ${jobName} in the bounded cluster. Please modify your job config.`, + ); + } +} + +async function addTagToJob(userName, jobName, tagName) { + const restServerUri = new URL(config.restServerUri, window.location.href); + const url = urljoin( + restServerUri.toString(), + `/api/v2/jobs/${userName}~${jobName}/tag`, + ); + const result = await requestApi(url, { + method: 'PUT', + body: JSON.stringify({ value: tagName }), + headers: { + Authorization: `Bearer ${token}`, + }, + }); + return result; +} + +// `userName` and `jobName` is the userName/jobName on this cluster +// `clusterConfig`` is the bounded cluster to be transferred to. +// After transfer, the username in the bounded cluster should be clusterConfig.username, +// and the job name should be _.get(jobConfig, 'name'). +export async function transferJob( + userName, + jobName, + clusterConfig, + jobConfig, + jobConfigYAML, +) { + await Promise.all([ + confirmVC(clusterConfig, jobConfig), + confirmSKU(clusterConfig, jobConfig), + confirmJobName(clusterConfig, jobConfig), + confirmStorage(clusterConfig, jobConfig), + ]); + // add tag + await addTagToJob( + userName, + jobName, + `pai-transfer-attempt-to-${clusterConfig.alias}-url-${clusterConfig.uri}`, + ); + // submit job to the bounded cluster + const url = urljoin(clusterConfig.uri, `/rest-server/api/v2/jobs`); + await requestBoundedClusterApi(clusterConfig.alias, url, { + method: 'POST', + body: jobConfigYAML, + headers: { + Authorization: `Bearer ${clusterConfig.token}`, + }, + }); + + // add tag + const transferredURL = new URL( + `job-detail.html?${qs.stringify({ + username: clusterConfig.username, + jobName: _.get(jobConfig, 'name'), + })}`, + clusterConfig.uri, + ); + await addTagToJob( + userName, + jobName, + `pai-transferred-to-${clusterConfig.alias}-url-${transferredURL}`, + ); +} diff --git a/src/webportal/src/app/job/job-view/fabric/job-transfer/info-box.jsx b/src/webportal/src/app/job/job-view/fabric/job-transfer/info-box.jsx new file mode 100644 index 0000000000..177d333a82 --- /dev/null +++ b/src/webportal/src/app/job/job-view/fabric/job-transfer/info-box.jsx @@ -0,0 +1,50 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { + Stack, + Text, + Dialog, + DialogFooter, + PrimaryButton, +} from 'office-ui-fabric-react'; +import PropTypes from 'prop-types'; +import React from 'react'; + +function InfoBox({ hidden, title, message, onDismiss, redirectURL }) { + return ( + + ); +} + +InfoBox.propTypes = { + hidden: PropTypes.bool.isRequired, + title: PropTypes.string.isRequired, + message: PropTypes.string.isRequired, + onDismiss: PropTypes.func.isRequired, + redirectURL: PropTypes.string, +}; + +export default InfoBox; diff --git a/src/webportal/src/app/job/job-view/fabric/job-transfer/stop-box.jsx b/src/webportal/src/app/job/job-view/fabric/job-transfer/stop-box.jsx new file mode 100644 index 0000000000..998ed61190 --- /dev/null +++ b/src/webportal/src/app/job/job-view/fabric/job-transfer/stop-box.jsx @@ -0,0 +1,86 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { + Stack, + Text, + Dialog, + DialogFooter, + PrimaryButton, + DefaultButton, +} from 'office-ui-fabric-react'; +import PropTypes from 'prop-types'; +import React, { useState } from 'react'; +import qs from 'querystring'; +import { stopJob } from './conn'; +import { Spinner, SpinnerSize } from 'office-ui-fabric-react/lib/Spinner'; + +function StopBox({ hidden, userName, jobName, jobState, onDismiss }) { + const [isStopping, setIsStopping] = useState(false); + + const redirectToJobDetail = () => { + window.location.href = `/job-detail.html?${qs.stringify({ + username: userName, + jobName: jobName, + })}`; + }; + + const onClickStop = () => { + (async () => { + setIsStopping(true); + await stopJob(userName, jobName); + })() + .then(() => { + setIsStopping(false); + onDismiss(); + redirectToJobDetail(); + }) + .catch(err => { + setIsStopping(false); + alert(`An error happens during job stopping. Details: ${err.message}`); + }); + }; + + const onClickKeep = () => { + onDismiss(); + redirectToJobDetail(); + }; + + return ( + + ); +} + +StopBox.propTypes = { + hidden: PropTypes.bool.isRequired, + userName: PropTypes.string.isRequired, + jobName: PropTypes.string.isRequired, + jobState: PropTypes.string.isRequired, + onDismiss: PropTypes.func.isRequired, +}; + +export default StopBox; diff --git a/src/webportal/src/app/user/fabric/conn.js b/src/webportal/src/app/user/fabric/conn.js index 4131d5c7d6..a8567e4d7b 100644 --- a/src/webportal/src/app/user/fabric/conn.js +++ b/src/webportal/src/app/user/fabric/conn.js @@ -136,6 +136,29 @@ export const updateUserAdminRequest = async (username, admin) => { }); }; +export const updateBoundedClustersRequest = async ( + username, + updatedBoundedClusters, +) => { + const url = `${config.restServerUri}/api/v2/users/me`; + const token = checkToken(); + return fetchWrapper(url, { + method: 'PUT', + headers: { + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + data: { + username: username, + extension: { + boundedClusters: updatedBoundedClusters, + }, + }, + patch: true, + }), + }); +}; + export const getAllVcsRequest = async () => { const url = `${config.restServerUri}/api/v2/virtual-clusters`; const token = checkToken(); diff --git a/src/webportal/src/app/user/fabric/user-profile.jsx b/src/webportal/src/app/user/fabric/user-profile.jsx index 96f40fd597..f4802e58e1 100644 --- a/src/webportal/src/app/user/fabric/user-profile.jsx +++ b/src/webportal/src/app/user/fabric/user-profile.jsx @@ -8,9 +8,11 @@ import cookies from 'js-cookie'; import PropTypes from 'prop-types'; import { FontClassNames, FontWeights, getTheme } from '@uifabric/styling'; import { DefaultButton } from 'office-ui-fabric-react'; +import { isEmpty, cloneDeep } from 'lodash'; import Card from '../../components/card'; import { SpinnerLoading } from '../../components/loading'; +import config from '../../config/webportal.config'; import { getUserRequest, getAllVcsRequest, @@ -21,6 +23,7 @@ import { updateUserEmailRequest, listStorageDetailRequest, getGroupsRequest, + updateBoundedClustersRequest, } from './conn'; import t from '../../components/tachyons.scss'; @@ -28,6 +31,8 @@ import { VirtualClusterDetailsList } from '../../home/home/virtual-cluster-stati import TokenList from './user-profile/token-list'; import UserProfileHeader from './user-profile/header'; import StorageList from './user-profile/storage-list'; +import BoundedClusterDialog from './user-profile/bounded-cluster-dialog'; +import BoundedClusterList from './user-profile/bounded-cluster-list'; const UserProfileCard = ({ title, children, headerButton }) => { const { spacing } = getTheme(); @@ -53,6 +58,8 @@ UserProfileCard.propTypes = { children: PropTypes.node, }; +const enableJobTransfer = config.enableJobTransfer; + const UserProfile = () => { const [loading, setLoading] = useState(true); const [userInfo, setUserInfo] = useState(null); @@ -60,7 +67,9 @@ const UserProfile = () => { const [tokens, setTokens] = useState(null); const [storageDetails, setStorageDetails] = useState(null); const [groups, setGroups] = useState(null); - + const [showBoundedClusterDialog, setShowBoundedClusterDialog] = useState( + false, + ); const [processing, setProcessing] = useState(false); useEffect(() => { @@ -135,6 +144,43 @@ const UserProfile = () => { await getTokenRequest().then(res => setTokens(res.tokens)); }); + const onAddBoundedCluster = async clusterConfig => { + let updatedBoundedClusters = {}; + if (userInfo.extension.boundedClusters) { + updatedBoundedClusters = cloneDeep(userInfo.extension.boundedClusters); + } + updatedBoundedClusters[clusterConfig.alias] = { + uri: clusterConfig.uri, + username: clusterConfig.username, + token: clusterConfig.token, + }; + await updateBoundedClustersRequest( + userInfo.username, + updatedBoundedClusters, + ); + const updatedUserInfo = await getUserRequest(userInfo.username); + setUserInfo(updatedUserInfo); + }; + + const onDeleteBoundedCluster = async clusterAlias => { + let updatedBoundedClusters = {}; + if (userInfo.extension.boundedClusters) { + updatedBoundedClusters = cloneDeep(userInfo.extension.boundedClusters); + } + if (!(clusterAlias in updatedBoundedClusters)) { + throw new Error( + `Cannot find cluster ${clusterAlias} in your bounded clusters!`, + ); + } + delete updatedBoundedClusters[clusterAlias]; + await updateBoundedClustersRequest( + userInfo.username, + updatedBoundedClusters, + ); + const updatedUserInfo = await getUserRequest(userInfo.username); + setUserInfo(updatedUserInfo); + }; + const { spacing } = getTheme(); if (loading) { @@ -173,6 +219,39 @@ const UserProfile = () => { groups={groups} /> + {enableJobTransfer === 'true' && ( + setShowBoundedClusterDialog(true)} + > + Add a bounded cluster + + } + > + {showBoundedClusterDialog && ( + setShowBoundedClusterDialog(false)} + onAddBoundedCluster={onAddBoundedCluster} + /> + )} + {!isEmpty(userInfo.extension.boundedClusters) && ( + + )} + {isEmpty(userInfo.extension.boundedClusters) && ( +
+ There is no added bounded cluster. +
+ )} +
+ )} ); diff --git a/src/webportal/src/app/user/fabric/user-profile/bounded-cluster-dialog.jsx b/src/webportal/src/app/user/fabric/user-profile/bounded-cluster-dialog.jsx new file mode 100644 index 0000000000..572beb4758 --- /dev/null +++ b/src/webportal/src/app/user/fabric/user-profile/bounded-cluster-dialog.jsx @@ -0,0 +1,180 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import React, { useState } from 'react'; +import PropTypes from 'prop-types'; +import { isEmpty } from 'lodash'; +import { + DefaultButton, + PrimaryButton, + DialogType, + Dialog, + DialogFooter, + TextField, +} from 'office-ui-fabric-react'; + +import t from '../../../components/tachyons.scss'; +import Joi from 'joi-browser'; +import { PAIV2 } from '@microsoft/openpai-js-sdk'; + +const validateInput = async (clusterAlias, clusterUri, username, token) => { + const inputSchema = Joi.object() + .keys({ + alias: Joi.string() + .regex(/^[A-Za-z0-9\-_]+$/) + .required(), + uri: Joi.string() + .uri() + .required(), + username: Joi.string().required(), + token: Joi.string() + .trim() + .required(), + }) + .required(); + const input = { + alias: clusterAlias, + uri: clusterUri, + username: username, + token: token, + }; + const { error, value } = Joi.validate(input, inputSchema); + if (error) { + throw new Error(error); + } + + const client = new PAIV2.OpenPAIClient({ + rest_server_uri: new URL('rest-server', value.uri), + username: value.username, + token: value.token, + https: value.uri.startsWith('https'), + }); + + // check if token is valid + try { + await client.virtualCluster.listVirtualClusters(); + } catch (err) { + throw new Error( + `Try to connect the cluster but failed. Details: ${err.message}`, + ); + } + + return value; +}; + +const BoundedClusterDialog = ({ onDismiss, onAddBoundedCluster }) => { + const [error, setError] = useState(''); + const [processing, setProcessing] = useState(false); + const [clusterAlias, setClusterAlias] = useState(''); + const [clusterUri, setClusterUri] = useState(''); + const [username, setUsername] = useState(''); + const [token, setToken] = useState(''); + + const onAddAsync = async () => { + const clusterConfig = await validateInput( + clusterAlias, + clusterUri, + username, + token, + ); + await onAddBoundedCluster(clusterConfig); + }; + + const onAdd = () => { + setProcessing(true); + onAddAsync() + .then( + // If successful, close this dialog + () => onDismiss(), + ) + .catch( + // If error, show the error, and don't close this dialog + e => { + console.error(e); + setError(e.message); + }, + ) + .finally(() => setProcessing(false)); + }; + + return ( +
+ + +
+ ); +}; + +BoundedClusterDialog.propTypes = { + onDismiss: PropTypes.func.isRequired, + onAddBoundedCluster: PropTypes.func.isRequired, +}; + +export default BoundedClusterDialog; diff --git a/src/webportal/src/app/user/fabric/user-profile/bounded-cluster-list.jsx b/src/webportal/src/app/user/fabric/user-profile/bounded-cluster-list.jsx new file mode 100644 index 0000000000..9923714c77 --- /dev/null +++ b/src/webportal/src/app/user/fabric/user-profile/bounded-cluster-list.jsx @@ -0,0 +1,173 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import c from 'classnames'; +import React, { useState } from 'react'; +import PropTypes from 'prop-types'; +import { + DetailsList, + DetailsListLayoutMode, + SelectionMode, + CommandBarButton, + DialogType, + Dialog, + DialogFooter, + PrimaryButton, + DefaultButton, +} from 'office-ui-fabric-react'; + +import t from '../../../components/tachyons.scss'; +import CopyButton from '../../../components/copy-button'; + +const BoundedClusterList = ({ boundedClusters, onDelete }) => { + const [processing, setProcessing] = useState(false); + const [deleteClusterAlias, setDeleteClusterAlias] = useState(null); + + const boundedClusterList = []; + for (const alias in boundedClusters) { + boundedClusterList.push({ + alias: alias, + uri: boundedClusters[alias].uri, + username: boundedClusters[alias].username, + token: boundedClusters[alias].token, + }); + } + // sort by alias + boundedClusterList.sort((c1, c2) => (c1.alias > c2.alias ? 1 : -1)); + + const columns = [ + { + key: 'token', + minWidth: 120, + name: 'Token', + isResizable: true, + onRender(clusterConfig) { + return ( +
+
{clusterConfig.token}
+ +
+ ); + }, + }, + { + key: 'clusterAlias', + minWidth: 150, + maxWidth: 150, + name: 'Cluster Alias', + isResizable: true, + onRender(clusterConfig) { + return ( +
+ {clusterConfig.alias} +
+ ); + }, + }, + { + key: 'clusterUri', + minWidth: 150, + maxWidth: 150, + name: 'Cluster URI', + isResizable: true, + onRender(clusterConfig) { + return ( +
+ {clusterConfig.uri} +
+ ); + }, + }, + { + key: 'username', + minWidth: 150, + name: 'Username', + isResizable: true, + onRender(clusterConfig) { + return ( +
+ {clusterConfig.username} +
+ ); + }, + }, + { + key: 'action', + minWidth: 100, + name: 'Action', + isResizable: true, + onRender(clusterConfig) { + return ( +
+ setDeleteClusterAlias(clusterConfig.alias)} + disabled={processing} + /> +
+ ); + }, + }, + ]; + + return ( +
+ + +
+ ); +}; + +BoundedClusterList.propTypes = { + boundedClusters: PropTypes.object.isRequired, + onDelete: PropTypes.func.isRequired, +}; + +export default BoundedClusterList;