diff --git a/.cfignore b/.cfignore index 148a77cdae..4b6eebf7b1 100644 --- a/.cfignore +++ b/.cfignore @@ -3,3 +3,7 @@ node_modules/ bower_components/ dist/ components/*/backend/vendor +dev-certs/ +out/ +outputs/ +tmp/ diff --git a/.gitignore b/.gitignore index f0d4bc2f65..7a0258a148 100644 --- a/.gitignore +++ b/.gitignore @@ -39,4 +39,9 @@ deploy/hsc-upgrade-volume/ deploy/development.rc deploy/ci/secrets.yml deploy/kubernetes/values.yaml +deploy/cloud-foundry/db-migration/goose outputs/ +deploy/kubernetes/console/charts/ +deploy/stratos-ui-release/.dev_builds +deploy/stratos-ui-release/blobs +deploy/stratos-ui-release/dev_releases/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000000..35b7f2850c --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,41 @@ +# Change Log + +## 0.9.2 Alpha-3 Release +[Full Changelog](https://github.com/SUSE/stratos-ui/compare/0.9.1...0.9.2) + +Third alpha release containing the following bug fixes and minor improvements: + +- Fix a few service instance bugs [\#1226](https://github.com/SUSE/stratos-ui/pull/1226) +- Fixes issues where trailing slash in CF endpoint causes problems [\#1224](https://github.com/SUSE/stratos-ui/pull/1224) +- Reuse unbound routes [\#1223](https://github.com/SUSE/stratos-ui/pull/1223) +- Optionally enable endpoints-dashboard via env var for use in cf push [\#1221](https://github.com/SUSE/stratos-ui/pull/1221) +- Use stable name for docker registry in CI pipelines [\#1220](https://github.com/SUSE/stratos-ui/pull/1220) +- Improve documentation [\#1219](https://github.com/SUSE/stratos-ui/pull/1219) +- Add upgrade documentation for helm repository based installation [\#1218](https://github.com/SUSE/stratos-ui/pull/1218) +- Update CI piplines for environment [\#1217](https://github.com/SUSE/stratos-ui/pull/1217) +- Fixed docker image name for all-in-one deployment [\#1216](https://github.com/SUSE/stratos-ui/pull/1216) +- Persist app wall selection of cf/org/space in local storage [\#1214](https://github.com/SUSE/stratos-ui/pull/1214) +- Fix 'remove' i10n in create space modal [\#1213](https://github.com/SUSE/stratos-ui/pull/1213) +- Fix terminate instance UI [\#1211](https://github.com/SUSE/stratos-ui/pull/1211) +- Fix issue where previously selected language was not shown on landing page [\#1209](https://github.com/SUSE/stratos-ui/pull/1209) +- Add missing defaults to values.yaml [\#1207](https://github.com/SUSE/stratos-ui/pull/1207) +- Fix localisation in unmap route from apps modal \(unmap route from app is fine\) [\#1206](https://github.com/SUSE/stratos-ui/pull/1206) +- Split deploy app wizard service into smaller chunks [\#1202](https://github.com/SUSE/stratos-ui/pull/1202) +- UX Review: Update landing page \(login + setup screens\) [\#1200](https://github.com/SUSE/stratos-ui/pull/1200) +- Remember grid or list state for the app wall [\#1199](https://github.com/SUSE/stratos-ui/pull/1199) + +## 0.9.1 Alpha-2 Release +[Full Changelog](https://github.com/SUSE/stratos-ui/compare/0.9.0...0.9.1) + +Second alpha release contains the following fixes: + +- Improved documentation when deploying using Helm [\#1201](https://github.com/SUSE/stratos-ui/pull/1201) +- Added the ability to deploy the Console helm chart without using shared volumes, to make it easier to deploy in multi-node clusters with basic storage provisioner such as `hostpath` [\#1204](https://github.com/SUSE/stratos-ui/pull/1204) +- Specified the `cflinuxfs2` stack to the CF manifest.yaml, since default CAASP stack `opensuse42` is unable to deploy the app [\#1205](https://github.com/SUSE/stratos-ui/pull/1205) +- Changed root of the volume mount for Postgres in kubernetes to address permission issue in certain environments [\#1203](https://github.com/SUSE/stratos-ui/pull/1203) + +## 0.9.0 Alpha-1 Release + +First Alpha release of the Stratos UI Console. + +For information on the Alpha feature set and on deploying the Console, please start with the main [README](https://github.com/SUSE/stratos-ui/blob/0.9.0/README.md) documentation. diff --git a/README.md b/README.md index bc844314b1..58ad778d49 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,6 @@ Stratos UI can be deployed in the following environments: 3. Docker, using docker compose. See [guide](deploy/docker-compose) 4. Docker, single container deploying all components. See [guide](deploy/all-in-one) - ## Quick Start To get started quickly, we recommend following the steps to deploy the Stratos UI Console as a Cloud Foundry Application - see [here](deploy/cloud-foundry). diff --git a/build/bk-build-utils.js b/build/bk-build-utils.js index cbbc46b314..ad3cd88a4d 100644 --- a/build/bk-build-utils.js +++ b/build/bk-build-utils.js @@ -51,8 +51,12 @@ function skipGlideInstall() { if (isLocalDevBuild()) { - // Check if we can find the golang folder - indicates glide has run before - var folder = path.join(env.GOPATH, 'src', 'golang.org'); + // Skip glide install if ... + // .. we're in test mode and we've found a common test dependency + // .. we're building the backend and we've found a common dependency + var folder = prepareBuild.getBuildTest() + ? path.join(env.GOPATH, 'src', 'github.com', 'smartystreets', 'goconvey', 'convey') + : path.join(env.GOPATH, 'src', 'github.com', 'labstack', 'echo'); return fs.existsSync(folder); } return false; diff --git a/build/components.js b/build/components.js index 3b18db6038..7ec62eb6f7 100644 --- a/build/components.js +++ b/build/components.js @@ -139,6 +139,8 @@ } }); }); + // Exclude backend/vendor folder from bower_component plugins + globs.bowerFull.push('!' + path.relative(baseFolder,path.join(wildBowerFolder, 'backend/vendor/**'))); return globs; } diff --git a/build/coverage.conf.js b/build/coverage.conf.js index e9e908ddda..1bad891f9e 100644 --- a/build/coverage.conf.js +++ b/build/coverage.conf.js @@ -28,11 +28,11 @@ jasmine.getEnv().addReporter(new function () { var deferred = []; var results = []; -/* - this.specStarted = function (spec) { - console.log('Spec Start : ' + spec.id + ':: ' + spec.fullName); - }; -*/ + /* + this.specStarted = function (spec) { + console.log('Spec Start : ' + spec.id + ':: ' + spec.fullName); + }; + */ this.specDone = function (spec) { //console.log('Spec Finished: ' + spec.id + ':: ' + spec.fullName); if (spec.status !== 'failed' && spec.status !== 'disabled') { diff --git a/build/dev-dependencies.js b/build/dev-dependencies.js new file mode 100644 index 0000000000..5e1350c808 --- /dev/null +++ b/build/dev-dependencies.js @@ -0,0 +1,17 @@ +(function () { + 'use strict'; + + var gutil = require('gulp-util'); + + var deps = {}; + + module.exports = { + get: function (string) { + if (gutil.env.devMode && !deps[string]) { + deps[string] = require(string); + } + return deps[string]; + } + }; + +})(); diff --git a/build/e2e.gulp.js b/build/e2e.gulp.js index b821163a94..1242915ed3 100644 --- a/build/e2e.gulp.js +++ b/build/e2e.gulp.js @@ -18,6 +18,9 @@ var path = require('path'); var runSequence = require('run-sequence'); var ngAnnotate = require('gulp-ng-annotate'); + var e2eConfigFile = './build/coverage.conf.js'; + var glob = require('glob'); + var fs = require('fs'); var components; gulp.task('prepare:e2e', function () { @@ -29,11 +32,31 @@ }); gulp.task('coverage-combine', function (cb) { - var combine = require('istanbul-combine'); var coverageDir = path.resolve(__dirname, '..', 'out', 'coverage-report'); + var toCombine = path.join(coverageDir, '_json/*.json'); + + // Remove 'frontend' from both key and path for all coverage reports. This makes the path invalid however now + // keys align across both e2e tests (components don't show '/frontend') and unit (components show '/frontend' where + // used). The html is fine except for the file paths drop '/frontend' in places. This behaviour matches the + // standalone output of the e2e coverage reports. + glob(toCombine, null, function (err, files) { + if (err) { + cb(err); + return; + } + + for (var i = 0; i < files.length; i++) { + var file = files[i]; + var content = fs.readFileSync(file, 'utf8'); + content = content.replace(/(components\/[^\/]+)\/frontend(\/src\/)/g, '$1$2'); + fs.writeFileSync(file, content); + } + }); + + var combine = require('istanbul-combine'); var opts = { dir: path.join(coverageDir, 'combined'), - pattern: path.join(coverageDir, '_json/*.json'), + pattern: toCombine, print: 'summary', reporters: { html: {} @@ -42,6 +65,13 @@ combine(opts, cb); }); + gulp.task('e2e:nocov', function () { + e2eConfigFile = './build/protractor.conf.js'; + runSequence( + 'e2e:tests' + ); + }); + gulp.task('e2e:tests', function (cb) { // Use the protractor in our node_modules folder var cmd = './node_modules/protractor/bin/protractor'; @@ -50,7 +80,7 @@ options.env.NODE_ENV = 'development'; options.env.env = 'development'; - var args = ['./build/coverage.conf.js']; + var args = [e2eConfigFile]; if (process.env.STRATOS_E2E_SUITE) { args.push('--suite'); args.push(process.env.STRATOS_E2E_SUITE); diff --git a/build/gulp.config.js b/build/gulp.config.js index a9250258ab..0e12d6e45b 100644 --- a/build/gulp.config.js +++ b/build/gulp.config.js @@ -49,7 +49,7 @@ }, 'bootstrap-sass': { main: [ -// 'assets/stylesheets/_bootstrap.scss' + // 'assets/stylesheets/_bootstrap.scss' ] } } diff --git a/build/i18n.gulp.js b/build/i18n.gulp.js index 28ad7afe54..3f54993be6 100644 --- a/build/i18n.gulp.js +++ b/build/i18n.gulp.js @@ -65,7 +65,7 @@ contents: new Buffer(res) }); - // console.log(res); + // console.log(res); that.push(file); }); @@ -98,7 +98,7 @@ dest[k] = v; } } else { - // Merge again + // Merge again merge(dest[k], v); } } diff --git a/build/main.gulp.js b/build/main.gulp.js index 60577aca92..dcd0ac1df4 100644 --- a/build/main.gulp.js +++ b/build/main.gulp.js @@ -30,6 +30,7 @@ var i18n = require('./i18n.gulp'); var cleanCSS = require('gulp-clean-css'); var config = require('./gulp.config'); + var devDeps = require('./dev-dependencies'); // Pull in the gulp tasks for e2e tests require('./e2e.gulp'); @@ -132,6 +133,7 @@ // Compile SCSS to CSS gulp.task('css:generate', function () { + var sourcemaps = devDeps.get('gulp-sourcemaps'); var scssFile = './bower_components/index.scss'; utils.generateScssFile('./bower_components/index.scss', components.findMainFile('**/*.scss'), components.getBowerFolder()); return gulp @@ -142,17 +144,23 @@ this.emit('end'); } }))) - .pipe(sass()) - .pipe(autoprefixer({browsers: ['last 2 versions'], cascade: false})) + .pipe(gutil.env.devMode ? sourcemaps.init() : gutil.noop()) + .pipe(sass().on('error', sass.logError)) + .pipe(gutil.env.devMode ? sourcemaps.write({ includeContent: false}) : gutil.noop()) + .pipe(autoprefixer({ browsers: ['last 2 version'], cascade: false })) + .pipe(gutil.env.devMode ? sourcemaps.write() : gutil.noop()) .pipe(gulp.dest(paths.dist)); }); gulp.task('css', ['css:generate'], function () { + var sourcemaps = devDeps.get('gulp-sourcemaps'); var cssFiles = bowerFiles.css; cssFiles.push(path.join(paths.dist, 'index.css')); return gulp.src(cssFiles) .pipe(concat('index.css')) - .pipe(cleanCSS({})) + .pipe(gutil.env.devMode ? sourcemaps.init({loadMaps: true}) : gutil.noop()) + .pipe(cleanCSS()) + .pipe(gutil.env.devMode ? sourcemaps.write() : gutil.noop()) .pipe(gulp.dest(paths.dist)); }); diff --git a/build/protractor.conf.js b/build/protractor.conf.js index 097454cf5c..47389990b1 100644 --- a/build/protractor.conf.js +++ b/build/protractor.conf.js @@ -51,10 +51,13 @@ } }, + // Default is 11000 + allScriptsTimeout: 20000, + params: { protocol: 'https://', - host: 'localhost', - port: '3100', + host: secrets.console.host || 'localhost', + port: secrets.console.port || '3100', credentials: { admin: secrets.console.admin, user: secrets.console.user @@ -133,8 +136,19 @@ browser.addMockModule('disableNgAnimate', disableNgAnimate); + var setLocale = function () { + angular.module('setLocale', []).config(['$injector', function ($injector) { + // Override whatever language the running browser is in, this removes unexpected http request to + // locale_.json + var languageServiceProvider = $injector.get('languageServiceProvider'); + languageServiceProvider.setBrowserLocale('en_US'); + }]); + }; + + browser.addMockModule('setLocale', setLocale); + // Optional. Really nice to see the progress of the tests while executing - var SpecReporter = require('jasmine-spec-reporter'); + var SpecReporter = require('jasmine-spec-reporter').SpecReporter; jasmine.getEnv().addReporter(new SpecReporter({ displayPendingSpec: false, displayPendingSummary: false, @@ -151,6 +165,7 @@ }, jasmineNodeOpts: { + defaultTimeoutInterval: 45000, // disable default jasmine report (using jasmine-spec-reporter) print: function () { } diff --git a/build/tools/get-glide.sh b/build/tools/get-glide.sh new file mode 100755 index 0000000000..cb21e4575b --- /dev/null +++ b/build/tools/get-glide.sh @@ -0,0 +1,121 @@ +#!/bin/sh + +# The install script is licensed under the MIT license Glide itself is under. +# See https://github.com/Masterminds/glide/blob/master/LICENSE for more details. + +# To run this script execute: +# `curl https://glide.sh/get | sh` + +PROJECT_NAME="glide" + +# LGOBIN represents the local bin location. This can be either the GOBIN, if set, +# or the GOPATH/bin. + +LGOBIN="" + +verifyGoInstallation() { + GO=$(which go) + if [ "$?" = "1" ]; then + echo "$PROJECT_NAME needs go. Please intall it first." + exit 1 + fi + if [ -z "$GOPATH" ]; then + echo "$PROJECT_NAME needs environment variable "'$GOPATH'". Set it before continue." + exit 1 + fi + if [ -n "$GOBIN" ]; then + if [ ! -d "$GOBIN" ]; then + echo "$GOBIN "'($GOBIN)'" folder not found. Please create it before continue." + exit 1 + fi + LGOBIN="$GOBIN" + else + if [ ! -d "$GOPATH/bin" ]; then + echo "$GOPATH/bin "'($GOPATH/bin)'" folder not found. Please create it before continue." + exit 1 + fi + LGOBIN="$GOPATH/bin" + fi + +} + +initArch() { + ARCH=$(uname -m) + case $ARCH in + armv5*) ARCH="armv5";; + armv6*) ARCH="armv6";; + armv7*) ARCH="armv7";; + aarch64) ARCH="arm64";; + x86) ARCH="386";; + x86_64) ARCH="amd64";; + i686) ARCH="386";; + i386) ARCH="386";; + esac +} + +initOS() { + OS=$(echo `uname`|tr '[:upper:]' '[:lower:]') + + case "$OS" in + # Minimalist GNU for Windows + mingw*) OS='windows';; + esac +} + +downloadFile() { + TAG=$(wget -q -O - https://glide.sh/version) + LATEST_RELEASE_URL="https://api.github.com/repos/Masterminds/$PROJECT_NAME/releases/tags/$TAG" + LATEST_RELEASE_JSON=$(wget -q -O - "$LATEST_RELEASE_URL") + GLIDE_DIST="glide-$TAG-$OS-$ARCH.tar.gz" + # || true forces this command to not catch error if grep does not find anything + DOWNLOAD_URL=$(echo "$LATEST_RELEASE_JSON" | grep 'browser_' | cut -d\" -f4 | grep "$GLIDE_DIST") || true + if [ -z "$DOWNLOAD_URL" ]; then + echo "Sorry, we dont have a dist for your system: $OS $ARCH" + echo "You can ask one here: https://github.com/Masterminds/$PROJECT_NAME/issues" + exit 1 + else + GLIDE_TMP_FILE="/tmp/$GLIDE_DIST" + echo "Downloading $DOWNLOAD_URL" + wget -q -O "$GLIDE_TMP_FILE" "$DOWNLOAD_URL" + fi +} + +installFile() { + GLIDE_TMP="/tmp/$PROJECT_NAME" + mkdir -p "$GLIDE_TMP" + tar xf "$GLIDE_TMP_FILE" -C "$GLIDE_TMP" + GLIDE_TMP_BIN="$GLIDE_TMP/$OS-$ARCH/$PROJECT_NAME" + cp "$GLIDE_TMP_BIN" "$LGOBIN" +} + +bye() { + result=$? + if [ "$result" != "0" ]; then + echo "Fail to install $PROJECT_NAME" + fi + exit $result +} + +testVersion() { + set +e + GLIDE="$(which $PROJECT_NAME)" + if [ "$?" = "1" ]; then + echo "$PROJECT_NAME not found. Did you add "'$LGOBIN'" to your "'$PATH?' + exit 1 + fi + set -e + GLIDE_VERSION=$($PROJECT_NAME -v) + echo "$GLIDE_VERSION installed successfully" +} + +# Execution + +#Stop execution on any error +trap "bye" EXIT +verifyGoInstallation +set -e +initArch +initOS +downloadFile +installFile +testVersion diff --git a/components/about-app/src/plugin.config.js b/components/about-app/src/plugin.config.js index d5649d071b..c4468576c1 100644 --- a/components/about-app/src/plugin.config.js +++ b/components/about-app/src/plugin.config.js @@ -4,10 +4,10 @@ // register this plugin application to the platform if (env && env.registerApplication) { env.registerApplication( - 'aboutApp', // plugin application identity - 'about-app', // plugin application's root angular module name - 'plugins/about-app/', // plugin application's base path - 'about-app' // plugin applications's start state + 'aboutApp', // plugin application identity + 'about-app', // plugin application's root angular module name + 'plugins/about-app/', // plugin application's base path + 'about-app' // plugin applications's start state ); } diff --git a/components/app-core/backend/auth.go b/components/app-core/backend/auth.go index c72800b10a..48dc86ad0d 100644 --- a/components/app-core/backend/auth.go +++ b/components/app-core/backend/auth.go @@ -40,7 +40,7 @@ const UAAAdminIdentifier = "stratos.admin" const CFAdminIdentifier = "cloud_controller.admin" // SessionExpiresOnHeader Custom header for communicating the session expiry time to clients -const SessionExpiresOnHeader = "X-Cnap-Session-Expires-On" +const SessionExpiresOnHeader = "X-Cap-Session-Expires-On" // EmptyCookieMatcher - Used to detect and remove empty Cookies sent by certain browsers var EmptyCookieMatcher *regexp.Regexp = regexp.MustCompile(portalSessionName + "=(?:;[ ]*|$)") @@ -184,7 +184,6 @@ func (p *portalProxy) DoLoginToCNSI(c echo.Context, cnsiGUID string) (*interface } func (p *portalProxy) verifyLoginToCNSI(c echo.Context) error { - log.Debug("verifyLoginToCNSI") cnsiGUID := c.FormValue("cnsi_guid") @@ -327,6 +326,7 @@ func (p *portalProxy) logout(c echo.Context) error { func (p *portalProxy) getUAATokenWithCreds(skipSSLValidation bool, username, password, client, clientSecret, authEndpoint string) (*UAAResponse, error) { log.Debug("getUAATokenWithCreds") + body := url.Values{} body.Set("grant_type", "password") body.Set("username", username) @@ -338,6 +338,7 @@ func (p *portalProxy) getUAATokenWithCreds(skipSSLValidation bool, username, pas func (p *portalProxy) getUAATokenWithRefreshToken(skipSSLValidation bool, refreshToken, client, clientSecret, authEndpoint string) (*UAAResponse, error) { log.Debug("getUAATokenWithRefreshToken") + body := url.Values{} body.Set("grant_type", "refresh_token") body.Set("refresh_token", refreshToken) @@ -386,6 +387,7 @@ func (p *portalProxy) getUAAToken(body url.Values, skipSSLValidation bool, clien func (p *portalProxy) saveUAAToken(u userTokenInfo, authTok string, refreshTok string) (interfaces.TokenRecord, error) { log.Debug("saveUAAToken") + key := u.UserGUID tokenRecord := interfaces.TokenRecord{ AuthToken: authTok, @@ -403,6 +405,7 @@ func (p *portalProxy) saveUAAToken(u userTokenInfo, authTok string, refreshTok s func (p *portalProxy) saveCNSIToken(cnsiID string, u userTokenInfo, authTok string, refreshTok string) (interfaces.TokenRecord, error) { log.Debug("saveCNSIToken") + tokenRecord := interfaces.TokenRecord{ AuthToken: authTok, RefreshToken: refreshTok, @@ -420,6 +423,7 @@ func (p *portalProxy) saveCNSIToken(cnsiID string, u userTokenInfo, authTok stri func (p *portalProxy) deleteCNSIToken(cnsiID string, userGUID string) error { log.Debug("deleteCNSIToken") + err := p.unsetCNSITokenRecord(cnsiID, userGUID) if err != nil { log.Errorf("%v", err) @@ -431,6 +435,7 @@ func (p *portalProxy) deleteCNSIToken(cnsiID string, userGUID string) error { func (p *portalProxy) GetUAATokenRecord(userGUID string) (interfaces.TokenRecord, error) { log.Debug("GetUAATokenRecord") + tokenRepo, err := tokens.NewPgsqlTokenRepository(p.DatabaseConnectionPool) if err != nil { log.Errorf("Database error getting repo for UAA token: %v", err) @@ -448,6 +453,7 @@ func (p *portalProxy) GetUAATokenRecord(userGUID string) (interfaces.TokenRecord func (p *portalProxy) setUAATokenRecord(key string, t interfaces.TokenRecord) error { log.Debug("setUAATokenRecord") + tokenRepo, err := tokens.NewPgsqlTokenRepository(p.DatabaseConnectionPool) if err != nil { return fmt.Errorf("Database error getting repo for UAA token: %v", err) @@ -463,6 +469,7 @@ func (p *portalProxy) setUAATokenRecord(key string, t interfaces.TokenRecord) er func (p *portalProxy) verifySession(c echo.Context) error { log.Debug("verifySession") + sessionExpireTime, err := p.GetSessionInt64Value(c, "exp") if err != nil { msg := "Could not find session date" @@ -542,6 +549,7 @@ func (p *portalProxy) verifySession(c echo.Context) error { func (p *portalProxy) getUAAUser(userGUID string) (*interfaces.ConnectedUser, error) { log.Debug("getUAAUser") + // get the uaa token record uaaTokenRecord, err := p.GetUAATokenRecord(userGUID) if err != nil { @@ -573,6 +581,7 @@ func (p *portalProxy) getUAAUser(userGUID string) (*interfaces.ConnectedUser, er func (p *portalProxy) GetCNSIUser(cnsiGUID string, userGUID string) (*interfaces.ConnectedUser, bool) { log.Debug("GetCNSIUser") + // get the uaa token record cfTokenRecord, ok := p.GetCNSITokenRecord(cnsiGUID, userGUID) if !ok { diff --git a/components/app-core/backend/auth_test.go b/components/app-core/backend/auth_test.go index 324774604e..6c68a6e152 100644 --- a/components/app-core/backend/auth_test.go +++ b/components/app-core/backend/auth_test.go @@ -49,7 +49,7 @@ func TestLoginToUAA(t *testing.T) { WillReturnRows(expectNoRows()) mock.ExpectExec(insertIntoTokens). - // WithArgs(mockUserGUID, "uaa", mockTokenRecord.AuthToken, mockTokenRecord.RefreshToken, newExpiry). + // WithArgs(mockUserGUID, "uaa", mockTokenRecord.AuthToken, mockTokenRecord.RefreshToken, newExpiry). WillReturnResult(sqlmock.NewResult(1, 1)) Convey("Should not fail to login", func() { @@ -88,7 +88,6 @@ func TestLoginToUAAWithBadCreds(t *testing.T) { pp.Config.ConsoleConfig.UAAEndpoint = uaaUrl pp.Config.ConsoleConfig.SkipSSLValidation = true - err := pp.loginToUAA(ctx) Convey("Login to UAA should fail", func() { So(err, ShouldNotBeNil) @@ -129,9 +128,8 @@ func TestLoginToUAAButCantSaveToken(t *testing.T) { pp.Config.ConsoleConfig.UAAEndpoint = uaaUrl pp.Config.ConsoleConfig.SkipSSLValidation = true - mock.ExpectQuery(selectAnyFromTokens). - // WithArgs(mockUserGUID). + // WithArgs(mockUserGUID). WillReturnRows(sqlmock.NewRows([]string{"COUNT(*)"}).AddRow("0")) // --- set up the database expectation for pp.saveUAAToken @@ -207,7 +205,7 @@ func TestLoginToCNSI(t *testing.T) { // Setup expectation that the CNSI token will get saved //encryptedUAAToken, _ := tokens.EncryptToken(pp.Config.EncryptionKeyInBytes, mockUAAToken) mock.ExpectExec(insertIntoTokens). - //WithArgs(mockCNSIGUID, mockUserGUID, "cnsi", encryptedUAAToken, encryptedUAAToken, sessionValues["exp"]). + //WithArgs(mockCNSIGUID, mockUserGUID, "cnsi", encryptedUAAToken, encryptedUAAToken, sessionValues["exp"]). WillReturnResult(sqlmock.NewResult(1, 1)) // do the call @@ -660,7 +658,7 @@ func TestVerifySessionExpired(t *testing.T) { mock.ExpectQuery(selectAnyFromTokens). WillReturnRows(sqlmock.NewRows([]string{"auth_token", "refresh_token", "token_expiry"}). - AddRow(mockUAAToken, mockUAAToken, sessionValues["exp"])) + AddRow(mockUAAToken, mockUAAToken, sessionValues["exp"])) err := pp.verifySession(ctx) Convey("Should fail to verify session", func() { diff --git a/components/app-core/backend/cnsi.go b/components/app-core/backend/cnsi.go index f87249679f..6a7a7b848d 100644 --- a/components/app-core/backend/cnsi.go +++ b/components/app-core/backend/cnsi.go @@ -262,19 +262,31 @@ func (p *portalProxy) GetCNSIRecord(guid string) (interfaces.CNSIRecord, error) return rec, nil } -func (p *portalProxy) cnsiRecordExists(endpoint string) bool { - log.Debug("cnsiRecordExists") +func (p *portalProxy) GetCNSIRecordByEndpoint(endpoint string) (interfaces.CNSIRecord, error) { + log.Debug("GetCNSIRecordByEndpoint") + var rec interfaces.CNSIRecord + cnsiRepo, err := cnsis.NewPostgresCNSIRepository(p.DatabaseConnectionPool) if err != nil { - return false + return rec, err } - _, err = cnsiRepo.FindByAPIEndpoint(endpoint) + rec, err = cnsiRepo.FindByAPIEndpoint(endpoint) if err != nil { - return false + return rec, err } - return true + // Ensure that trailing slash is removed from the API Endpoint + rec.APIEndpoint.Path = strings.TrimRight(rec.APIEndpoint.Path, "/") + + return rec, nil +} + +func (p *portalProxy) cnsiRecordExists(endpoint string) bool { + log.Debug("cnsiRecordExists") + + _, err := p.GetCNSIRecordByEndpoint(endpoint); + return err == nil } func (p *portalProxy) setCNSIRecord(guid string, c interfaces.CNSIRecord) error { diff --git a/components/app-core/backend/datastore/database_cf_config.go b/components/app-core/backend/datastore/database_cf_config.go new file mode 100644 index 0000000000..0d1226d5d4 --- /dev/null +++ b/components/app-core/backend/datastore/database_cf_config.go @@ -0,0 +1,68 @@ +package datastore + +import ( + "encoding/json" + "github.com/SUSE/stratos-ui/components/app-core/backend/config" + log "github.com/Sirupsen/logrus" + "strconv" + "strings" +) + +const ( + SERVICES_ENV = "VCAP_SERVICES" +) + +type VCAPService struct { + Credentials VCAPCredential `json:"credentials"` + Tags []string `json:"tags"` +} + +type VCAPCredential struct { + Username string `json:"username"` + Password string `json:"password"` + Dbname string `json:"dbname"` + Hostname string `json:"hostname"` + Port string `json:"port"` + Uri string `json:"uri"` +} + +// Discover cf db services via their 'uri' env var and apply settings to the DatabaseConfig objects +func ParseCFEnvs(db *DatabaseConfig) bool { + if config.IsSet(SERVICES_ENV) == false { + return false + } + + // Extract struts from VCAP_SERVICES env + vcapServicesStr := config.GetString(SERVICES_ENV) + var vcapServices map[string][]VCAPService + err := json.Unmarshal([]byte(vcapServicesStr), &vcapServices) + if err != nil { + log.Warnf("Unable to convert %s env var into JSON", SERVICES_ENV) + return false + } + + for _, services := range vcapServices { + if len(services) == 0 { + continue + } + service := services[0] + + for _, tag := range service.Tags { + if strings.HasPrefix(tag, "stratos_postgresql") { + dbCredentials := service.Credentials + // At the moment we only handle Postgres + db.DatabaseProvider = "pgsql" + db.Username = dbCredentials.Username + db.Password = dbCredentials.Password + db.Database = dbCredentials.Dbname + db.Host = dbCredentials.Hostname + db.Port, err = strconv.Atoi(dbCredentials.Port) + db.SSLMode = "disable" + log.Info("Discovered Cloud Foundry postgres service and applied config") + return true + } + } + } + + return false +} diff --git a/components/app-core/backend/datastore/datastore.go b/components/app-core/backend/datastore/datastore.go index 2f0b85db30..24921be480 100644 --- a/components/app-core/backend/datastore/datastore.go +++ b/components/app-core/backend/datastore/datastore.go @@ -11,24 +11,35 @@ import ( "github.com/SUSE/stratos-ui/components/app-core/backend/config" log "github.com/Sirupsen/logrus" + // Mysql driver + _ "github.com/go-sql-driver/mysql" "github.com/kat-co/vala" - // SQL Lite 3 + // Sqlite driver _ "github.com/mattn/go-sqlite3" ) +const ( + // SQLite DB Provider + SQLITE string = "sqlite" + // PGSQL DB Provider + PGSQL = "pgsql" + // MYSQL DB Provider + MYSQL = "mysql" +) + // DatabaseConfig represents the connection configuration parameters type DatabaseConfig struct { DatabaseProvider string `configName:"DATABASE_PROVIDER"` - Username string `configName:"PGSQL_USER"` - Password string `configName:"PGSQL_PASSWORD"` - Database string `configName:"PGSQL_DATABASE"` - Host string `configName:"PGSQL_HOST"` - Port int `configName:"PGSQL_PORT"` - SSLMode string `configName:"PGSQL_SSL_MODE"` - ConnectionTimeoutInSecs int `configName:"PGSQL_CONNECT_TIMEOUT_IN_SECS"` - SSLCertificate string `configName:"PGSQL_CERT"` - SSLKey string `configName:"PGSQL_CERT_KEY"` - SSLRootCertificate string `configName:"PGSQL_ROOT_CERT"` + Username string `configName:"DB_USER"` + Password string `configName:"DB_PASSWORD"` + Database string `configName:"DB_DATABASE_NAME"` + Host string `configName:"DB_HOST"` + Port int `configName:"DB_PORT"` + SSLMode string `configName:"DB_SSL_MODE"` + ConnectionTimeoutInSecs int `configName:"DB_CONNECT_TIMEOUT_IN_SECS"` + SSLCertificate string `configName:"DB_CERT"` + SSLKey string `configName:"DB_CERT_KEY"` + SSLRootCertificate string `configName:"DB_ROOT_CERT"` } // SSLValidationMode is the PostgreSQL driver SSL validation modes @@ -48,7 +59,7 @@ const ( // SQLiteDatabaseFile - SQLite database file SQLiteDatabaseFile = "./console-database.db" // Default database provider when not specified - DefaultDatabaseProvider = "pgsql" + DefaultDatabaseProvider = MYSQL ) const ( @@ -73,7 +84,7 @@ func NewDatabaseConnectionParametersFromConfig(dc DatabaseConfig) (DatabaseConfi } // No configuration needed for SQLite - if dc.DatabaseProvider == "sqlite" { + if dc.DatabaseProvider == SQLITE { return dc, nil } @@ -84,12 +95,15 @@ func NewDatabaseConnectionParametersFromConfig(dc DatabaseConfig) (DatabaseConfi return dc, err } - if dc.SSLMode == string(SSLDisabled) || dc.SSLMode == string(SSLRequired) || - dc.SSLMode == string(SSLVerifyCA) || dc.SSLMode == string(SSLVerifyFull) { + if dc.DatabaseProvider == PGSQL { + if dc.SSLMode == string(SSLDisabled) || dc.SSLMode == string(SSLRequired) || + dc.SSLMode == string(SSLVerifyCA) || dc.SSLMode == string(SSLVerifyFull) { + return dc, nil + } + } else if dc.DatabaseProvider == MYSQL { return dc, nil } - - return dc, fmt.Errorf("Invalid SSL mode: %v", dc.SSLMode) + return dc, fmt.Errorf("Invalid provider %v", dc) } func validateRequiredDatabaseParams(username, password, database, host string, port int) (err error) { @@ -114,10 +128,15 @@ func validateRequiredDatabaseParams(username, password, database, host string, p func GetConnection(dc DatabaseConfig) (*sql.DB, error) { log.Debug("GetConnection") - if dc.DatabaseProvider == "pgsql" { + if dc.DatabaseProvider == PGSQL { return sql.Open("postgres", buildConnectionString(dc)) } + if dc.DatabaseProvider == MYSQL { + return sql.Open("mysql", buildConnectionStringForMysql(dc)) + + } + // SQL Lite return GetSQLLiteConnection() } @@ -210,6 +229,28 @@ func buildConnectionString(dc DatabaseConfig) string { return connStr } +func buildConnectionStringForMysql(dc DatabaseConfig) string { + log.Println("buildConnectionString") + escapeStr := func(in string) string { + return strings.Replace(in, `'`, `\'`, -1) + } + + connStr := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?parseTime=true", + escapeStr(dc.Username), + escapeStr(dc.Password), + dc.Host, + dc.Port, + escapeStr(dc.Database)) + + log.Printf("DB Connection string: %s:*********@tcp(%s:%d)/%s?parseTime=true", + escapeStr(dc.Username), + dc.Host, + dc.Port, + escapeStr(dc.Database)) + + return connStr +} + // Ping - ping the database to ensure the connection/pool works. func Ping(db *sql.DB) error { log.Debug("Ping database") @@ -226,7 +267,7 @@ func Ping(db *sql.DB) error { // SQLite uses ? func ModifySQLStatement(sql string, databaseProvider string) string { - if databaseProvider == "sqlite" { + if databaseProvider == SQLITE || databaseProvider == MYSQL { sqlParamReplace := regexp.MustCompile("\\$[0-9]") return sqlParamReplace.ReplaceAllString(sql, "?") } diff --git a/components/app-core/backend/development.rc.sample b/components/app-core/backend/development.rc.sample index 57769df5c7..38653b4ea6 100644 --- a/components/app-core/backend/development.rc.sample +++ b/components/app-core/backend/development.rc.sample @@ -28,13 +28,13 @@ export GITHUB_OAUTH_STATE=thisshouldberando # All the things # ################### -export PGSQL_USER='console' -export PGSQL_PASSWORD='console' -export PGSQL_DATABASE='console-db' -export PGSQL_HOST='localhost' -export PGSQL_PORT=5432 -export PGSQL_CONNECT_TIMEOUT_IN_SECS=100 -export PGSQL_SSL_MODE='disable' +export DB_USER='console' +export DB_PASSWORD='console' +export DB_DATABASE_NAME='console-db' +export DB_HOST='localhost' +export DB_PORT=5432 +export DB_CONNECT_TIMEOUT_IN_SECS=100 +export DB_SSL_MODE='disable' export HTTP_CLIENT_TIMEOUT_IN_SECS=100 export SKIP_SSL_VALIDATION='true' diff --git a/components/app-core/backend/glide.lock b/components/app-core/backend/glide.lock index c6d4a2c47d..77988257a9 100644 --- a/components/app-core/backend/glide.lock +++ b/components/app-core/backend/glide.lock @@ -1,13 +1,12 @@ -hash: 094a647361758abee514f89cf6e36736a4f8375dbf24800116141c5360555ae4 -updated: 2017-05-16T11:17:15.523496697+01:00 +hash: c790bcfd2741c2e94175d40e1a44d7c595d4d57fd34753a61b065fecdfcc5f76 +updated: 2017-08-21T09:55:31.917609969+01:00 imports: - name: github.com/antonlindstrom/pgstore version: 4f1dbba445e540bf071eb9572262edb8fe4a593a - name: github.com/cloudfoundry/noaa - version: 086fd462f8fcdde1a234e028e3e157237bdb3383 + version: a1d4a94e2be6711111893c59538976d07ef7ea98 subpackages: - consumer - - consumer/internal - errors - name: github.com/cloudfoundry/sonde-go version: 78019103037ae46207993c8816e970d85bf1a9e4 @@ -15,6 +14,8 @@ imports: - events - name: github.com/dgrijalva/jwt-go version: 6c8dedd55f8a2e41f605de6d5d66e51ed1f299fc +- name: github.com/go-sql-driver/mysql + version: a0583e0143b1624142adab07e0e97fe106d99561 - name: github.com/gorilla/context version: 1ea25387ff6f684839d82767c1733ff4d4d15d0a - name: github.com/gorilla/securecookie @@ -23,6 +24,8 @@ imports: version: ca9ada44574153444b00d3fd9c8559e4cc95f896 - name: github.com/gorilla/websocket version: 3ab3a8b8831546bd18fd182c20687ca853b2bb13 +- name: github.com/irfanhabib/mysqlstore + version: 304308519d1355852f92cd88b31153c8a9a4e4db - name: github.com/kat-co/vala version: 43c3f19f86f47a7a83ce5656a1dd8fee3da5d12b - name: github.com/labstack/echo @@ -65,9 +68,9 @@ imports: - name: golang.org/x/net version: 59a0b19b5533c7977ddeb86b017bf507ed407b12 subpackages: - - context - - html - - websocket + - context + - html + - websocket - name: golang.org/x/sys version: 0b25a408a50076fbbcae6b7ac0ea5fbb0b085e79 subpackages: @@ -92,4 +95,3 @@ testImports: - convey/reporting - name: gopkg.in/DATA-DOG/go-sqlmock.v1 version: 9958e5c69de03e97ec215b23f6fcae1f600c3fb6 - diff --git a/components/app-core/backend/glide.yaml b/components/app-core/backend/glide.yaml index 70eddd096e..0d603178e0 100644 --- a/components/app-core/backend/glide.yaml +++ b/components/app-core/backend/glide.yaml @@ -17,7 +17,6 @@ import: version: ~1.1.0 - package: github.com/gorilla/websocket version: ~1.1.0 -# No release was cut - package: github.com/kat-co/vala version: 43c3f19f86f47a7a83ce5656a1dd8fee3da5d12b - package: github.com/labstack/echo @@ -32,6 +31,9 @@ import: version: ^1.1.0 - package: github.com/gorilla/securecookie version: ~1.1.0 +- package: github.com/go-sql-driver/mysql + version: ^1.3.0 +- package: github.com/irfanhabib/mysqlstore testImport: - package: github.com/smartystreets/goconvey version: 1.6.2 diff --git a/components/app-core/backend/main.go b/components/app-core/backend/main.go index 873108f40a..dd5d0a9a79 100644 --- a/components/app-core/backend/main.go +++ b/components/app-core/backend/main.go @@ -18,6 +18,7 @@ import ( log "github.com/Sirupsen/logrus" "github.com/antonlindstrom/pgstore" + "github.com/irfanhabib/mysqlstore" "github.com/labstack/echo" "github.com/labstack/echo/engine/standard" "github.com/labstack/echo/middleware" @@ -259,7 +260,7 @@ func initConnPool(dc datastore.DatabaseConfig) (*sql.DB, error) { // If our timeout boundary has been exceeded, bail out if timeout.Sub(time.Now()) < 0 { - return nil, fmt.Errorf("Timeout boundary of %d minutes has been exceeded. Exiting.", TimeoutBoundary) + return nil, fmt.Errorf("timeout boundary of %d minutes has been exceeded. Exiting", TimeoutBoundary) } // Circle back and try again @@ -273,8 +274,10 @@ func initConnPool(dc datastore.DatabaseConfig) (*sql.DB, error) { func initSessionStore(db *sql.DB, databaseProvider string, pc interfaces.PortalConfig, sessionExpiry int) (HttpSessionStore, error) { log.Debug("initSessionStore") + sessionsTable := "sessions" + // Store depends on the DB Type - if databaseProvider == "pgsql" { + if databaseProvider == datastore.PGSQL { log.Info("Creating Postgres session store") sessionStore, err := pgstore.NewPGStoreFromPool(db, []byte(pc.SessionStoreSecret)) // Setup cookie-store options @@ -283,9 +286,19 @@ func initSessionStore(db *sql.DB, databaseProvider string, pc interfaces.PortalC sessionStore.Options.Secure = true return sessionStore, err } + // Store depends on the DB Type + if databaseProvider == datastore.MYSQL { + log.Info("Creating MySQL session store") + sessionStore, err := mysqlstore.NewMySQLStoreFromConnection(db, sessionsTable, "/", 3600, []byte(pc.SessionStoreSecret)) + // Setup cookie-store options + sessionStore.Options.MaxAge = sessionExpiry + sessionStore.Options.HttpOnly = true + sessionStore.Options.Secure = true + return sessionStore, err + } log.Info("Creating SQLite session store") - sessionStore, err := sqlitestore.NewSqliteStoreFromConnection(db, "sessions", "/", 3600, []byte(pc.SessionStoreSecret)) + sessionStore, err := sqlitestore.NewSqliteStoreFromConnection(db, sessionsTable, "/", 3600, []byte(pc.SessionStoreSecret)) // Setup cookie-store options sessionStore.Options.MaxAge = sessionExpiry sessionStore.Options.HttpOnly = true @@ -312,7 +325,10 @@ func loadPortalConfig(pc interfaces.PortalConfig) (interfaces.PortalConfig, erro func loadDatabaseConfig(dc datastore.DatabaseConfig) (datastore.DatabaseConfig, error) { log.Debug("loadDatabaseConfig") - if err := config.Load(&dc); err != nil { + + if datastore.ParseCFEnvs(&dc) == true { + log.Info("Using Cloud Foundry DB service") + } else if err := config.Load(&dc); err != nil { return dc, fmt.Errorf("Unable to load database configuration. %v", err) } @@ -321,18 +337,11 @@ func loadDatabaseConfig(dc datastore.DatabaseConfig) (datastore.DatabaseConfig, return dc, fmt.Errorf("Unable to load database configuration. %v", err) } - // Determine database provider - if len(dc.Host) > 0 { - dc.DatabaseProvider = "pgsql" - } else { - dc.DatabaseProvider = "sqlite" - } - return dc, nil } -func createTempCertFiles(pc interfaces.PortalConfig) (string, string, error) { - log.Debug("createTempCertFiles") +func detectTLSCert(pc interfaces.PortalConfig) (string, string, error) { + log.Debug("detectTLSCert") certFilename := "pproxy.crt" certKeyFilename := "pproxy.key" @@ -346,6 +355,17 @@ func createTempCertFiles(pc interfaces.PortalConfig) (string, string, error) { return devCertsDir + certFilename, devCertsDir + certKeyFilename, nil } + // Check if certificate have been provided as files (as is the case in kubernetes) + if pc.TLSCertPath != "" && pc.TLSCertKeyPath != "" { + log.Infof("Using TLS cert: %s, %s", pc.TLSCertPath, pc.TLSCertKeyPath) + _, errCertMissing := os.Stat(pc.TLSCertPath) + _, errCertKeyMissing := os.Stat(pc.TLSCertKeyPath) + if errCertMissing != nil || errCertKeyMissing != nil { + return "", "", fmt.Errorf("unable to find certificate %s or certificate key %s", pc.TLSCertPath, pc.TLSCertKeyPath) + } + return pc.TLSCertPath, pc.TLSCertKeyPath, nil + } + err := ioutil.WriteFile(certFilename, []byte(pc.TLSCert), 0600) if err != nil { return "", "", err @@ -427,7 +447,7 @@ func start(config interfaces.PortalConfig, p *portalProxy, addSetupMiddleware *s } if config.HTTPS { - certFile, certKeyFile, err := createTempCertFiles(config) + certFile, certKeyFile, err := detectTLSCert(config) if err != nil { return err } @@ -622,6 +642,9 @@ func isConsoleUpgrading() bool { } upgradeLockPath := fmt.Sprintf("/%s/%s", upgradeVolume, upgradeLockFile) + if string(upgradeVolume[0]) == "/" { + upgradeLockPath = fmt.Sprintf("%s/%s", upgradeVolume, upgradeLockFile) + } if _, err := os.Stat(upgradeLockPath); err == nil { return true diff --git a/components/app-core/backend/main_test.go b/components/app-core/backend/main_test.go index 326bee3343..2f7cb3ce63 100644 --- a/components/app-core/backend/main_test.go +++ b/components/app-core/backend/main_test.go @@ -59,8 +59,8 @@ func (e *echoContextMock) Reset(engine.Request, engine.Response) { */ func TestLoadPortalConfig(t *testing.T) { - t.Parallel() + os.Unsetenv("DATABASE_PROVIDER") os.Setenv("HTTP_CLIENT_TIMEOUT_IN_SECS", "10") os.Setenv("SKIP_SSL_VALIDATION", "true") os.Setenv("CONSOLE_PROXY_TLS_ADDRESS", ":8080") @@ -88,7 +88,6 @@ func TestLoadPortalConfig(t *testing.T) { t.Error("Unable to get TLSAddress from config") } - if result.CFClient != "portal-proxy" { t.Error("Unable to get CFClient from config") } @@ -117,15 +116,15 @@ func TestLoadPortalConfig(t *testing.T) { } func TestLoadDatabaseConfig(t *testing.T) { - t.Parallel() - os.Setenv("PGSQL_USER", "console") - os.Setenv("PGSQL_PASSWORD", "console") - os.Setenv("PGSQL_DATABASE", "console-db") - os.Setenv("PGSQL_HOST", "localhost") - os.Setenv("PGSQL_PORT", "5432") - os.Setenv("PGSQL_CONNECT_TIMEOUT_IN_SECS", "5") - os.Setenv("PGSQL_SSL_MODE", "disable") + os.Unsetenv("DATABASE_PROVIDER") + os.Setenv("DB_USER", "console") + os.Setenv("DB_PASSWORD", "console") + os.Setenv("DB_DATABASE_NAME", "console-db") + os.Setenv("DB_HOST", "localhost") + os.Setenv("DB_PORT", "5432") + os.Setenv("DB_CONNECT_TIMEOUT_IN_SECS", "5") + os.Setenv("DB_SSL_MODE", "disable") var dc datastore.DatabaseConfig @@ -137,9 +136,14 @@ func TestLoadDatabaseConfig(t *testing.T) { } func TestLoadDatabaseConfigWithInvalidSSLMode(t *testing.T) { - t.Parallel() - os.Setenv("PGSQL_SSL_MODE", "invalid.ssl.mode") + os.Setenv("DB_USER", "console") + os.Setenv("DB_PASSWORD", "console") + os.Setenv("DB_DATABASE_NAME", "console-db") + os.Setenv("DB_HOST", "localhost") + os.Setenv("DB_PORT", "5432") + os.Setenv("DATABASE_PROVIDER", "pgsql") + os.Setenv("DB_SSL_MODE", "invalid.ssl.mode") var dc datastore.DatabaseConfig diff --git a/components/app-core/backend/mock_server_test.go b/components/app-core/backend/mock_server_test.go index b420f0ef33..0eb9eefe2b 100644 --- a/components/app-core/backend/mock_server_test.go +++ b/components/app-core/backend/mock_server_test.go @@ -11,13 +11,12 @@ import ( "testing" "time" - "gopkg.in/DATA-DOG/go-sqlmock.v1" - log "github.com/Sirupsen/logrus" "github.com/gorilla/securecookie" "github.com/gorilla/sessions" "github.com/labstack/echo" "github.com/labstack/echo/engine/standard" + sqlmock "gopkg.in/DATA-DOG/go-sqlmock.v1" "github.com/SUSE/stratos-ui/components/app-core/backend/repository/crypto" "github.com/SUSE/stratos-ui/components/app-core/backend/repository/interfaces" @@ -131,12 +130,11 @@ func setupPortalProxy(db *sql.DB) *portalProxy { urlP, _ := url.Parse("https://login.52.38.188.107.nip.io:50450") pc := interfaces.PortalConfig{ ConsoleConfig: &interfaces.ConsoleConfig{ - ConsoleClient: "console", - ConsoleClientSecret: "", - UAAEndpoint: urlP, - SkipSSLValidation: true, + ConsoleClient: "console", + ConsoleClientSecret: "", + UAAEndpoint: urlP, + SkipSSLValidation: true, ConsoleAdminScope: UAAAdminIdentifier, - }, SessionStoreSecret: "hiddenraisinsohno!", EncryptionKeyInBytes: mockEncryptionKey, @@ -263,21 +261,21 @@ var mockUAAResponse = UAAResponse{ } const ( - mockAPIEndpoint = "https://api.127.0.0.1" - mockAuthEndpoint = "https://login.127.0.0.1" - mockTokenEndpoint = "https://uaa.127.0.0.1" + mockAPIEndpoint = "https://api.127.0.0.1" + mockAuthEndpoint = "https://login.127.0.0.1" + mockTokenEndpoint = "https://uaa.127.0.0.1" mockDopplerEndpoint = "https://doppler.127.0.0.1" - mockProxyVersion = 20161117141922 + mockProxyVersion = 20161117141922 stringCFType = "cf" stringCEType = "hce" selectAnyFromTokens = `SELECT .+ FROM tokens WHERE .+` - insertIntoTokens = `INSERT INTO tokens` - updateTokens = `UPDATE tokens` - selectAnyFromCNSIs = `SELECT (.+) FROM cnsis WHERE (.+)` - insertIntoCNSIs = `INSERT INTO cnsis` - getDbVersion = `SELECT version_id FROM goose_db_version WHERE is_applied = 't' ORDER BY id DESC LIMIT 1` + insertIntoTokens = `INSERT INTO tokens` + updateTokens = `UPDATE tokens` + selectAnyFromCNSIs = `SELECT (.+) FROM cnsis WHERE (.+)` + insertIntoCNSIs = `INSERT INTO cnsis` + getDbVersion = `SELECT version_id FROM goose_db_version WHERE is_applied = '1' ORDER BY id DESC LIMIT 1` ) var rowFieldsForCNSI = []string{"guid", "name", "cnsi_type", "api_endpoint", "auth_endpoint", "token_endpoint", "doppler_logging_endpoint", "skip_ssl_validation"} diff --git a/components/app-core/backend/passthrough.go b/components/app-core/backend/passthrough.go index 9c3e40ecc0..4cf9cc2414 100644 --- a/components/app-core/backend/passthrough.go +++ b/components/app-core/backend/passthrough.go @@ -160,8 +160,8 @@ func fwdCNSIStandardHeaders(cnsiRequest *CNSIRequest, req *http.Request) { switch { // Skip these // - "Referer" causes CF to fail with a 403 - // - "Connection", "X-Cnap-*" and "Cookie" are consumed by us - case k == "Connection", k == "Cookie", k == "Referer", strings.HasPrefix(strings.ToLower(k), "x-cnap-"): + // - "Connection", "X-Cap-*" and "Cookie" are consumed by us + case k == "Connection", k == "Cookie", k == "Referer", strings.HasPrefix(strings.ToLower(k), "x-cap-"): // Forwarding everything else default: @@ -172,8 +172,8 @@ func fwdCNSIStandardHeaders(cnsiRequest *CNSIRequest, req *http.Request) { func (p *portalProxy) proxy(c echo.Context) error { log.Debug("proxy") - cnsiList := strings.Split(c.Request().Header().Get("x-cnap-cnsi-list"), ",") - shouldPassthrough := "true" == c.Request().Header().Get("x-cnap-passthrough") + cnsiList := strings.Split(c.Request().Header().Get("x-cap-cnsi-list"), ",") + shouldPassthrough := "true" == c.Request().Header().Get("x-cap-passthrough") if err := p.validateCNSIList(cnsiList); err != nil { return echo.NewHTTPError(http.StatusBadRequest, err.Error()) @@ -218,7 +218,7 @@ func (p *portalProxy) proxy(c echo.Context) error { return echo.NewHTTPError(http.StatusBadRequest, buildErr.Error()) } // Allow the host part of the API URL to be overridden - apiHost := c.Request().Header().Get("x-cnap-api-host") + apiHost := c.Request().Header().Get("x-cap-api-host") // Don't allow any '.' chars in the api name if apiHost != "" && !strings.ContainsAny(apiHost, ".") { // Add trailing . for when we replace diff --git a/components/app-core/backend/repository/cnsis/pgsql_cnsis.go b/components/app-core/backend/repository/cnsis/pgsql_cnsis.go index 466b2c81bb..22db50379c 100644 --- a/components/app-core/backend/repository/cnsis/pgsql_cnsis.go +++ b/components/app-core/backend/repository/cnsis/pgsql_cnsis.go @@ -8,8 +8,8 @@ import ( "github.com/SUSE/stratos-ui/components/app-core/backend/datastore" - log "github.com/Sirupsen/logrus" "github.com/SUSE/stratos-ui/components/app-core/backend/repository/interfaces" + log "github.com/Sirupsen/logrus" ) var listCNSIs = `SELECT guid, name, cnsi_type, api_endpoint, auth_endpoint, token_endpoint, doppler_logging_endpoint, skip_ssl_validation diff --git a/components/app-core/backend/repository/console_config/psql_console_config.go b/components/app-core/backend/repository/console_config/psql_console_config.go index 803a9fda1a..77ec8fd824 100644 --- a/components/app-core/backend/repository/console_config/psql_console_config.go +++ b/components/app-core/backend/repository/console_config/psql_console_config.go @@ -94,8 +94,8 @@ func (c *ConsoleConfigRepository) SaveConsoleConfig(config *interfaces.ConsoleCo if err != nil { return fmt.Errorf("Unable to truncate Console Config table: %v", err) } - isComplete := config.ConsoleAdminScope != "" + if _, err := c.db.Exec(saveConsoleConfig, fmt.Sprintf("%s", config.UAAEndpoint), config.ConsoleAdminScope, config.ConsoleClient, config.ConsoleClientSecret, config.SkipSSLValidation, isComplete); err != nil { return fmt.Errorf("Unable to Save Console Config record: %v", err) @@ -125,7 +125,7 @@ func (c *ConsoleConfigRepository) IsInitialised() (bool, error) { rowCount, err := c.getTableCount() if err != nil { - for strings.Contains(err.Error(), "does not exist") { + for strings.Contains(err.Error(), "does not exist") || strings.Contains(err.Error(), "doesn't exist") { // Schema isn't initialised yet. Wait a few secs and retry log.Warnf("It appears schema isn't initialised yet, sleeping and trying again %s", err) time.Sleep(1 * time.Second) @@ -173,8 +173,8 @@ func (c *ConsoleConfigRepository) isSetupComplete() (bool, error) { return isSetupComplete, nil } -func (c *ConsoleConfigRepository) getTableCount() (int, error) { - rows, err := c.db.Query(getTableCount) +func (c *ConsoleConfigRepository) getCount(sqlStatement string) (int, error) { + rows, err := c.db.Query(sqlStatement) if err != nil { if strings.Contains(err.Error(), "does not exist") { @@ -198,3 +198,7 @@ func (c *ConsoleConfigRepository) getTableCount() (int, error) { return count, nil } + +func (c *ConsoleConfigRepository) getTableCount() (int, error) { + return c.getCount(getTableCount) +} diff --git a/components/app-core/backend/repository/crypto/aes.go b/components/app-core/backend/repository/crypto/aes.go index f4759309d9..fb21fcb503 100644 --- a/components/app-core/backend/repository/crypto/aes.go +++ b/components/app-core/backend/repository/crypto/aes.go @@ -73,8 +73,12 @@ func Decrypt(key, ciphertext []byte) (plaintext []byte, err error) { // ReadEncryptionKey - Read the encryption key from the shared volume func ReadEncryptionKey(v, f string) ([]byte, error) { log.Println("ReadEncryptionKey") - fname := fmt.Sprintf("/%s/%s", v, f) - key64chars, err := ioutil.ReadFile(fname) + + encryptionKey := fmt.Sprintf("/%s/%s", v, f) + if string(f[0]) == "/" { + encryptionKey = fmt.Sprintf("%s/%s", v, f) + } + key64chars, err := ioutil.ReadFile(encryptionKey) if err != nil { log.Errorf("Unable to read encryption key file: %+v\n", err) return nil, err diff --git a/components/app-core/backend/repository/goose-db-version/pgsql_goose_db_version.go b/components/app-core/backend/repository/goose-db-version/pgsql_goose_db_version.go index 065b377a4f..82e1fde74c 100644 --- a/components/app-core/backend/repository/goose-db-version/pgsql_goose_db_version.go +++ b/components/app-core/backend/repository/goose-db-version/pgsql_goose_db_version.go @@ -9,7 +9,7 @@ import ( ) const ( - getCurrentVersion = `SELECT version_id FROM goose_db_version WHERE is_applied = 't' ORDER BY id DESC LIMIT 1` + getCurrentVersion = `SELECT version_id FROM goose_db_version WHERE is_applied = '1' ORDER BY id DESC LIMIT 1` ) // PostgresGooseDBVersionRepository is a PostgreSQL-backed Goose DB Version repository diff --git a/components/app-core/backend/repository/interfaces/portal_proxy.go b/components/app-core/backend/repository/interfaces/portal_proxy.go index 2db438e28a..8fc0ee7407 100644 --- a/components/app-core/backend/repository/interfaces/portal_proxy.go +++ b/components/app-core/backend/repository/interfaces/portal_proxy.go @@ -28,6 +28,7 @@ type PortalProxy interface { DoLoginToCNSI(c echo.Context, cnsiGUID string) (*LoginRes, error) // Expose internal portal proxy records to extensions GetCNSIRecord(guid string) (CNSIRecord, error) + GetCNSIRecordByEndpoint(endpoint string) (CNSIRecord, error) GetCNSITokenRecord(cnsiGUID string, userGUID string) (TokenRecord, bool) GetCNSIUser(cnsiGUID string, userGUID string) (*ConnectedUser, bool) GetConfig() *PortalConfig diff --git a/components/app-core/backend/repository/interfaces/structs.go b/components/app-core/backend/repository/interfaces/structs.go index 8293d862ed..c23d8b3e54 100644 --- a/components/app-core/backend/repository/interfaces/structs.go +++ b/components/app-core/backend/repository/interfaces/structs.go @@ -86,6 +86,8 @@ type PortalConfig struct { TLSAddress string `configName:"CONSOLE_PROXY_TLS_ADDRESS"` TLSCert string `configName:"CONSOLE_PROXY_CERT"` TLSCertKey string `configName:"CONSOLE_PROXY_CERT_KEY"` + TLSCertPath string `configName:"CONSOLE_PROXY_CERT_PATH"` + TLSCertKeyPath string `configName:"CONSOLE_PROXY_CERT_KEY_PATH"` CFClient string `configName:"CF_CLIENT"` CFClientSecret string `configName:"CF_CLIENT_SECRET"` AllowedOrigins []string `configName:"ALLOWED_ORIGINS"` diff --git a/components/app-core/frontend/src/model/account.model.js b/components/app-core/frontend/src/model/account.model.js index 9459bf227f..d78f56167e 100644 --- a/components/app-core/frontend/src/model/account.model.js +++ b/components/app-core/frontend/src/model/account.model.js @@ -128,7 +128,7 @@ * @private */ function onLoggedIn(response) { - var sessionExpiresOnEpoch = response.headers()['x-cnap-session-expires-on']; + var sessionExpiresOnEpoch = response.headers()['x-cap-session-expires-on']; var loginData = response.data || {}; var loginRes = loginData.user ? loginData.user : loginData; diff --git a/components/app-core/frontend/src/model/navigation.model.js b/components/app-core/frontend/src/model/navigation.model.js index 5866a3cc15..30350caa11 100644 --- a/components/app-core/frontend/src/model/navigation.model.js +++ b/components/app-core/frontend/src/model/navigation.model.js @@ -207,7 +207,7 @@ position: pos, // baseState is used to work out which menu entry is active based on any child state baseState: baseState || name, // defaults to name - items: new Menu() // sub-menu + items: new Menu() // sub-menu }; return this._addMenuItem(item); @@ -232,7 +232,7 @@ position: pos, onClick: fn, baseState: name, - items: new Menu() // sub-menu + items: new Menu() // sub-menu }; return this._addMenuItem(item); diff --git a/components/app-core/frontend/src/utils/logged-in.service.js b/components/app-core/frontend/src/utils/logged-in.service.js index c41bf26d7b..933892a403 100644 --- a/components/app-core/frontend/src/utils/logged-in.service.js +++ b/components/app-core/frontend/src/utils/logged-in.service.js @@ -22,7 +22,7 @@ * @returns {object} Logged In Service */ function loggedInServiceFactory(appEventService, modelManager, loginManager, frameworkDialogConfirm, - $interval, $rootScope, $window, $log, $document, $translate) { + $interval, $rootScope, $window, $log, $document, $translate) { var loggedIn = false; var lastUserInteraction = moment(); diff --git a/components/app-core/frontend/src/view/application.directive.js b/components/app-core/frontend/src/view/application.directive.js index 6392fd2807..77f0f856b2 100644 --- a/components/app-core/frontend/src/view/application.directive.js +++ b/components/app-core/frontend/src/view/application.directive.js @@ -48,9 +48,21 @@ * @property {boolean} serverErrorOnLogin - a flag indicating if user login failed because of a server error. * @class */ - function ApplicationController($q, appEventService, modelManager, loginManager, appUpgradeCheck, appLocalStorage, - consoleSetupCheck, $timeout, $stateParams, $window, $rootScope, $scope, - appLoggedInService) { + function ApplicationController( + $q, + appEventService, + modelManager, + loginManager, + appUpgradeCheck, + appLocalStorage, + consoleSetupCheck, + $timeout, + $stateParams, + $window, + $rootScope, + $scope, + appLoggedInService + ) { var vm = this; @@ -82,7 +94,7 @@ } // Navigation options - $rootScope.$on('$stateChangeSuccess', function (event, toState, toParams) { // eslint-disable-line angular/on-watch + $rootScope.$on('$stateChangeSuccess', function (event, toState, toParams) { // eslint-disable-line angular/on-watch vm.hideNavigation = toParams.hideNavigation; vm.hideAccount = toParams.hideAccount; }); @@ -147,6 +159,10 @@ * @public */ function login(username, password) { + vm.failedLogin = false; + vm.serverErrorOnLogin = false; + vm.serverFailedToRespond = false; + modelManager.retrieve('app.model.account') .login(username, password) .then( diff --git a/components/app-core/frontend/src/view/endpoints/endpoints-dashboard-service-instance.service.js b/components/app-core/frontend/src/view/endpoints/endpoints-dashboard-service-instance.service.js index 58e1a3a821..679c10060e 100644 --- a/components/app-core/frontend/src/view/endpoints/endpoints-dashboard-service-instance.service.js +++ b/components/app-core/frontend/src/view/endpoints/endpoints-dashboard-service-instance.service.js @@ -24,8 +24,8 @@ * @returns {object} the service instance service */ function cnsiServiceFactory($q, $interpolate, $translate, modelManager, appEndpointsDashboardService, - appUtilsService, appErrorService, appNotificationsService, - appCredentialsDialog, frameworkDialogConfirm, appEventService) { + appUtilsService, appErrorService, appNotificationsService, + appCredentialsDialog, frameworkDialogConfirm, appEventService) { var that = this; var endpointPrefix = 'cnsi_'; var cnsiEndpointProviders = {}; diff --git a/components/app-core/frontend/src/view/landing-page/landing-page.directive.js b/components/app-core/frontend/src/view/landing-page/landing-page.directive.js index 95e3186b22..cc0be1db00 100644 --- a/components/app-core/frontend/src/view/landing-page/landing-page.directive.js +++ b/components/app-core/frontend/src/view/landing-page/landing-page.directive.js @@ -28,9 +28,10 @@ function landingPageController($scope, languageService) { var vm = this; vm.languageService = languageService; - vm.languageOptions = vm.languageService.getAll(); - languageService.getLocale(true).then(function (locale) { - vm.currentLanguage = locale; + vm.languageOptions = []; + languageService.initialised.then(function () { + vm.languageOptions = languageService.getAll(); + vm.currentLanguage = languageService.getLocale(); $scope.$watch(function () { return vm.currentLanguage; diff --git a/components/app-core/frontend/src/view/language/language.service.js b/components/app-core/frontend/src/view/language/language.service.js index 8af70eb014..bae7e413a9 100644 --- a/components/app-core/frontend/src/view/language/language.service.js +++ b/components/app-core/frontend/src/view/language/language.service.js @@ -97,23 +97,7 @@ */ function languageServiceFactory($q, $log, $translate, frameworkAsyncTaskDialog, modelManager, appLocalStorage) { - var userPreference = appLocalStorage.getItem(localeStorageId); - var setPromise = $q.resolve(); - - // Determine if there is only one locale which the user should always use - var locales = _getLocales(); - if (locales.length === 1) { - $log.debug('Only 1 locale found, setting to preferred + fallback: ', locales[0]); - // Attempt to set the fallback + preferred - $translate.preferredLanguage(locales[0]); - $translate.useFallbackLanguage(locales[0]); - // Ensure that the user pref is this one. This avoids instances where older, unsupported locales have not been - // cleared out of the source tree - userPreference = locales[0]; - } - - // Ensure that the locale is set to the user's pref (or forced to the only locale) - setLocale(userPreference); + var initialised = $translate.onReady().then(init); var service = { /** @@ -158,28 +142,63 @@ * fetching, false to fetch immediately * @returns {string|object} If waitForSet is true returns promise containing locale, else locale */ - getLocale: getLocale - }; + getLocale: getLocale, - if (enableLanguageSelection()) { - var userNavModel = modelManager.retrieve('app.model.navigation').user; - var item = userNavModel.addMenuItemFunction('select-language', service.showLanguageSelection, 'menu.language', 2); - item.setTextValues(function () { - return { current: service.getLocaleLocalised() }; - }); - } + /** + * @name initialised + * @description Promise resolving when language service has been initialised + */ + initialised: initialised + }; return service; + function init() { + var userPreference = appLocalStorage.getItem(localeStorageId); + + // Determine if there is only one locale which the user should always use + var locales = _getLocales(); + if (locales.length === 1) { + $log.debug('Only 1 locale found, setting to preferred + fallback: ', locales[0]); + // Attempt to set the fallback + preferred + $translate.preferredLanguage(locales[0]); + $translate.useFallbackLanguage(locales[0]); + // Ensure that the user pref is this one. This avoids instances where older, unsupported locales have not been + // cleared out of the source tree + userPreference = locales[0]; + } + + // Ensure that the locale is set to the user's pref (or forced to the only locale) + var setPromise = setLocale(userPreference); + + if (enableLanguageSelection()) { + var userNavModel = modelManager.retrieve('app.model.navigation').user; + var item = userNavModel.addMenuItemFunction('select-language', service.showLanguageSelection, 'menu.language', 2); + item.setTextValues(function () { + return { current: service.getLocaleLocalised() }; + }); + } + + return setPromise; + } + function setLocale(locale) { if (locale) { - // Only store the locale if it's explicitly been set... + // Check locale is valid + if (_.indexOf(_getLocales(), locale) < 0) { + // If not leave it up to the defaults for the current session + locale = ''; + } + // Only store the locale if it's explicitly been set or is invalid... appLocalStorage.setItem(localeStorageId, locale); - } else { + } + + if (!locale) { // .. otherwise use a best guess from the browser locale = browserLocale || $translate.resolveClientLocale(); locale = locale.replace('-', '_'); } + // Take into account moment naming conventions. For a list of supported moment locales see // https://github.com/moment/moment/tree/2.10.6/locale var momentLocale = locale.replace('_', '-'); @@ -190,7 +209,7 @@ return $q.resolve(); } - setPromise = $translate.use(locale).then(function () { + return $translate.use(locale).then(function () { $log.debug("Changed locale to '" + $translate.use() + "'"); momentLocale = momentLocale.toLowerCase(); var newMomentLocale = moment.locale(momentLocale); @@ -203,15 +222,10 @@ $log.warn("Failed to load language for locale '" + locale + "', falling back to '" + $translate.use() + "'"); return $q.reject(reason); }); - return setPromise; } - function getLocale(waitForSet) { - if (waitForSet && setPromise) { - return setPromise.then($translate.use); - } else { - return $translate.use(); - } + function getLocale() { + return $translate.use(); } function getAll() { diff --git a/components/app-core/frontend/src/view/login-page/login-form/login-form.scss b/components/app-core/frontend/src/view/login-page/login-form/login-form.scss index 1d5f17c9eb..55d2985ed1 100644 --- a/components/app-core/frontend/src/view/login-page/login-form/login-form.scss +++ b/components/app-core/frontend/src/view/login-page/login-form/login-form.scss @@ -14,7 +14,7 @@ login-form { > h1 { margin-bottom: $console-unit-space * 1.5; - margin-top: $console-unit-space / 2; + margin-top: $console-unit-space / 4; color: $brand-primary; font-weight: $console-font-weight-light; } @@ -57,6 +57,7 @@ login-form { .spinner-notification { text-transform: none; font-size: $font-size-large; + padding-right: $console-unit-space / 2; } .bounce-spinner > div { diff --git a/components/app-core/frontend/src/view/util/local-storage.service.js b/components/app-core/frontend/src/view/util/local-storage.service.js index fa82ec8c45..4c6825b1e9 100644 --- a/components/app-core/frontend/src/view/util/local-storage.service.js +++ b/components/app-core/frontend/src/view/util/local-storage.service.js @@ -2,8 +2,8 @@ 'use strict'; angular - .module('app.view') - .factory('appLocalStorage', localStorageFactory); + .module('app.view') + .factory('appLocalStorage', localStorageFactory); /** * @namespace app.view.localStorageFactory diff --git a/components/app-core/frontend/src/view/util/upgrade.service.js b/components/app-core/frontend/src/view/util/upgrade.service.js index 99d2197078..e2b282cac7 100644 --- a/components/app-core/frontend/src/view/util/upgrade.service.js +++ b/components/app-core/frontend/src/view/util/upgrade.service.js @@ -2,9 +2,9 @@ 'use strict'; angular - .module('app.view') - .factory('appUpgradeCheck', upgradeCheckFactory) - .config(upgradeCheckInterceptor); + .module('app.view') + .factory('appUpgradeCheck', upgradeCheckFactory) + .config(upgradeCheckInterceptor); /** * @namespace appUpgradeCheck @@ -53,7 +53,7 @@ // rejection is a response object // Must be a 503 with the Retry-After header and mst be do a Portal Proxy URL if (isUpgrading(rejection)) { - // This indicates upgrade in progress, so change state to an upgrade error page + // This indicates upgrade in progress, so change state to an upgrade error page appEventService.$emit(appEventService.events.TRANSFER, 'error-page', {error: 'upgrading', hideAccount: true}); } // Always return the rejection as it was diff --git a/components/app-core/frontend/test/e2e/po/helpers.po.js b/components/app-core/frontend/test/e2e/po/helpers.po.js index e8380d02ba..6cab77a6ad 100644 --- a/components/app-core/frontend/test/e2e/po/helpers.po.js +++ b/components/app-core/frontend/test/e2e/po/helpers.po.js @@ -66,7 +66,9 @@ hasClass: hasClass, isButtonEnabled: isButtonEnabled, - scrollIntoView: scrollIntoView + scrollIntoView: scrollIntoView, + + waitForElementAndClick: waitForElementAndClick }; function getHost() { @@ -422,4 +424,10 @@ }); } + function waitForElementAndClick(element) { + var until = protractor.ExpectedConditions; + browser.wait(until.presenceOf(element), 10000); + return element.click(); + } + })(); diff --git a/components/app-core/frontend/test/e2e/po/widgets/actions-menu.po.js b/components/app-core/frontend/test/e2e/po/widgets/actions-menu.po.js index bb79e4407c..86c667a164 100644 --- a/components/app-core/frontend/test/e2e/po/widgets/actions-menu.po.js +++ b/components/app-core/frontend/test/e2e/po/widgets/actions-menu.po.js @@ -2,6 +2,7 @@ 'use strict'; var wrapper = require('../wrapper.po'); + var helpers = require('../helpers.po'); module.exports = { isSingleButton: isSingleButton, @@ -40,11 +41,11 @@ } function click(actionMenu) { - return actionMenu.element(by.css('.actions-menu-icon')).click(); + return helpers.waitForElementAndClick(actionMenu.element(by.css('.actions-menu-icon'))); } function clickItem(actionMenu, row) { - return _getRow(actionMenu, row).click(); + return helpers.waitForElementAndClick(_getRow(actionMenu, row)); } function _getRow(actionMenu, row) { diff --git a/components/app-core/frontend/test/e2e/po/widgets/async-dialog-view.po.js b/components/app-core/frontend/test/e2e/po/widgets/async-dialog-view.po.js index 916ec8a10c..17b7e40b1e 100644 --- a/components/app-core/frontend/test/e2e/po/widgets/async-dialog-view.po.js +++ b/components/app-core/frontend/test/e2e/po/widgets/async-dialog-view.po.js @@ -32,7 +32,9 @@ cancel: _.partial(cancel, element), primary: _.partial(primary, element), - commit: _.partial(commit, element) + commit: _.partial(commit, element), + + getTitleText: _.partial(getTitleText, element) }; } diff --git a/components/app-core/frontend/test/e2e/po/widgets/table.po.js b/components/app-core/frontend/test/e2e/po/widgets/table.po.js index 645dad9ae8..503975aef1 100644 --- a/components/app-core/frontend/test/e2e/po/widgets/table.po.js +++ b/components/app-core/frontend/test/e2e/po/widgets/table.po.js @@ -1,23 +1,15 @@ (function () { 'use strict'; - var _ = require('lodash'); + var wrapper = require('../wrapper.po'); module.exports = { - wrap: wrap + getRows: getRows, + getData: getData, + getItem: getItem }; - function wrap(element) { - return { - getElement: function () { - return element; - }, - getRows: _.partial(getRows, element), - //getItem: _.partial(getItem, element), - getData: _.partial(getData, element), - getItem: _.partial(getItem, element) - }; - } + wrapper(module); function getRows(ele) { //return ele.element(by.css('.wizard-head h4')).getText(); diff --git a/components/app-core/frontend/test/e2e/po/wrapper.po.js b/components/app-core/frontend/test/e2e/po/wrapper.po.js index 5d2bc05fe1..5d39cea372 100644 --- a/components/app-core/frontend/test/e2e/po/wrapper.po.js +++ b/components/app-core/frontend/test/e2e/po/wrapper.po.js @@ -7,7 +7,9 @@ module.exports = function (_module) { if (!_module.exports.wrap) { _module.exports.wrap = function (element) { + var until = protractor.ExpectedConditions; var wrappers = {}; + _.each(_module.exports, function (value, key) { if (_.isFunction(value)) { wrappers[key] = _.partial(value, element); @@ -23,6 +25,11 @@ return element.getWebElement(); }; } + if (!wrappers.waitForElement) { + wrappers.waitForElement = function () { + browser.wait(until.presenceOf(wrappers.getElement()), 10000); + }; + } return wrappers; }; } diff --git a/components/app-core/frontend/test/unit/api/userServiceInstance.api.spec.js b/components/app-core/frontend/test/unit/api/userServiceInstance.api.spec.js index 2352873bed..fbfe2c7969 100644 --- a/components/app-core/frontend/test/unit/api/userServiceInstance.api.spec.js +++ b/components/app-core/frontend/test/unit/api/userServiceInstance.api.spec.js @@ -56,9 +56,9 @@ $httpBackend.when('POST', '/pp/v1/auth/login/cnsi').respond(200, data); userServiceInstanceApi.connect('TESTGUID', 'user', 'password') - .then(function (response) { - expect(response.data).toEqual('test'); - }); + .then(function (response) { + expect(response.data).toEqual('test'); + }); $httpBackend.flush(); }); diff --git a/components/app-framework/bower.json b/components/app-framework/bower.json index 7ac678e2c0..15a3c9e9e9 100644 --- a/components/app-framework/bower.json +++ b/components/app-framework/bower.json @@ -25,6 +25,7 @@ "jquery": "~1.11.3", "lodash": "~4.0.1", "ngSmoothScroll": "~2.0.0", + "ng-tags-input": "~3.2.0", "angular-cookies": "1.4", "angular-utf8-base64": "~0.0.5", "SpinKit": "spinkit#~1.2.5" diff --git a/components/app-framework/src/filters/json.filter.js b/components/app-framework/src/filters/json.filter.js new file mode 100644 index 0000000000..e22344c886 --- /dev/null +++ b/components/app-framework/src/filters/json.filter.js @@ -0,0 +1,32 @@ +(function () { + 'use strict'; + + // The filters + angular.module('app.framework.filters') + .filter('jsonString', jsonStringFilter); + + /** + * @namespace app.framework.filters.jsonStringFilter + * @memberof app.framework.filters + * @name jsonStringFilter + * @description An angular filter which will format the JSON object as a string OR show a supplied invalid message + * @param {object} $filter - Angular $filter service + * @returns {Function} The filter itself + */ + function jsonStringFilter($filter) { + var jsonStringFilter = function (obj, invalidMsg) { + + try { + return angular.toJson(obj); + } catch (e) { + return $filter('translate')(invalidMsg) || ''; + } + }; + + // Ensure the filter is reapplied on change of language + jsonStringFilter.$stateful = true; + + return jsonStringFilter; + } + +})(); diff --git a/components/app-framework/src/framework.module.js b/components/app-framework/src/framework.module.js index 94dc48502b..809ab63e72 100644 --- a/components/app-framework/src/framework.module.js +++ b/components/app-framework/src/framework.module.js @@ -10,6 +10,7 @@ 'app.framework.widgets', 'angular-websocket', 'ngAnimate', - 'toastr' + 'toastr', + 'ngTagsInput' ]); })(); diff --git a/components/app-framework/src/utils/auto-focus/focus-when.directive.js b/components/app-framework/src/utils/auto-focus/focus-when.directive.js new file mode 100644 index 0000000000..27a42ce898 --- /dev/null +++ b/components/app-framework/src/utils/auto-focus/focus-when.directive.js @@ -0,0 +1,28 @@ +(function () { + 'use strict'; + + angular + .module('app.framework.utils') + .directive('focusWhen', focusWhen); + + /** + * A simple attribute directive to set focus on an element when a value is set + * @param {Object} $timeout - the Angular $timeout service + * @returns {Object} the focus-when directive + * */ + function focusWhen($timeout) { + return { + restrict: 'A', + link: function (scope, element, attrs) { + scope.$watch(attrs.focusWhen, function (nv) { + if (nv) { + $timeout(function () { + element[0].focus(); + }, 0); + } + }); + } + }; + } + +})(); diff --git a/components/app-framework/src/utils/auto-populate-input/auto-populate-input.directive.js b/components/app-framework/src/utils/auto-populate-input/auto-populate-input.directive.js index eb39dde702..5b62e02872 100644 --- a/components/app-framework/src/utils/auto-populate-input/auto-populate-input.directive.js +++ b/components/app-framework/src/utils/auto-populate-input/auto-populate-input.directive.js @@ -2,8 +2,8 @@ 'use strict'; angular - .module('app.framework.utils') - .directive('autoPopulateInput', autoPopulateInput); + .module('app.framework.utils') + .directive('autoPopulateInput', autoPopulateInput); /** * @name autoPopulateInput diff --git a/components/app-framework/src/utils/focusable-input/focusable-input.directive.js b/components/app-framework/src/utils/focusable-input/focusable-input.directive.js index 940c24a3b7..c85ed94068 100644 --- a/components/app-framework/src/utils/focusable-input/focusable-input.directive.js +++ b/components/app-framework/src/utils/focusable-input/focusable-input.directive.js @@ -27,8 +27,12 @@ element.find('input').on('focus', handleOnFocus); element.find('select-input').on('focus', handleOnFocus); + element.find('tags-input').on('focus', handleOnFocus); + element.find('textarea').on('focus', handleOnFocus); element.find('input').on('blur', handleOnBlur); element.find('select-input').on('blur', handleOnBlur); + element.find('tags-input').on('blur', handleOnBlur); + element.find('textarea').on('blur', handleOnBlur); function handleOnFocus() { element.addClass(focusedClass); diff --git a/components/app-framework/src/utils/long-running/long-running.service.js b/components/app-framework/src/utils/long-running/long-running.service.js index b5b279dc8d..e70ac4c6f9 100644 --- a/components/app-framework/src/utils/long-running/long-running.service.js +++ b/components/app-framework/src/utils/long-running/long-running.service.js @@ -78,7 +78,7 @@ loop(); } }, - loop); + loop); }, interval); } } diff --git a/components/app-framework/src/widgets/actions-toolbar/actions-toolbar.html b/components/app-framework/src/widgets/actions-toolbar/actions-toolbar.html index f4ec4847ab..4c5784ce49 100644 --- a/components/app-framework/src/widgets/actions-toolbar/actions-toolbar.html +++ b/components/app-framework/src/widgets/actions-toolbar/actions-toolbar.html @@ -3,7 +3,7 @@ @@ -23,24 +23,26 @@

{{asyncTaskDialogCtrl.content.title}}

diff --git a/components/app-framework/src/widgets/async-task-dialog/async-task-dialog.service.js b/components/app-framework/src/widgets/async-task-dialog/async-task-dialog.service.js index d2210500ee..03e59ccc7b 100644 --- a/components/app-framework/src/widgets/async-task-dialog/async-task-dialog.service.js +++ b/components/app-framework/src/widgets/async-task-dialog/async-task-dialog.service.js @@ -141,6 +141,11 @@ vm.hasErrorMessage = hasErrorMessage; vm.isFormInvalid = isFormInvalid; + // Ignore enter if event has been marked with preventDefault() + vm.canUseEnter = function (ev) { + return !ev.isDefaultPrevented(); + }; + /** * @name invokeAction * @description invokes the asyn task diff --git a/components/app-framework/src/widgets/detail-view/detail-view.html b/components/app-framework/src/widgets/detail-view/detail-view.html index ec615d9360..d1e0ccd189 100644 --- a/components/app-framework/src/widgets/detail-view/detail-view.html +++ b/components/app-framework/src/widgets/detail-view/detail-view.html @@ -4,5 +4,5 @@

- + diff --git a/components/app-framework/src/widgets/detail-view/detail-view.service.js b/components/app-framework/src/widgets/detail-view/detail-view.service.js index 7d795f9258..6fea9d02a3 100644 --- a/components/app-framework/src/widgets/detail-view/detail-view.service.js +++ b/components/app-framework/src/widgets/detail-view/detail-view.service.js @@ -82,7 +82,8 @@ templateUrl: config.templateUrl, template: config.template, title: config.title, - titleTranslateValues: config.titleTranslateValues + titleTranslateValues: config.titleTranslateValues, + hideClose: config.hideClose }; } }, @@ -101,18 +102,20 @@ } }); - if (config.dialog && openDetailViewCount > 1) { - $timeout(function () { - var dialog = angular.element('.detail-view-dialog .modal-dialog'); - // Latest dialog will be the first element - var thisDialog = angular.element(dialog.get(0)); - var parentDialog = angular.element(dialog.get(1)); - - var overlap = STACKED_HORIZONTAL_MARGIN * 2; - thisDialog.width(parentDialog.width() + overlap); - thisDialog.height(parentDialog.height()); - }); - } + modal.opened.then(function () { + if (config.dialog && openDetailViewCount > 1) { + $timeout(function () { + var dialog = angular.element('.detail-view-dialog .modal-dialog'); + // Latest dialog will be the first element + var thisDialog = angular.element(dialog.get(0)); + var parentDialog = angular.element(dialog.get(1)); + + var overlap = STACKED_HORIZONTAL_MARGIN * 2; + thisDialog.width(parentDialog.width() + overlap); + thisDialog.height(parentDialog.height()); + }); + } + }); modal.rendered.then(function () { $timeout(function () { diff --git a/components/app-framework/src/widgets/file-drop/gitignore.service.js b/components/app-framework/src/widgets/file-drop/gitignore.service.js index 96d690e87b..6a9562c72a 100644 --- a/components/app-framework/src/widgets/file-drop/gitignore.service.js +++ b/components/app-framework/src/widgets/file-drop/gitignore.service.js @@ -60,44 +60,44 @@ */ exports.parse = function (content) { return content.split('\n') - .map(function (line) { - line = line.trim(); - return line; - }) - .filter(function (line) { - return line && line[0] !== '#'; - }) - .reduce(function (lists, line) { - var isNegative = line[0] === '!'; - if (isNegative) { - line = line.slice(1); - } - if (line[0] === '/') { - line = line.slice(1); - } - if (isNegative) { - lists[1].push(line); - } else { - lists[0].push(line); - } - return lists; - }, [[], []]) - .map(function (list) { - return list - .sort() - .map(prepareRegexes) - .reduce(function (list, prepared) { - list[0].push(prepared[0]); - list[1].push(prepared[1]); - return list; - }, [[], [], []]); - }) - .map(function (item) { - return [ - item[0].length > 0 ? new RegExp('^((' + item[0].join(')|(') + '))') : new RegExp('$^'), - item[1].length > 0 ? new RegExp('^((' + item[1].join(')|(') + '))') : new RegExp('$^') - ]; - }); + .map(function (line) { + line = line.trim(); + return line; + }) + .filter(function (line) { + return line && line[0] !== '#'; + }) + .reduce(function (lists, line) { + var isNegative = line[0] === '!'; + if (isNegative) { + line = line.slice(1); + } + if (line[0] === '/') { + line = line.slice(1); + } + if (isNegative) { + lists[1].push(line); + } else { + lists[0].push(line); + } + return lists; + }, [[], []]) + .map(function (list) { + return list + .sort() + .map(prepareRegexes) + .reduce(function (list, prepared) { + list[0].push(prepared[0]); + list[1].push(prepared[1]); + return list; + }, [[], [], []]); + }) + .map(function (item) { + return [ + item[0].length > 0 ? new RegExp('^((' + item[0].join(')|(') + '))') : new RegExp('$^'), + item[1].length > 0 ? new RegExp('^((' + item[1].join(')|(') + '))') : new RegExp('$^') + ]; + }); }; function prepareRegexes(pattern) { @@ -115,15 +115,15 @@ function preparePartialRegex(pattern) { return pattern - .split('/') - .map(function (item, index) { - if (index) { - return '([\\/]?(' + prepareRegexPattern(item) + '\\b|$))'; - } else { - return '(' + prepareRegexPattern(item) + '\\b)'; - } - }) - .join(''); + .split('/') + .map(function (item, index) { + if (index) { + return '([\\/]?(' + prepareRegexPattern(item) + '\\b|$))'; + } else { + return '(' + prepareRegexPattern(item) + '\\b)'; + } + }) + .join(''); } function escapeRegex(pattern) { diff --git a/components/app-framework/src/widgets/gallery-card/gallery-card.html b/components/app-framework/src/widgets/gallery-card/gallery-card.html index 5e888a8021..e75b3c2d6c 100644 --- a/components/app-framework/src/widgets/gallery-card/gallery-card.html +++ b/components/app-framework/src/widgets/gallery-card/gallery-card.html @@ -5,6 +5,7 @@ {{ galleryCardCtrl.cardData.titleTranslate }} diff --git a/components/app-framework/src/widgets/json-text-input/json-text-input.directive.js b/components/app-framework/src/widgets/json-text-input/json-text-input.directive.js new file mode 100644 index 0000000000..f1edfbbbd8 --- /dev/null +++ b/components/app-framework/src/widgets/json-text-input/json-text-input.directive.js @@ -0,0 +1,34 @@ +(function () { + 'use strict'; + + angular + .module('app.framework.widgets') + .directive('jsonTextInput', jsonTextInput); + + /** + * @name jsonTextInput + * @description A directive that displays JSON. + * @returns {object} The directive definition object + */ + function jsonTextInput() { + return { + restrict: 'A', + require: 'ngModel', + scope: { + ngModel: '=' + }, + link: function ($scope, elem, attr, ngModelCtrl) { + ngModelCtrl.$parsers.push(function (viewValue) { + try { + return angular.fromJson(viewValue); + } catch (e) { + return undefined; + } + }); + ngModelCtrl.$formatters.push(function (value) { + return angular.toJson(value, 2); + }); + } + }; + } +})(); diff --git a/components/app-framework/src/widgets/json-text-input/json-text-input.scss b/components/app-framework/src/widgets/json-text-input/json-text-input.scss new file mode 100644 index 0000000000..2a4cb917bb --- /dev/null +++ b/components/app-framework/src/widgets/json-text-input/json-text-input.scss @@ -0,0 +1,16 @@ +.json-input-field { + flex: 1 1 0; + display: flex; + flex-direction: column; + + &.form-group { + width: 100%; + } + + textarea { + outline: 0; + border: 0; + font-family: monospace; + font-size: $font-size-monospace; + } +} \ No newline at end of file diff --git a/components/app-framework/src/widgets/select-input/select-input.directive.js b/components/app-framework/src/widgets/select-input/select-input.directive.js index fd54327b93..73d1ca0559 100644 --- a/components/app-framework/src/widgets/select-input/select-input.directive.js +++ b/components/app-framework/src/widgets/select-input/select-input.directive.js @@ -68,7 +68,7 @@ } function handleKeypress(event) { - var enterSpaceKeyCodes = [13, 32]; // enter or space + var enterSpaceKeyCodes = [13, 32]; // enter or space var keyCode = event.which || event.keyCode; var charStr = String.fromCharCode(keyCode); @@ -131,7 +131,7 @@ */ function init() { $scope.$watch(function () { - return vm.inputOptions.length; + return vm.inputOptions ? vm.inputOptions.length : 0; }, function (length) { if (length === 1) { vm.setValue(vm.inputOptions[0]); diff --git a/components/app-framework/src/widgets/tags-input/tags-input.scss b/components/app-framework/src/widgets/tags-input/tags-input.scss new file mode 100644 index 0000000000..d6f5f5b9a0 --- /dev/null +++ b/components/app-framework/src/widgets/tags-input/tags-input.scss @@ -0,0 +1,49 @@ +.tags-input-field.form-group { + height: auto; + + // Override default styles for the tags-input directive + tags-input { + + .host { + margin: 0; + min-height: 32px; + outline: 0; + } + + .tags { + box-shadow: none; + border: 0; + padding: 0; + + &.focused { + outline: 0; + border: 0; + box-shadow: none; + } + + input.input { + font-family: $font-family-base; + font-size: $font-size-base; + height: 26px; + margin-bottom: 0; + } + + .tag-item { + background: none; + font-family: $font-family-base; + font-size: $font-size-base; + margin: 2px 6px 2px 0; + + &.selected { + background: $suse-primary; + } + + ti-tag-item { + ng-include { + margin: 0 !important; + } + } + } + } + } +} diff --git a/components/app-framework/src/widgets/widgets.scss b/components/app-framework/src/widgets/widgets.scss index d83463e6fb..b5a8dfad26 100644 --- a/components/app-framework/src/widgets/widgets.scss +++ b/components/app-framework/src/widgets/widgets.scss @@ -11,6 +11,7 @@ @import "flyout/flyout"; @import "gallery-card/gallery-card"; @import "global-spinner/global-spinner"; +@import "json-text-input/json-text-input"; @import "json-tree-view/json-tree-view"; @import "log-viewer/logs-viewer"; @import "paginator/paginator"; @@ -24,6 +25,8 @@ @import "status-indicator/status-indicator"; @import "tabbed-nav/tabbed-nav"; @import "table-inline-message/table-inline-message"; +@import "tags-input/tags-input"; @import "toaster/toast"; @import "wizard/wizard"; @import "auto-update/auto-update"; + diff --git a/components/app-framework/src/widgets/wizard/wizard.directive.js b/components/app-framework/src/widgets/wizard/wizard.directive.js index f22b980876..7b120bd6ce 100644 --- a/components/app-framework/src/widgets/wizard/wizard.directive.js +++ b/components/app-framework/src/widgets/wizard/wizard.directive.js @@ -215,8 +215,8 @@ */ function disableNext() { var step = vm.steps[vm.currentIndex] || {}; - if (_.isFunction(step.allowNext) && !step.allowNext()) { - return true; + if (_.isFunction(step.allowNext)) { + return !step.allowNext(); } if ($scope.wizardForm) { var form = $scope.wizardForm[step.formName]; @@ -331,26 +331,26 @@ }; } }) - .then(function () { - if (step.isLastStep) { - vm.resetMessage(); - vm.actions.finish(vm); - } else { - vm.switchTo(index + 1).then(function () { + .then(function () { + if (step.isLastStep) { vm.resetMessage(); - }); - } - }, function (message) { + vm.actions.finish(vm); + } else { + vm.switchTo(index + 1).then(function () { + vm.resetMessage(); + }); + } + }, function (message) { // Hide the loading indicator if we showed one - vm.currentIndex = index; - vm.busyMessage = false; - if (message) { - vm.showMessage(message, 'alert-danger'); - } else { - vm.resetMessage(); - } - vm.resetButtons(); - }); + vm.currentIndex = index; + vm.busyMessage = false; + if (message) { + vm.showMessage(message, 'alert-danger'); + } else { + vm.resetMessage(); + } + vm.resetButtons(); + }); } else { if (step.isLastStep) { vm.actions.finish(vm); diff --git a/components/app-framework/src/widgets/wizard/wizard.html b/components/app-framework/src/widgets/wizard/wizard.html index df013b04eb..80814ed167 100644 --- a/components/app-framework/src/widgets/wizard/wizard.html +++ b/components/app-framework/src/widgets/wizard/wizard.html @@ -69,36 +69,37 @@

{{ wizardCtrl.workflow.title }}

+
+ - - - + - + +
diff --git a/components/app-framework/src/widgets/wizard/wizard.scss b/components/app-framework/src/widgets/wizard/wizard.scss index 4ff52ae3bb..5b8a6f5ef0 100644 --- a/components/app-framework/src/widgets/wizard/wizard.scss +++ b/components/app-framework/src/widgets/wizard/wizard.scss @@ -14,4 +14,26 @@ .wizard-nav-step-number, .wizard-nav-step-number-complete, .wizard-nav-item-sep { display: none; -} \ No newline at end of file +} + + +.wizard-foot { + overflow: hidden; + .wizard-foot-buttons { + + button { + transition: margin-left 0.5s, margin-right 0.5s; + } + + &.wizard-footer-hidden { + + button:first-child { + margin-left: -200px + } + + button:last-child { + margin-right: -200px + } + } + } +} diff --git a/components/app-setup/frontend/src/plugin.config.js b/components/app-setup/frontend/src/plugin.config.js index 30474c154a..ecc78dec0d 100644 --- a/components/app-setup/frontend/src/plugin.config.js +++ b/components/app-setup/frontend/src/plugin.config.js @@ -4,10 +4,10 @@ // register this plugin application to the platform if (env && env.registerApplication) { env.registerApplication( - 'appSetup', // plugin application identity - 'app-setup', // plugin application's root angular module name - 'plugins/app-setup/', // plugin application's base path - 'setup' // Main start state + 'appSetup', // plugin application identity + 'app-setup', // plugin application's root angular module name + 'plugins/app-setup/', // plugin application's base path + 'setup' // Main start state ); } diff --git a/components/app-theme/src/scss/style/components/_async_task_dialog.scss b/components/app-theme/src/scss/style/components/_async_task_dialog.scss index bd3fd3f0ff..cf5dced94d 100644 --- a/components/app-theme/src/scss/style/components/_async_task_dialog.scss +++ b/components/app-theme/src/scss/style/components/_async_task_dialog.scss @@ -1,3 +1,6 @@ +$async-dialog-header-height: $console-unit-space * 3; +$async-dialog-footer-height: $button-height + $console-unit-space * 2 + 1; + .async-dialog { margin-right: $detail-view-margin; margin-left: $detail-view-margin; @@ -20,10 +23,33 @@ .modal-footer { margin-top: $console-unit-space * 2; padding-top: $console-unit-space; + height: $async-dialog-footer-height; + overflow: hidden; + flex: 0 0 $async-dialog-footer-height; } .disable-margin { margin-top: 0; } + + .modal-footer { + .async-footer-buttons { + + button { + transition: margin-left 0.5s, margin-right 0.5s; + } + + &.async-footer-hidden { + + button:first-child { + margin-left: -200px + } + + button:last-child { + margin-right: -200px + } + } + } + } } diff --git a/components/app-theme/src/scss/theme/components/_actions-toolbar.scss b/components/app-theme/src/scss/theme/components/_actions-toolbar.scss index 9fd4651476..6acaecf5ed 100644 --- a/components/app-theme/src/scss/theme/components/_actions-toolbar.scss +++ b/components/app-theme/src/scss/theme/components/_actions-toolbar.scss @@ -4,7 +4,8 @@ $actions-toolbar-hover-bg: darken($white, 10%) !default; $actions-toolbar-height: $button-height !default; -$actions-toolbar-menu-hover-color: $link-color !default; +$actions-toolbar-menu-color: $link-color !default; +$actions-toolbar-menu-hover-color: $link-hover-color !default; $actions-toolbar-spacing: 4px !default; $actions-toolbar-menu-gripper-size: 28px !default; $actions-toolbar-icon-svg-size: 16px !default; diff --git a/components/cf-app-push/backend/deploy.go b/components/cf-app-push/backend/deploy.go index 8fc4133d61..ed169a3d89 100644 --- a/components/cf-app-push/backend/deploy.go +++ b/components/cf-app-push/backend/deploy.go @@ -81,6 +81,7 @@ const ( SOURCE_FILE SOURCE_FILE_DATA SOURCE_FILE_ACK + SOURCE_GITURL ) const ( @@ -119,21 +120,36 @@ var upgrader = websocket.Upgrader{ } type StratosProject struct { - Url string `json:"url"` - CommitHash string `json:"commit"` - Branch string `json:"branch"` + DeploySource interface{} `json:"deploySource"` +} + +type DeploySource struct { + SourceType string `json:"type"` Timestamp int64 `json:"timestamp"` } // Structure used to provide metadata about the GitHub source type GitHubSourceInfo struct { - Project string `json:"project"` - Branch string `json:"branch"` + DeploySource + Project string `json:"project"` + Branch string `json:"branch"` + Url string `json:"url"` + CommitHash string `json:"commit"` +} + +// Structure used to provide metadata about the Git Url source +type GitUrlSourceInfo struct { + DeploySource + Project string `json:"project"` + Branch string `json:"branch"` + Url string `json:"url"` + CommitHash string `json:"commit"` } type FolderSourceInfo struct { + DeploySource Files int `json:"files"` - Folders []string `json:"folders"` + Folders []string `json:"folders,omitempty"` } func (cfAppPush *CFAppPush) deploy(echoContext echo.Context) error { @@ -157,7 +173,6 @@ func (cfAppPush *CFAppPush) deploy(echoContext echo.Context) error { defer pingTicker.Stop() // We use a simple protocol to get the source to use for cf push - // This can either be a github project or one or more files // Send a message to the client to say that we are awaiting source details sendEvent(clientWebSocket, SOURCE_REQUIRED) @@ -185,6 +200,8 @@ func (cfAppPush *CFAppPush) deploy(echoContext echo.Context) error { sourceEnvVarMetadata, appDir, err = getGitHubSource(clientWebSocket, tempDir, msg) case SOURCE_FOLDER: sourceEnvVarMetadata, appDir, err = getFolderSource(clientWebSocket, tempDir, msg) + case SOURCE_GITURL: + sourceEnvVarMetadata, appDir, err = getGitUrlSource(clientWebSocket, tempDir, msg) default: err = errors.New("Unsupported source type; don't know how to get the source for the application") } @@ -253,6 +270,7 @@ func (cfAppPush *CFAppPush) deploy(echoContext echo.Context) error { func getFolderSource(clientWebSocket *websocket.Conn, tempDir string, msg SocketMessage) (string, string, error) { // The msg data is JSON for the Folder info info := FolderSourceInfo{} + if err := json.Unmarshal([]byte(msg.Message), &info); err != nil { return "", tempDir, err } @@ -317,6 +335,9 @@ func getFolderSource(clientWebSocket *websocket.Conn, tempDir string, msg Socket archiver := getArchiverFor(lastFilePath) if archiver != nil { + // Overwrite generic 'filefolder' type + info.DeploySource.SourceType = "archive" + log.Debug("Unpacking archive ......") unpackPath := filepath.Join(tempDir, "application") err := os.Mkdir(unpackPath, 0700) @@ -337,12 +358,21 @@ func getFolderSource(clientWebSocket *websocket.Conn, tempDir string, msg Socket } // Archive done - return "", unpackPath, nil + tempDir = unpackPath } } // All done! - return "", tempDir, nil + + // Return a string that can be added to the manifest as an application env var to trace where the source originated + info.Timestamp = time.Now().Unix() + info.Folders = nil + stratosProject := StratosProject{ + DeploySource: info, + } + + marshalledJson, _ := json.Marshal(stratosProject) + return string(marshalledJson), tempDir, nil } // Check the suffix of the file name and return an archiver that can handle that file type @@ -360,17 +390,52 @@ func getArchiverFor(filePath string) archiver.Archiver { } func getGitHubSource(clientWebSocket *websocket.Conn, tempDir string, msg SocketMessage) (string, string, error) { + var ( + err error + ) // The msg data is JSON for the GitHub info info := GitHubSourceInfo{} - if err := json.Unmarshal([]byte(msg.Message), &info); err != nil { + if err = json.Unmarshal([]byte(msg.Message), &info); err != nil { + return "", tempDir, err + } + + info.Url = fmt.Sprintf("https://github.com/%s", info.Project) + log.Infof("GitHub Source: %s, branch %s, url: %s", info.Project, info.Branch, info.Url) + + info.CommitHash, err = cloneRepository(info.Url, info.Branch, clientWebSocket, tempDir) + if err != nil { + return "", tempDir, err + } + + sendEvent(clientWebSocket, EVENT_CLONED) + + // Return a string that can be added to the manifest as an application env var to trace where the source originated + info.Timestamp = time.Now().Unix() + stratosProject := StratosProject{ + DeploySource: info, + } + + marshalledJson, _ := json.Marshal(stratosProject) + return string(marshalledJson), tempDir, nil +} + +func getGitUrlSource(clientWebSocket *websocket.Conn, tempDir string, msg SocketMessage) (string, string, error) { + + var ( + err error + ) + + // The msg data is JSON for the GitHub info + info := GitUrlSourceInfo{} + + if err = json.Unmarshal([]byte(msg.Message), &info); err != nil { return "", tempDir, err } - projectUrl := fmt.Sprintf("https://github.com/%s", info.Project) - log.Infof("GitHub Source: %s, branch %s, url: %s", info.Project, info.Branch, projectUrl) + log.Infof("Git Url Source: %s, branch %s", info.Url, info.Branch) - commitHash, err := cloneRepository(projectUrl, info.Branch, clientWebSocket, tempDir) + info.CommitHash, err = cloneRepository(info.Url, info.Branch, clientWebSocket, tempDir) if err != nil { return "", tempDir, err } @@ -378,11 +443,9 @@ func getGitHubSource(clientWebSocket *websocket.Conn, tempDir string, msg Socket sendEvent(clientWebSocket, EVENT_CLONED) // Return a string that can be added to the manifest as an application env var to trace where the source originated + info.Timestamp = time.Now().Unix() stratosProject := StratosProject{ - Url: projectUrl, - CommitHash: commitHash, - Branch: info.Branch, - Timestamp: time.Now().Unix(), + DeploySource: info, } marshalledJson, _ := json.Marshal(stratosProject) @@ -532,22 +595,22 @@ func (cfAppPush *CFAppPush) getConfigData(echoContext echo.Context, cnsiGuid str func cloneRepository(repoUrl string, branch string, clientWebSocket *websocket.Conn, tempDir string) (string, error) { + if len(branch) == 0 { + err := errors.New("No branch supplied") + log.Infof("Failed to checkout repo %s due to %+v", branch, repoUrl, err) + sendErrorMessage(clientWebSocket, err, CLOSE_FAILED_NO_BRANCH) + return "", err + } + vcsGit := GetVCS() - err := vcsGit.Create(tempDir, repoUrl) + err := vcsGit.Create(tempDir, repoUrl, branch) if err != nil { log.Infof("Failed to clone repo %s due to %+v", repoUrl, err) sendErrorMessage(clientWebSocket, err, CLOSE_FAILED_CLONE) return "", err } - err = vcsGit.Checkout(tempDir, branch) - if err != nil { - log.Infof("Failed to checkout %s branch in repo %s due to %+v", branch, repoUrl, err) - sendErrorMessage(clientWebSocket, err, CLOSE_FAILED_NO_BRANCH) - return "", err - } - head, err := vcsGit.Head(tempDir) if err != nil { log.Infof("Unable to fetch HEAD in branch due to %s", err) diff --git a/components/cf-app-push/backend/main.go b/components/cf-app-push/backend/main.go index 289a53ca97..0984e0708d 100644 --- a/components/cf-app-push/backend/main.go +++ b/components/cf-app-push/backend/main.go @@ -5,8 +5,8 @@ import ( "code.cloudfoundry.org/cli/cf/commands/application" "code.cloudfoundry.org/cli/cf/flags" - "github.com/labstack/echo" "github.com/SUSE/stratos-ui/components/app-core/backend/repository/interfaces" + "github.com/labstack/echo" ) type CFAppPush struct { diff --git a/components/cf-app-push/backend/vcs.go b/components/cf-app-push/backend/vcs.go index a4cec00bf1..a5ae021a1d 100644 --- a/components/cf-app-push/backend/vcs.go +++ b/components/cf-app-push/backend/vcs.go @@ -1,4 +1,5 @@ package main + // Based on https://github.com/golang/go/blob/master/src/cmd/go/internal/get/vcs.go import ( @@ -13,7 +14,7 @@ import ( var vcsGit = &vcsCmd{ name: "Git", cmd: "git", - createCmd: []string{"clone {repo} {dir}"}, + createCmd: []string{"clone -b {branch} {repo} {dir}"}, checkoutCmd: []string{"checkout refs/remotes/origin/{branch}"}, headCmd: []string{"rev-parse HEAD"}, } @@ -24,17 +25,17 @@ func GetVCS() *vcsCmd { } type vcsCmd struct { - name string - cmd string // name of binary to invoke command + name string + cmd string // name of binary to invoke command createCmd []string // commands to download a fresh copy of a repository checkoutCmd []string // commands to checkout a branch headCmd []string // get current head commit } -func (vcs *vcsCmd) Create(dir string, repo string) error { +func (vcs *vcsCmd) Create(dir string, repo string, branch string) error { for _, cmd := range vcs.createCmd { - if err := vcs.run(".", cmd, "dir", dir, "repo", repo); err != nil { + if err := vcs.run(".", cmd, "dir", dir, "repo", repo, "branch", branch); err != nil { return err } } @@ -72,7 +73,7 @@ func (v *vcsCmd) run1(dir string, cmdline string, keyval []string, verbose bool) m := make(map[string]string) for i := 0; i < len(keyval); i += 2 { - m[keyval[i]] = keyval[i + 1] + m[keyval[i]] = keyval[i+1] } args := strings.Fields(cmdline) for i, arg := range args { @@ -102,7 +103,7 @@ func (v *vcsCmd) run1(dir string, cmdline string, keyval []string, verbose bool) func expand(match map[string]string, s string) string { for k, v := range match { - s = strings.Replace(s, "{" + k + "}", v, -1) + s = strings.Replace(s, "{"+k+"}", v, -1) } return s } @@ -113,7 +114,7 @@ func EnvForDir(dir string, base []string) []string { func MergeEnvLists(in, out []string) []string { out = append([]string(nil), out...) - NextVar: +NextVar: for _, inkv := range in { k := strings.SplitAfterN(inkv, "=", 2)[0] for i, outkv := range out { diff --git a/components/cf-app-push/frontend/i18n/en_US/app-push.json b/components/cf-app-push/frontend/i18n/en_US/app-push.json index 0be3c74a58..9f01dbcf6b 100644 --- a/components/cf-app-push/frontend/i18n/en_US/app-push.json +++ b/components/cf-app-push/frontend/i18n/en_US/app-push.json @@ -14,16 +14,33 @@ "title": "Source", "choose-label": "Source Type", "choose-placeholder": "Select the source type", - "github": { - "label": "Public Github", - "project-label": "Project", - "project-placeholder": "suse/stratos-ui", - "project-not-found": "Project not found", - "branch-label": "Project Branch", - "branch-placeholder": "Select Branch", - "branch-last-commit": "", - "branch-last-commit-date": "", - "branch-last-commit-committer": "" + "git": { + "git": "Git", + "github": { + "github": "Public Github", + "project-label": "Project", + "project-placeholder": "suse/stratos-ui", + "project-not-found": "Project not found", + "branch-label": "Branch", + "branch-placeholder": "Select Branch", + "branch-last-commit": "", + "branch-last-commit-date": "", + "branch-last-commit-committer": "" + }, + "gitUrl": { + "gitUrl": "Public Git URL", + "url": { + "url": "URL", + "placeholder": "HTTPS URL of git repository", + "error-required": "URL is required", + "error-pattern": "Invalid URL" + }, + "branch": { + "branch": "Branch Name", + "placeholder": "Branch name will decrease clone time", + "error-required": "Branch is required" + } + } }, "source": { "file": "Select a source or drag and drop a GitHub url or an archive file", @@ -64,8 +81,8 @@ }, "socket": { "event-type": { - "CLOSE_FAILED_NO_BRANCH": "Failed to clone repository (invalid branch), please ensure github details are correct", - "CLOSE_FAILED_CLONE": "Failed to clone repository, please ensure github details are correct and try again", + "CLOSE_FAILED_NO_BRANCH": "Failed to clone repository (no branch supplied), please ensure git details are correct", + "CLOSE_FAILED_CLONE": "Failed to clone repository, please ensure git details are correct and try again", "CLOSE_FAILURE": "Unknown failure. See log for more information.", "CLOSE_INVALID_MANIFEST": "Invalid manifest", "CLOSE_NO_MANIFEST": "Failed to find manifest", diff --git a/components/cf-app-push/frontend/src/plugin.config.js b/components/cf-app-push/frontend/src/plugin.config.js index 781c18ec62..30b1548eef 100644 --- a/components/cf-app-push/frontend/src/plugin.config.js +++ b/components/cf-app-push/frontend/src/plugin.config.js @@ -4,9 +4,9 @@ // register this plugin application to the platform if (env && env.registerApplication) { env.registerApplication( - 'cfAppPush', // plugin application identity - 'cf-app-push', // plugin application's root angular module name - 'plugins/cf-app-push/', // plugin application's base path + 'cfAppPush', // plugin application identity + 'cf-app-push', // plugin application's root angular module name + 'plugins/cf-app-push/', // plugin application's base path '' // plugin applications's start state ); } diff --git a/components/cf-app-push/frontend/src/view/deploy-app-workflow/deploy-app.scss b/components/cf-app-push/frontend/src/view/deploy-app-workflow/deploy-app.scss index 7646693b90..f68ac31547 100644 --- a/components/cf-app-push/frontend/src/view/deploy-app-workflow/deploy-app.scss +++ b/components/cf-app-push/frontend/src/view/deploy-app-workflow/deploy-app.scss @@ -1,4 +1,4 @@ -$deploy-app-step-height: 350px; +$deploy-app-step-height: 392px; $deploy-app-step-padding-bottom: 6px; @import "deploy-step-deploying/deploy-step-deploying"; diff --git a/components/cf-app-push/frontend/src/view/deploy-app-workflow/deploy-app.service.js b/components/cf-app-push/frontend/src/view/deploy-app-workflow/deploy-app.service.js index 983da52609..2967b659fc 100644 --- a/components/cf-app-push/frontend/src/view/deploy-app-workflow/deploy-app.service.js +++ b/components/cf-app-push/frontend/src/view/deploy-app-workflow/deploy-app.service.js @@ -82,7 +82,7 @@ * @param {object} appDeployStepDeployingService - Service to provide the deploying step */ function DeployAppController($scope, $uibModalInstance, $state, appDeployStepDestinationService, - appDeployStepSourceService, appDeployStepDeployingService) { + appDeployStepSourceService, appDeployStepDeployingService) { var vm = this; @@ -95,7 +95,7 @@ }, wizard: { allowBack: true, - sourceType: 'github' + sourceType: 'git' } }; diff --git a/components/cf-app-push/frontend/src/view/deploy-app-workflow/deploy-step-deploying/deploy-step-deploying.service.js b/components/cf-app-push/frontend/src/view/deploy-app-workflow/deploy-step-deploying/deploy-step-deploying.service.js index a6fd8a36ba..4529e8fc4a 100644 --- a/components/cf-app-push/frontend/src/view/deploy-app-workflow/deploy-step-deploying/deploy-step-deploying.service.js +++ b/components/cf-app-push/frontend/src/view/deploy-app-workflow/deploy-step-deploying/deploy-step-deploying.service.js @@ -42,7 +42,8 @@ SOURCE_FOLDER: 30002, SOURCE_FILE: 30003, SOURCE_FILE_DATA: 30004, - SOURCE_FILE_ACK: 30005 + SOURCE_FILE_ACK: 30005, + SOURCE_GITURL: 30006 }; // How often to check for the app being created @@ -176,17 +177,26 @@ } function sendSourceMetadata() { - if (wizardData.sourceType === 'github') { - sendGitHubSourceMetadata(); + if (wizardData.sourceType === 'git') { + sendGitMetadata(); } else if (wizardData.sourceType === 'local') { sendLocalSourceMetadata(); } } + function sendGitMetadata() { + if (sourceUserInput.gitType === 'github') { + sendGitHubSourceMetadata(); + } else if (sourceUserInput.gitType === 'giturl') { + sendGitUrlSourceMetadata(); + } + } + function sendGitHubSourceMetadata() { var github = { project: sourceUserInput.githubProject, - branch: sourceUserInput.githubBranch.name + branch: sourceUserInput.githubBranch.name, + type: sourceUserInput.gitType }; var msg = { @@ -199,6 +209,23 @@ data.webSocket.send(angular.toJson(msg)); } + function sendGitUrlSourceMetadata() { + var giturl = { + url: sourceUserInput.gitUrl, + branch: sourceUserInput.gitUrlBranch, + type: sourceUserInput.gitType + }; + + var msg = { + message: angular.toJson(giturl), + timestamp: Math.round((new Date()).getTime() / 1000), + type: socketEventTypes.SOURCE_GITURL + }; + + // Send the source metadata + data.webSocket.send(angular.toJson(msg)); + } + function sendLocalSourceMetadata() { var metadata = { files: [], @@ -209,6 +236,7 @@ sourceUserInput.fileTransfers = metadata.files; metadata.files = metadata.files.length; + metadata.type = 'filefolder'; data.uploadingFiles = { remaining: metadata.files, bytes: 0, diff --git a/components/cf-app-push/frontend/src/view/deploy-app-workflow/deploy-step-source/deploy-source-git/deploy-source-git.directive.js b/components/cf-app-push/frontend/src/view/deploy-app-workflow/deploy-step-source/deploy-source-git/deploy-source-git.directive.js index 653a24b46f..ea65d92ca7 100644 --- a/components/cf-app-push/frontend/src/view/deploy-app-workflow/deploy-step-source/deploy-source-git/deploy-source-git.directive.js +++ b/components/cf-app-push/frontend/src/view/deploy-app-workflow/deploy-step-source/deploy-source-git/deploy-source-git.directive.js @@ -9,8 +9,8 @@ * @namespace cf-app-push.accountActions * @memberof cf-app-push * @name DeploySourceGit - * @description ???????? - * @returns {object} The ???????? directive definition object + * @description Directive to aid user in selecting a git source for their application + * @returns {object} The DeploySourceGit directive definition object */ function DeploySourceGit() { return { @@ -42,6 +42,15 @@ githubBranches: [] }; + vm.userInput.gitType = vm.userInput.gitType || 'github'; + vm.userInput.gitUrlBranch = vm.userInput.gitUrlBranch || 'master'; + + vm.isGithub = isGithub; + vm.isGitUrl = isGitUrl; + // For now mandate https (pp container does not support ssh) + vm.gitUrlRegEx = /https:(\/\/)?(.*?)(\.git)(\/?|\#[-\d\w._]+?)$/; + // vm.gitUrlRegEx = /(?:git|ssh|https?|git@[-\w.]+):(\/\/)?(.*?)(\.git)(\/?|\#[-\d\w._]+?)$/; + var gitHubUrlBase = 'https://github.com/'; var debounceGithubProjectFetch = _.debounce(function () { @@ -90,25 +99,21 @@ }, 1000); $scope.$watch(function () { - return vm.userInput.githubProjectValid && - vm.userInput.githubProject && + var gitType = vm.userInput.gitType; + var gitHub = vm.userInput.githubProject && vm.userInput.githubBranch && - vm.userInput.githubBranch.name; - }, function (newVal) { - vm.valid = newVal; - }); + vm.userInput.githubBranch.name || false; + var gitUrl = vm.userInput.gitUrl && vm.userInput.gitUrlBranch || false; + return gitType.toString() + gitHub.toString() + gitUrl.toString(); + }, updateValid); - $scope.$watch(function () { - return vm.userInput.githubProject; - }, function (newVal, oldVal) { + $scope.$watch('dplyGitCtrl.userInput.githubProject', function (newVal, oldVal) { if (oldVal !== newVal) { debounceGithubProjectFetch(); } }); - $scope.$watch(function () { - return vm.userInput.githubBranch; - }, function (newVal, oldVal) { + $scope.$watch('dplyGitCtrl.userInput.githubBranch', function (newVal, oldVal) { if (newVal && oldVal !== newVal) { $http.get('https://api.github.com/repos/' + vm.userInput.githubProject + '/commits/' + newVal.commit.sha) .then(function (response) { @@ -120,14 +125,21 @@ } }); - $scope.$watch(function () { - return vm.dropInfo; - }, function (newVal, oldVal) { + $scope.$watch('dplyGitCtrl.dropInfo', function (newVal, oldVal) { if (oldVal !== newVal) { var info = newVal; - // Check if this is a GitHub link - if (angular.isString(info.value) && info.value.toLowerCase().indexOf(gitHubUrlBase) === 0) { - vm.sourceType = 'github'; + if (!info.isWebLink || !angular.isString(info.value)) { + return; + } + + if (vm.gitUrlRegEx.test(info.value)) { + vm.sourceType = 'git'; + vm.userInput.gitType = 'giturl'; + vm.userInput.gitUrl = info.value; + } else if (info.value.toLowerCase().indexOf(gitHubUrlBase) === 0) { + // Check if this is a GitHub link + vm.sourceType = 'git'; + vm.userInput.gitType = 'github'; var urlParts = info.value.substring(gitHubUrlBase.length).split('/'); if (urlParts.length > 1) { var branch; @@ -147,6 +159,14 @@ } }); + function isGithub() { + return vm.userInput.gitType === 'github'; + } + + function isGitUrl() { + return vm.userInput.gitType === 'giturl'; + } + function selectBranch(branch) { var foundBranch = _.find(vm.data.githubBranches, function (o) { return o.value && o.value.name === branch; @@ -154,6 +174,31 @@ vm.userInput.githubBranch = foundBranch ? foundBranch.value : undefined; } + function isGitUrlValid() { + return $scope.formGitUrl.$valid; + } + + function isGithubValid() { + return vm.userInput.githubProjectValid && + vm.userInput.githubProject && + vm.userInput.githubBranch && + vm.userInput.githubBranch.name; + } + + function updateValid() { + switch (vm.userInput.gitType) { + case 'giturl': + vm.valid = !!isGitUrlValid(); + break; + case 'github': + vm.valid = !!isGithubValid(); + break; + default: + vm.valid = false; + break; + } + } + } })(); diff --git a/components/cf-app-push/frontend/src/view/deploy-app-workflow/deploy-step-source/deploy-source-git/deploy-source-git.html b/components/cf-app-push/frontend/src/view/deploy-app-workflow/deploy-step-source/deploy-source-git/deploy-source-git.html index 1a4f490b52..995520deb8 100644 --- a/components/cf-app-push/frontend/src/view/deploy-app-workflow/deploy-step-source/deploy-source-git/deploy-source-git.html +++ b/components/cf-app-push/frontend/src/view/deploy-app-workflow/deploy-step-source/deploy-source-git/deploy-source-git.html @@ -1,22 +1,27 @@
+
+ +
- + ng-class="{'has-error': dplyGitCtrl.isGithub() && dplyGitCtrl.userInput.githubProjectValid === false, 'form-group-disabled': !dplyGitCtrl.isGithub()}"> + - deploy-app-dialog.step-source.github.project-not-found - + deploy-app-dialog.step-source.git.github.project-not-found + + placeholder="{{'deploy-app-dialog.step-source.git.github.project-placeholder' | translate}}" + />
-
- +
+
@@ -43,17 +48,17 @@
- deploy-app-dialog.step-source.github.branch-last-commit + deploy-app-dialog.step-source.git.github.branch-last-commit {{ dplyGitCtrl.data.githubCommit.sha | limitTo:6}} {{ dplyGitCtrl.data.githubCommit.commit.message | limitTo:50}}...
- deploy-app-dialog.step-source.github.branch-last-commit-committer{{ dplyGitCtrl.data.githubCommit.author.login || dplyGitCtrl.data.githubCommit.commit.author.name}} + deploy-app-dialog.step-source.git.github.branch-last-commit-committer{{ dplyGitCtrl.data.githubCommit.author.login || dplyGitCtrl.data.githubCommit.commit.author.name}}
- deploy-app-dialog.step-source.github.branch-last-commit-date {{ dplyGitCtrl.data.githubCommit.commit.author.date | momentDateFormat }} + deploy-app-dialog.step-source.git.github.branch-last-commit-date {{ dplyGitCtrl.data.githubCommit.commit.author.date | momentDateFormat }}
@@ -61,4 +66,37 @@
+
+
+ +
+
+ +
+ deploy-app-dialog.step-source.git.gitUrl.url.error-required + deploy-app-dialog.step-source.git.gitUrl.url.error-pattern +
+ +
+
+ +
+ deploy-app-dialog.step-source.git.gitUrl.branch.error-required +
+ +
+
\ No newline at end of file diff --git a/components/cf-app-push/frontend/src/view/deploy-app-workflow/deploy-step-source/deploy-source-git/deploy-source-git.scss b/components/cf-app-push/frontend/src/view/deploy-app-workflow/deploy-step-source/deploy-source-git/deploy-source-git.scss index 70b1a41742..d2830d4306 100644 --- a/components/cf-app-push/frontend/src/view/deploy-app-workflow/deploy-step-source/deploy-source-git/deploy-source-git.scss +++ b/components/cf-app-push/frontend/src/view/deploy-app-workflow/deploy-step-source/deploy-source-git/deploy-source-git.scss @@ -15,90 +15,100 @@ $select-option-height: 39px; max-height: $select-option-height * 3; } } - } - .deploy-app-source-details { + .deploy-app-source-details { - &.deploy-app-invisible { - visibility: hidden; - } + &.deploy-app-invisible { + visibility: hidden; + } - &.deploy-app-github-details { - margin-top: $console-unit-space / 2; + &.deploy-app-github-details { + margin-top: $console-unit-space / 2; - .deploy-app-github-info { - border: 1px solid $rule; - padding: $console-unit-space / 4 $console-unit-space / 2; - max-width: $console-input-width * 1.25; + .deploy-app-github-info { + border: 1px solid $rule; + padding: $console-unit-space / 4 $console-unit-space / 2; + max-width: $console-input-width * 1.25; - &.deploy-app-file-details { - height: $console-input-height; - display: flex; - align-items: center; - padding-left: 18px; + &.deploy-app-file-details { + height: $console-input-height; + display: flex; + align-items: center; + padding-left: 18px; - > p { - margin: 0; - } + > p { + margin: 0; + } - > i { - margin-right: 6px; + > i { + margin-right: 6px; + } } - } - - img { - height: $github-avatar-height; - width: $github-avatar-width; - } - - .project { - display: flex; - flex-direction: row; - align-items: center; - .project-info { - padding-left: $console-unit-space / 2; + img { + height: $github-avatar-height; + width: $github-avatar-width; } - } - .lastCommit { - display: flex; - flex-direction: row; - align-items: center; - overflow-x: hidden; + .project { + display: flex; + flex-direction: row; + align-items: center; - padding-top: $github-info-commit-spacer; + .project-info { + padding-left: $console-unit-space / 2; + } + } - .lastCommit-info { - padding-left: $console-unit-space / 2; - flex: 1; + .lastCommit { display: flex; - flex-direction: column; + flex-direction: row; + align-items: center; overflow-x: hidden; - > .lastCommit-info-commit-message { + padding-top: $github-info-commit-spacer; + + .lastCommit-info { + padding-left: $console-unit-space / 2; + flex: 1; display: flex; + flex-direction: column; + overflow-x: hidden; + + > .lastCommit-info-commit-message { + display: flex; - > a { - text-overflow: ellipsis; - white-space: nowrap; - overflow-x: hidden; + > a { + text-overflow: ellipsis; + white-space: nowrap; + overflow-x: hidden; + } } - } - .lastCommit-date-committer { - display: flex; + .lastCommit-date-committer { + display: flex; - > .lastCommitter { - flex: 1; + > .lastCommitter { + flex: 1; + } } } } } + } } + } + .deploy-app-git-url-form { + padding-left: $console-unit-space; + } + + .deploy-app-github-form, .deploy-app-git-url-form { + radio-input { + padding-bottom: $console-unit-space / 2; + } } } } diff --git a/components/cf-app-push/frontend/src/view/deploy-app-workflow/deploy-step-source/deploy-step-source.html b/components/cf-app-push/frontend/src/view/deploy-app-workflow/deploy-step-source/deploy-step-source.html index 6f89cf5cb8..02822a6cdf 100644 --- a/components/cf-app-push/frontend/src/view/deploy-app-workflow/deploy-step-source/deploy-step-source.html +++ b/components/cf-app-push/frontend/src/view/deploy-app-workflow/deploy-step-source/deploy-step-source.html @@ -16,7 +16,7 @@
+ ng-show="step.wizard.sourceType === 'git'"> 0; } function filterByText(text) { text = text.toLowerCase(); - model.filteredApplications = _.filter(model.cachedApplications, function (app) { + model.filteredApplications = _.filter(model.filteredApplications, function (app) { if (app.entity.name.toLowerCase().indexOf(text) > -1) { return app; } diff --git a/components/cloud-foundry/frontend/src/model/model-utils.service.js b/components/cloud-foundry/frontend/src/model/model-utils.service.js index b87a3b5108..151a2aded5 100644 --- a/components/cloud-foundry/frontend/src/model/model-utils.service.js +++ b/components/cloud-foundry/frontend/src/model/model-utils.service.js @@ -33,10 +33,10 @@ * @returns {object} the $http service http config */ function makeHttpConfig(cnsiGuid) { - var headers = {'x-cnap-cnsi-list': cnsiGuid}; + var headers = {'x-cap-cnsi-list': cnsiGuid}; // Add passthrough header angular.extend(headers, { - 'x-cnap-passthrough': 'true' + 'x-cap-passthrough': 'true' }); return { headers: headers diff --git a/components/cloud-foundry/frontend/src/model/model.module.js b/components/cloud-foundry/frontend/src/model/model.module.js index e2138480a6..f04529c225 100644 --- a/components/cloud-foundry/frontend/src/model/model.module.js +++ b/components/cloud-foundry/frontend/src/model/model.module.js @@ -10,7 +10,7 @@ /** * @name interceptor - * @description A $http interceptor that adds `x-cnap-cnsi-list` + * @description A $http interceptor that adds `x-cap-cnsi-list` * header to each request if the CNSI guid is present in the * URL (route). * @param {object} $stateParams - the UI router $stateParams service @@ -27,8 +27,8 @@ } var cnsiGuid = $stateParams.cnsiGuid; - if (angular.isUndefined(config.headers['x-cnap-cnsi-list']) && angular.isDefined(cnsiGuid)) { - config.headers['x-cnap-cnsi-list'] = cnsiGuid; + if (angular.isUndefined(config.headers['x-cap-cnsi-list']) && angular.isDefined(cnsiGuid)) { + config.headers['x-cap-cnsi-list'] = cnsiGuid; } return config; } diff --git a/components/cloud-foundry/frontend/src/model/space.model.js b/components/cloud-foundry/frontend/src/model/space.model.js index e265ec5805..a0ca48f0cb 100644 --- a/components/cloud-foundry/frontend/src/model/space.model.js +++ b/components/cloud-foundry/frontend/src/model/space.model.js @@ -438,10 +438,10 @@ * @public */ function updateRoutesCount(cnsiGuid, guid, count) { - var promise = $q.resolve({data: {total_results: count}}); + var promise = $q.resolve({ data: { total_results: count } }); if (!count) { promise = apiManager.retrieve('cloud-foundry.api.Spaces') - .ListAllRoutesForSpace(guid, {'results-per-page': 1}, modelUtils.makeHttpConfig(cnsiGuid)); + .ListAllRoutesForSpace(guid, { 'results-per-page': 1 }, modelUtils.makeHttpConfig(cnsiGuid)); } return promise.then(function (response) { _.set(model, 'spaces.' + cnsiGuid + '.' + guid + '.details.totalRoutes', response.data.total_results); @@ -461,10 +461,10 @@ * @public */ function updateServiceInstanceCount(cnsiGuid, guid, count) { - var promise = $q.resolve({data: {total_results: count}}); + var promise = $q.resolve({ data: { total_results: count } }); if (!count) { promise = apiManager.retrieve('cloud-foundry.api.Spaces') - .ListAllServiceInstancesForSpace(guid, {'results-per-page': 1}, modelUtils.makeHttpConfig(cnsiGuid)); + .ListAllServiceInstancesForSpace(guid, { 'results-per-page': 1 }, modelUtils.makeHttpConfig(cnsiGuid)); } return promise.then(function (response) { _.set(model, 'spaces.' + cnsiGuid + '.' + guid + '.details.totalServiceInstances', response.data.total_results); @@ -484,10 +484,10 @@ * @public */ function updateServiceCount(cnsiGuid, guid, count) { - var promise = $q.resolve({data: {total_results: count}}); + var promise = $q.resolve({ data: { total_results: count } }); if (!count) { promise = apiManager.retrieve('cloud-foundry.api.Spaces') - .ListAllServicesForSpace(guid, {'results-per-page': 1}, modelUtils.makeHttpConfig(cnsiGuid)); + .ListAllServicesForSpace(guid, { 'results-per-page': 1 }, modelUtils.makeHttpConfig(cnsiGuid)); } return promise.then(function (response) { _.set(model, 'spaces.' + cnsiGuid + '.' + guid + '.details.totalServices', response.data.total_results); @@ -732,11 +732,10 @@ var updateData = { allow_ssh: enabled }; - return spaceApi.UpdateSpace(spaceGuid, updateData, null, - modelUtils.makeHttpConfig(cnsiGuid)).then(function (response) { - // Refresh the space itself - return getSpaceDetails(cnsiGuid, response.data); - }); + return spaceApi.UpdateSpace(spaceGuid, updateData, null, modelUtils.makeHttpConfig(cnsiGuid)).then(function (response) { + // Refresh the space itself + return getSpaceDetails(cnsiGuid, response.data); + }); } } diff --git a/components/cloud-foundry/frontend/src/plugin.config.js b/components/cloud-foundry/frontend/src/plugin.config.js index 6ac7a61922..5316883568 100644 --- a/components/cloud-foundry/frontend/src/plugin.config.js +++ b/components/cloud-foundry/frontend/src/plugin.config.js @@ -4,9 +4,9 @@ // register this plugin application to the platform if (env && env.registerApplication) { env.registerApplication( - 'cloudFoundry', // plugin application identity - 'cloud-foundry', // plugin application's root angular module name - 'plugins/cloud-foundry/', // plugin application's base path + 'cloudFoundry', // plugin application identity + 'cloud-foundry', // plugin application's root angular module name + 'plugins/cloud-foundry/', // plugin application's base path 'cf.applications.list.gallery-view' // plugin applications's start state ); } diff --git a/components/cloud-foundry/frontend/src/services/app-wall-actions.service.js b/components/cloud-foundry/frontend/src/services/app-wall-actions.service.js index 3fa057e699..549957b056 100644 --- a/components/cloud-foundry/frontend/src/services/app-wall-actions.service.js +++ b/components/cloud-foundry/frontend/src/services/app-wall-actions.service.js @@ -39,13 +39,22 @@ return false; }, execute: function addApplication() { + var reload = _.get(this.context, 'reload'); frameworkDetailView( { templateUrl: 'plugins/cloud-foundry/view/applications/workflows/add-app-workflow/add-app-dialog.html', dialog: true, - class: 'dialog-form-large' + class: 'dialog-form-large', + hideClose: true } - ); + ).result.then(function (result) { + // Do we need to reload the app collection to show the newly added app? This would be the case where + // the route was not created/binded successfully + if (_.get(result, 'reload') && angular.isFunction(reload)) { + // Note - this won't show the app if the user selected a different cluster/org/guid than that of the filter + reload(); + } + }); } }); diff --git a/components/cloud-foundry/frontend/src/services/cfServiceEndpoint.services.js b/components/cloud-foundry/frontend/src/services/cfServiceEndpoint.services.js index cfd706285c..d9026b6aae 100644 --- a/components/cloud-foundry/frontend/src/services/cfServiceEndpoint.services.js +++ b/components/cloud-foundry/frontend/src/services/cfServiceEndpoint.services.js @@ -67,7 +67,7 @@ function refreshToken(allServiceInstances) { var cfInfoApi = apiManager.retrieve('cloud-foundry.api.Info'); var cfGuids = _.map(_.filter(allServiceInstances, {cnsi_type: service.cnsi_type}) || [], 'guid') || []; - var cfCfg = {headers: {'x-cnap-cnsi-list': cfGuids.join(',')}}; + var cfCfg = {headers: {'x-cap-cnsi-list': cfGuids.join(',')}}; if (cfGuids.length > 0) { return cfInfoApi.GetInfo({}, cfCfg).then(function (response) { return response.data || {}; diff --git a/components/cloud-foundry/frontend/src/view/applications/application/application.module.js b/components/cloud-foundry/frontend/src/view/applications/application/application.module.js index f9f8ef6f14..6549ba2b38 100644 --- a/components/cloud-foundry/frontend/src/view/applications/application/application.module.js +++ b/components/cloud-foundry/frontend/src/view/applications/application/application.module.js @@ -6,6 +6,7 @@ 'cloud-foundry.view.applications.application.summary', 'cloud-foundry.view.applications.application.log-stream', 'cloud-foundry.view.applications.application.services', + 'cloud-foundry.view.applications.application.service-catalogue', 'cloud-foundry.view.applications.application.variables', 'cloud-foundry.view.applications.application.events' ]) @@ -48,8 +49,8 @@ * @property {object} frameworkDialogConfirm - the confirm dialog service */ function ApplicationController(modelManager, appEventService, frameworkDialogConfirm, appUtilsService, - cfAppCliCommands, frameworkDetailView, $stateParams, $scope, $window, $q, $interval, - $translate, $state, cfApplicationTabs) { + cfAppCliCommands, frameworkDetailView, $stateParams, $scope, $window, $q, $interval, + $translate, $state, cfApplicationTabs) { var vm = this; var authModel = modelManager.retrieve('cloud-foundry.model.auth'); diff --git a/components/cloud-foundry/frontend/src/view/applications/application/application.scss b/components/cloud-foundry/frontend/src/view/applications/application/application.scss index 79fb663019..321260c82c 100644 --- a/components/cloud-foundry/frontend/src/view/applications/application/application.scss +++ b/components/cloud-foundry/frontend/src/view/applications/application/application.scss @@ -1,4 +1,5 @@ @import 'services/services', + 'service-catalogue/service-catalogue', 'summary/summary', 'log-stream/cf-log-viewer', 'variables/variables'; @@ -47,7 +48,7 @@ cursor: pointer; } } - + } .application-header { diff --git a/components/cloud-foundry/frontend/src/view/applications/application/log-stream/log-stream.module.js b/components/cloud-foundry/frontend/src/view/applications/application/log-stream/log-stream.module.js index 1562bb6c38..f21a8c5b67 100644 --- a/components/cloud-foundry/frontend/src/view/applications/application/log-stream/log-stream.module.js +++ b/components/cloud-foundry/frontend/src/view/applications/application/log-stream/log-stream.module.js @@ -2,11 +2,9 @@ 'use strict'; angular - .module('cloud-foundry.view.applications.application.log-stream', - [ + .module('cloud-foundry.view.applications.application.log-stream', [ 'cloud-foundry.view.applications.application.log-stream.cfLogViewer', - 'cloud-foundry.view.applications.application.log-stream.cfLogViewerReize' - ]) + 'cloud-foundry.view.applications.application.log-stream.cfLogViewerReize']) .config(registerRoute) .run(registerAppTab); @@ -25,7 +23,7 @@ hide: false, uiSref: 'cf.applications.application.log-stream', uiSrefParam: function () { - return {guid: $stateParams.guid}; + return { guid: $stateParams.guid }; }, label: 'app.app-info.app-tabs.log-stream.label' }); diff --git a/components/cloud-foundry/frontend/src/view/applications/application/service-catalogue/service-catalogue-card/service-catalogue-card.directive.js b/components/cloud-foundry/frontend/src/view/applications/application/service-catalogue/service-catalogue-card/service-catalogue-card.directive.js new file mode 100644 index 0000000000..dd9e78a071 --- /dev/null +++ b/components/cloud-foundry/frontend/src/view/applications/application/service-catalogue/service-catalogue-card/service-catalogue-card.directive.js @@ -0,0 +1,156 @@ +(function () { + 'use strict'; + + angular + .module('cloud-foundry.view.applications.application.service-catalogue') + .directive('serviceCatalogueCard', serviceCatalogueCard); + + /** + * @memberof cloud-foundry.view.applications.application.services + * @name serviceCatalogueCard + * @description The service card directive + * @returns {object} The service card directive definition object + */ + function serviceCatalogueCard() { + return { + bindToController: { + app: '=', + cnsiGuid: '=', + service: '=', + addOnly: '=?' + }, + controller: ServiceCatalogueCardController, + controllerAs: 'serviceCatalogueCardCtrl', + restrict: 'E', + scope: {}, + templateUrl: 'plugins/cloud-foundry/view/applications/application/service-catalogue/service-catalogue-card/service-catalogue-card.html' + }; + } + + /** + * @memberof cloud-foundry.view.applications.application.services.serviceCatalogueCard + * @name serviceCatalogueCardController + * @description Controller for service card directive + * @constructor + * @param {object} $scope - the Angular $scope service + * @param {app.model.modelManager} modelManager - the application model manager + * @param {app.utils.appEventService} appEventService - the event management service + * @property {app.utils.appEventService} appEventService - the event management service + * @property {object} cfServiceInstanceService - the service instance service + * @property {cloud-foundry.model.service-binding} bindingModel - the Cloud Foundry service binding model + * @property {boolean} allowAddOnly - allow adding services only (no manage or detach) + * @property {array} serviceBindings - the service instances bound to specified app + * @property {number} numAttached - the number of service instances bound to specified app + * @property {array} actions - the actions that can be performed from vm.service card + */ + function ServiceCatalogueCardController($scope, modelManager, appEventService) { + var vm = this; + + var bindingModel = modelManager.retrieve('cloud-foundry.model.service-binding'); + var authModel = modelManager.retrieve('cloud-foundry.model.auth'); + + vm.allowAddOnly = angular.isDefined(vm.addOnly) ? vm.addOnly : false; + vm.numAttached = 0; + vm.serviceBindings = []; + + $scope.$watch(function () { + return vm.app.summary.services; + }, function () { + init(); + }); + + $scope.$watch(function () { + return vm.service; + }, function () { + vm.canBind = vm.service._bindTarget === 'APP'; + }); + + vm.addService = addService; + vm.hideServiceActions = hideServiceActions; + vm.getServiceInstanceGuids = getServiceInstanceGuids; + vm.getServiceBindings = getServiceBindings; + vm.init = init; + + /** + * @function init + * @memberof cloud-foundry.view.applications.application.services.serviceCatalogueCard.serviceCatalogueCardController + * @description Fetch service bindings for this app and update content + * @returns {undefined} + */ + function init() { + var serviceInstances = vm.getServiceInstanceGuids(); + if (serviceInstances.length > 0) { + return vm.getServiceBindings(serviceInstances); + } else { + vm.serviceBindings = []; + } + } + + /** + * @function getServiceInstanceGuids + * @memberof cloud-foundry.view.applications.application.services.serviceCatalogueCard.serviceCatalogueCardController + * @description Get service instances for app + * @returns {array} A list of service instance GUIDs + */ + function getServiceInstanceGuids() { + var serviceInstances = _.chain(vm.app.summary.services) + .filter(function (o) { + return angular.isDefined(o.service_plan) && + o.service_plan.service.guid === vm.service.metadata.guid; + }) + .map('guid') + .value(); + return serviceInstances; + } + + /** + * @function getServiceBindings + * @memberof cloud-foundry.view.applications.application.services.serviceCatalogueCard.serviceCatalogueCardController + * @description Get service bindings for specified service instances + * @param {array} serviceInstanceGuids A list of service instance GUIDs + * @returns {promise} A promise object + */ + function getServiceBindings(serviceInstanceGuids) { + var q = 'service_instance_guid IN ' + serviceInstanceGuids.join(','); + var options = {q: q, 'inline-relations-depth': 1, 'include-relations': 'service_instance'}; + return bindingModel.listAllServiceBindings(vm.cnsiGuid, options) + .then(function (bindings) { + var appGuid = vm.app.summary.guid; + var appBindings = _.filter(bindings, function (o) { + return o.entity.app_guid === appGuid; + }); + vm.serviceBindings = appBindings; + }); + } + + /** + * @function addService + * @memberof cloud-foundry.view.applications.application.services.serviceCatalogueCard.serviceCatalogueCardController + * @description Show the add service detail view + * @returns {undefined} + */ + function addService() { + var config = { + app: vm.app, + cnsiGuid: vm.cnsiGuid, + confirm: !vm.allowAddOnly, + service: vm.service + }; + + appEventService.$emit('cf.events.START_ADD_SERVICE_WORKFLOW', config); + } + + /** + * @function hideServiceActions + * @memberof cloud-foundry.view.applications.application.services.serviceCatalogueCard.serviceCatalogueCardController + * @description Update service actions visibility + * @returns {*} + */ + function hideServiceActions() { + return !authModel.isAllowed(vm.cnsiGuid, + authModel.resources.managed_service_instance, + authModel.actions.create, vm.app.summary.space_guid); + } + } + +})(); diff --git a/components/cloud-foundry/frontend/src/view/applications/application/service-catalogue/service-catalogue-card/service-catalogue-card.html b/components/cloud-foundry/frontend/src/view/applications/application/service-catalogue/service-catalogue-card/service-catalogue-card.html new file mode 100644 index 0000000000..3d233557f3 --- /dev/null +++ b/components/cloud-foundry/frontend/src/view/applications/application/service-catalogue/service-catalogue-card/service-catalogue-card.html @@ -0,0 +1,50 @@ +
+
+ +
+
+
+
+

{{ ::serviceCatalogueCardCtrl.service.entity.label }}

+
+ +
+
+

{{ ::serviceCatalogueCardCtrl.service.entity.extra.LongDescription || serviceCatalogueCardCtrl.service.entity.description }}

+
+
+
+
+ {{ serviceCatalogueCardCtrl.serviceBindings.length }} app.app-info.app-tabs.services.card.attached +
+
+
+
+ app.app-info.app-tabs.service-catalogue.card.cannot-bind +
+
+ {{ serviceCatalogueCardCtrl.serviceBindings.length }} app.app-info.app-tabs.service-catalogue.card.attached +
+ + +
+
+
+
diff --git a/components/cloud-foundry/frontend/src/view/applications/application/service-catalogue/service-catalogue-card/service-catalogue-card.scss b/components/cloud-foundry/frontend/src/view/applications/application/service-catalogue/service-catalogue-card/service-catalogue-card.scss new file mode 100644 index 0000000000..c91ccfc02a --- /dev/null +++ b/components/cloud-foundry/frontend/src/view/applications/application/service-catalogue/service-catalogue-card/service-catalogue-card.scss @@ -0,0 +1,147 @@ +service-catalogue-card { + display: flex; + width: 100%; + + .service-card { + display: flex; + width: 100%; + min-height: $console-unit-space * 5; + + .service-icon { + display: block; + padding: 0; + padding-right: $console-unit-space; + + img { + max-height: $console-unit-space * 4; + max-width: $console-unit-space * 4; + } + } + + .service-block { + display: flex; + flex-grow: 1; + flex-direction: column; + + .service-title { + display: flex; + .service-text { + display: flex; + flex: 1 1 0px; + + h4 { + margin-top: 0; + } + } + + .service-metadata { + display: flex; + padding: 0 $console-unit-space 0 $console-unit-space; + flex: none; + width: 35%; + + dl { + margin: 0; + dt { + display: inline-block; + text-transform: uppercase; + margin-right: $console-unit-space; + + &:not(:first-of-type) { + margin-top: $console-unit-space / 2; + } + } + dd { + display: inline-block; + } + } + } + + @media (max-width: 640px) { + .service-metadata { + display: none; + } + } + + .service-actions-menu { + text-align: right; + width: $console-unit-space * 2; + flex: none; + + .actions-menu { + &.open { + .actions-menu-icon { + color: $brand-primary; + } + + .actions-menu-list { + margin-top: -$console-unit-space / 2; + } + } + + .actions-menu-icon { + font-weight: $console-font-weight-semibold; + line-height: $console-unit-space; + font-size: 1.75em; + } + } + } + + } + + .service-description { + flex-grow: 1; + padding-right: $console-unit-space * 2; + } + + .service-action-block { + display: flex; + + .service-attached-msg { + flex: 1 1 0px; + margin: auto 0; + } + + .service-attached:before { + @extend .material-icons; + content: 'attachment'; + margin-right: $console-unit-space / 4; + vertical-align: bottom; + } + + .service-added:after { + @extend .material-icons; + content: 'attachment'; + margin-left: $console-unit-space / 4; + } + } + + .service-actions { + text-align: right; + + .btn-link { + margin-top: auto; + padding-right: 0; + display: flex; + align-items: center; + + &:hover, + &:focus, + &:active { + text-decoration: none; + outline: 0; + + .add-icon { + border-color: $link-hover-color; + } + } + + .add-icon { + font-size: 20px; + margin-left: $console-unit-space / 4; + } + } + } + } + } +} diff --git a/components/cloud-foundry/frontend/src/view/applications/application/service-catalogue/service-catalogue.html b/components/cloud-foundry/frontend/src/view/applications/application/service-catalogue/service-catalogue.html new file mode 100644 index 0000000000..3ebf560ccf --- /dev/null +++ b/components/cloud-foundry/frontend/src/view/applications/application/service-catalogue/service-catalogue.html @@ -0,0 +1,36 @@ +
+

+ +

app.app-info.app-tabs.service-catalogue.messages.no-services

+

+
+
+ +
+ +
+
+
+
+ + + +
+
+
+ + + + +
+
+
+
+
+ +
+
+ +
+
\ No newline at end of file diff --git a/components/cloud-foundry/frontend/src/view/applications/application/service-catalogue/service-catalogue.module.js b/components/cloud-foundry/frontend/src/view/applications/application/service-catalogue/service-catalogue.module.js new file mode 100644 index 0000000000..c0e0432bca --- /dev/null +++ b/components/cloud-foundry/frontend/src/view/applications/application/service-catalogue/service-catalogue.module.js @@ -0,0 +1,135 @@ +(function () { + 'use strict'; + + angular + .module('cloud-foundry.view.applications.application.service-catalogue', []) + .config(registerRoute) + .run(registerAppTab); + + function registerRoute($stateProvider) { + $stateProvider.state('cf.applications.application.service-catalogue', { + url: '/service-catalogue', + templateUrl: 'plugins/cloud-foundry/view/applications/application/service-catalogue/service-catalogue.html', + controller: ApplicationServiceCatalogueController, + controllerAs: 'applicationServiceCatalogueCtrl' + }); + } + + function registerAppTab($stateParams, cfApplicationTabs) { + cfApplicationTabs.tabs.push({ + position: 10, + hide: false, + uiSref: 'cf.applications.application.service-catalogue', + uiSrefParam: function () { + return {guid: $stateParams.guid}; + }, + label: 'app.app-info.app-tabs.service-catalogue.label' + }); + } + + /** + * @name ApplicationServiceCatalogueController + * @constructor + * @param {object} $scope - the Angular $scope service + * @param {app.model.modelManager} modelManager - the model management service + * @param {object} $stateParams - the UI router $stateParams service + * @property {cloud-foundry.model.space} model - the Cloud Foundry space model + * @property {cloud-foundry.model.application} model - the Cloud Foundry application model + * @property {string} id - the application GUID + * @property {string} cnsiGuid - the CNSI GUID + * @property {array} services - the services for the space + * @property {array} serviceCategories - the service categories to filter by + * @property {string} searchCategory - the category to filter by + * @property {object} search - the search object for filtering + * @property {object} category - the search category object for filtering + */ + function ApplicationServiceCatalogueController($scope, modelManager, $stateParams) { + var that = this; + this.model = modelManager.retrieve('cloud-foundry.model.space'); + this.appModel = modelManager.retrieve('cloud-foundry.model.application'); + this.id = $stateParams.guid; + this.cnsiGuid = $stateParams.cnsiGuid; + this.services = []; + this.serviceCategories = [ + { label: 'app.app-info.app-tabs.service-catalogue.categories.attached', value: 'attached' }, + { label: 'app.app-info.app-tabs.service-catalogue.categories.all', value: 'all' } + ]; + this.searchCategory = 'all'; + this.search = {}; + this.category = { + entity: { + extra: undefined + } + }; + this.ready = false; + + $scope.$watch(function () { + return that.appModel.application.summary.guid; + }, function () { + var summary = that.appModel.application.summary; + var spaceGuid = summary.space_guid; + if (angular.isDefined(spaceGuid)) { + that.model.listAllServicesForSpace(that.cnsiGuid, spaceGuid) + .then(function (services) { + // retrieve categories and attachment data for service filtering + var categories = []; + var attachedServices = _.chain(summary.services) + .filter(function (o) { return angular.isDefined(o.service_plan); }) + .map(function (o) { return o.service_plan.service.guid; }) + .value(); + angular.forEach(services, function (service) { + if (attachedServices.length > 0) { + if (_.includes(attachedServices, service.metadata.guid)) { + service.attached = true; + } + } + + // Parse service entity extra data JSON string + if (!_.isNil(service.entity.extra) && angular.isString(service.entity.extra)) { + service.entity.extra = angular.fromJson(service.entity.extra); + } + + // a service can belong to >1 category, so allow filtering by any of them + if (angular.isObject(service.entity.extra) && angular.isDefined(service.entity.extra.Categories)) { + var _categories = service.entity.extra.Categories; + if (angular.isString(_categories)) { + _categories = [_categories]; + } + var serviceCategories = _.map(_categories, function (o) { + return { + label: o, + value: { Categories: o }, + lower: o.toLowerCase() + }; + }); + categories = _.unionBy(categories, serviceCategories, 'lower'); + } + }); + + that.services.length = 0; + [].push.apply(that.services, services); + + categories = _.sortBy(categories, 'lower'); + that.serviceCategories.length = 2; + [].push.apply(that.serviceCategories, categories); + }) + .finally(function () { + that.ready = true; + }); + } + }); + + $scope.$watch(function () { + return that.searchCategory; + }, function (newSearchCategory) { + if (newSearchCategory === 'attached') { + that.category.entity.extra = undefined; + that.category.attached = true; + } else { + delete that.category.attached; + that.category.entity.extra = newSearchCategory === 'all' ? undefined : newSearchCategory; + } + }); + } + +})(); diff --git a/components/cloud-foundry/frontend/src/view/applications/application/service-catalogue/service-catalogue.scss b/components/cloud-foundry/frontend/src/view/applications/application/service-catalogue/service-catalogue.scss new file mode 100644 index 0000000000..7733a4f265 --- /dev/null +++ b/components/cloud-foundry/frontend/src/view/applications/application/service-catalogue/service-catalogue.scss @@ -0,0 +1,12 @@ +@import "./service-catalogue-card/service-catalogue-card"; + +.service-filter-panel { + .filter-form-group { + flex-grow: 1; + } +} + +.service-panel { + display: flex; + align-items: stretch; +} diff --git a/components/cloud-foundry/frontend/src/view/applications/application/services/manage-services/manage-services.directive.js b/components/cloud-foundry/frontend/src/view/applications/application/services/manage-services/manage-services.directive.js deleted file mode 100644 index 9ce35cd326..0000000000 --- a/components/cloud-foundry/frontend/src/view/applications/application/services/manage-services/manage-services.directive.js +++ /dev/null @@ -1,166 +0,0 @@ -(function () { - 'use strict'; - - angular - .module('cloud-foundry.view.applications') - .directive('manageServices', manageServices); - - /** - * @memberof cloud-foundry.view.applications - * @name manageServices - * @description An manage services detail view - * @returns {object} The manage-services directive definition object - */ - function manageServices() { - return { - controller: ManageServicesController, - controllerAs: 'manageServicesCtrl', - restrict: 'E' - }; - } - - /** - * @memberof cloud-foundry.view.applications - * @name ManageServicesController - * @constructor - * @param {object} $q - the Angular $q service - * @param {object} $scope - the Angular $scope service - * @param {app.model.modelManager} modelManager - the model management service - * @param {app.utils.appEventService} appEventService - the event management service - * @param {app.framework.widgets.frameworkDetailView} frameworkDetailView - the detail view service - * @param {object} cfServiceInstanceService - the service instance service - * @property {object} $q - the Angular $q service - * @property {frameworkDetailView} frameworkDetailView - the detail view service - * @property {object} cfServiceInstanceService - the service instance service - * @property {cloud-foundry.model.application} appModel - the CF application model - * @property {object} modal - the detail view modal instance - * @property {array} serviceInstances - service instances associated with this service - * @property {object} serviceBindings - service bindings associated with this app - */ - function ManageServicesController($q, $scope, modelManager, appEventService, frameworkDetailView, cfServiceInstanceService) { - var vm = this; - - var appModel = modelManager.retrieve('cloud-foundry.model.application'); - var modal = null; - - vm.serviceInstances = []; - vm.serviceBindings = {}; - - vm.detach = detach; - vm.viewEnvVariables = viewEnvVariables; - vm.reset = reset; - vm.startManageServices = startManageServices; - vm.getServiceBindings = getServiceBindings; - - var manageServicesEvent = appEventService.$on('cf.events.START_MANAGE_SERVICES', function (event, config) { - $q.when(vm.reset(config)).then(function () { - modal = vm.startManageServices(); - }); - }); - $scope.$on('$destroy', manageServicesEvent); - - /** - * @function reset - * @memberof cloud-foundry.view.applications.ManageServicesController - * @description Reset the view to an initial state - * @param {object} config - data containing app, service, etc. - * @returns {promise} A promise object - */ - function reset(config) { - vm.data = { - app: config.app, - service: config.service, - cnsiGuid: config.cnsiGuid - }; - vm.serviceInstances.length = 0; - vm.serviceBindings = {}; - - var serviceInstances = _.filter(vm.data.app.summary.services, function (o) { - return angular.isDefined(o.service_plan) && - o.service_plan.service.guid === vm.data.service.metadata.guid; - }); - if (serviceInstances.length > 0) { - [].push.apply(vm.serviceInstances, serviceInstances); - - var guids = _.map(vm.serviceInstances, 'guid'); - return vm.getServiceBindings(guids); - } - } - - /** - * @function getServiceBindings - * @memberof cloud-foundry.view.applications.ManageServicesController - * @description Retrieve service bindings for service instances - * @param {array} serviceInstanceGuids - a list of service instance GUIDs - * @returns {promise} A promise object - */ - function getServiceBindings(serviceInstanceGuids) { - - var q = 'service_instance_guid IN ' + serviceInstanceGuids.join(','); - return appModel.listServiceBindings(vm.data.cnsiGuid, vm.data.app.summary.guid, {q: q}) - .then(function (bindings) { - vm.serviceBindings = _.keyBy(bindings, function (o) { - return o.entity.service_instance_guid; - }); - }); - } - - /** - * @function detach - * @memberof cloud-foundry.view.applications.ManageServicesController - * @description Detach service instance - * @param {object} instance - the service instance to detach - * @returns {promise} A promise object - */ - function detach(instance) { - - var binding = vm.serviceBindings[instance.guid]; - return cfServiceInstanceService.unbindServiceFromApp( - vm.data.cnsiGuid, - vm.data.app.summary.guid, - binding.metadata.guid, - instance.name, - function closeOnEmpty() { - _.pull(vm.serviceInstances, instance); - if (vm.serviceInstances.length === 0) { - modal.dismiss('close'); - } - } - ); - } - - /** - * @function viewEnvVariables - * @memberof cloud-foundry.view.applications.ManageServicesController - * @description View environmental variables of service instance - * @param {object} instance - the service instance to view - * @returns {promise} A promise object - */ - function viewEnvVariables(instance) { - return cfServiceInstanceService.viewEnvVariables( - vm.data.cnsiGuid, - vm.data.app.summary, - vm.data.service.entity.label, - instance - ); - } - - /** - * @function startManageService - * @memberof cloud-foundry.view.applications.ManageServicesController - * @description Show the manage services detail view - * @returns {promise} A promise object - */ - function startManageServices() { - var config = { - templateUrl: 'plugins/cloud-foundry/view/applications/application/services/manage-services/manage-services.html', - title: 'app.app-info.app-tabs.services.manage.title', - dialog: true, - class: 'dialog-form-larger' - }; - - return frameworkDetailView(config, vm); - } - } - -})(); diff --git a/components/cloud-foundry/frontend/src/view/applications/application/services/manage-services/manage-services.html b/components/cloud-foundry/frontend/src/view/applications/application/services/manage-services/manage-services.html deleted file mode 100644 index fe26f56227..0000000000 --- a/components/cloud-foundry/frontend/src/view/applications/application/services/manage-services/manage-services.html +++ /dev/null @@ -1,38 +0,0 @@ -
-
-
- -
-
-

{{ detailViewCtrl.context.data.service.entity.label }}

-

{{ detailViewCtrl.context.data.service.entity.extra.LongDescription || detailViewCtrl.context.data.service.entity.description }}

-
-
- - - - - - - - - - - - - - - - - -
app.app-info.app-tabs.services.manage.name-labelapp.app-info.app-tabs.services.manage.plan-labelapp.app-info.app-tabs.services.manage.env-vars-label
{{ instance.name }}{{ instance.service_plan.name }} - - - -
-
diff --git a/components/cloud-foundry/frontend/src/view/applications/application/services/manage-services/manage-services.scss b/components/cloud-foundry/frontend/src/view/applications/application/services/manage-services/manage-services.scss deleted file mode 100644 index fd9202241c..0000000000 --- a/components/cloud-foundry/frontend/src/view/applications/application/services/manage-services/manage-services.scss +++ /dev/null @@ -1,41 +0,0 @@ -.manage-instances { - padding: 0 $console-unit-space; - // env vars window shown over needs some space - min-height: 400px; - - .service-description { - display: flex; - padding: 0; - - .service-icon { - padding: 0 $console-unit-space/2 $console-unit-space 0; - - img { - max-height: $console-unit-space * 4; - max-width: $console-unit-space * 4; - } - } - } - - .btn-link { - &:hover, - &:focus, - &:active { - text-decoration: none; - outline: 0; - } - } - - .min-col-header { - width: 1px; - white-space: nowrap; - } -} - -.env-variables { - margin: $console-unit-space; - - pre { - white-space: pre-wrap; - } -} diff --git a/components/cloud-foundry/frontend/src/view/applications/application/services/service-card/service-card.directive.js b/components/cloud-foundry/frontend/src/view/applications/application/services/service-card/service-card.directive.js index d128d62970..511eb7379e 100644 --- a/components/cloud-foundry/frontend/src/view/applications/application/services/service-card/service-card.directive.js +++ b/components/cloud-foundry/frontend/src/view/applications/application/services/service-card/service-card.directive.js @@ -33,196 +33,84 @@ * @description Controller for service card directive * @constructor * @param {object} $scope - the Angular $scope service - * @param {app.model.modelManager} modelManager - the application model manager - * @param {app.utils.appEventService} appEventService - the event management service + * @param {object} $q - the Angular $q service * @param {object} cfServiceInstanceService - the service instance service - * @property {app.utils.appEventService} appEventService - the event management service - * @property {object} cfServiceInstanceService - the service instance service - * @property {cloud-foundry.model.service-binding} bindingModel - the Cloud Foundry service binding model - * @property {boolean} allowAddOnly - allow adding services only (no manage or detach) - * @property {array} serviceBindings - the service instances bound to specified app - * @property {number} numAttached - the number of service instances bound to specified app * @property {array} actions - the actions that can be performed from vm.service card */ - function ServiceCardController($scope, modelManager, appEventService, cfServiceInstanceService) { + function ServiceCardController( + $scope, + $q, + cfServiceInstanceService + ) { var vm = this; - var bindingModel = modelManager.retrieve('cloud-foundry.model.service-binding'); - var authModel = modelManager.retrieve('cloud-foundry.model.auth'); + vm.detach = detach; + vm.viewEnvVariables = viewEnvVariables; - vm.allowAddOnly = angular.isDefined(vm.addOnly) ? vm.addOnly : false; - vm.numAttached = 0; vm.actions = [ - { - name: 'app.app-info.app-tabs.services.card.actions.add', - execute: function () { - addService(); - } - }, { name: 'app.app-info.app-tabs.services.card.actions.detach', execute: function () { - detach(); - } - }, - { - name: 'app.app-info.app-tabs.services.card.actions.manage', - execute: function () { - manageInstances(); + detach(vm.service).then(function (result) { + vm.service = result; + }); } } ]; - vm.serviceBindings = []; - - $scope.$watch(function () { - return vm.app.summary.services; - }, function () { - init(); - }); $scope.$watch(function () { return vm.service; }, function () { - vm.canBind = vm.service._bindTarget === 'APP'; - }); - - vm.addService = addService; - vm.detach = detach; - vm.manageInstances = manageInstances; - vm.hideServiceActions = hideServiceActions; - vm.getServiceInstanceGuids = getServiceInstanceGuids; - vm.getServiceBindings = getServiceBindings; - vm.updateActions = updateActions; - vm.init = init; - - /** - * @function init - * @memberof cloud-foundry.view.applications.application.services.serviceCard.ServiceCardController - * @description Fetch service bindings for this app and update content - * @returns {undefined} - */ - function init() { - var serviceInstances = vm.getServiceInstanceGuids(); - if (serviceInstances.length > 0) { - return vm.getServiceBindings(serviceInstances); - } else { - vm.serviceBindings = []; - vm.updateActions(); - } - } - - /** - * @function getServiceInstanceGuids - * @memberof cloud-foundry.view.applications.application.services.serviceCard.ServiceCardController - * @description Get service instances for app - * @returns {array} A list of service instance GUIDs - */ - function getServiceInstanceGuids() { - var serviceInstances = _.chain(vm.app.summary.services) - .filter(function (o) { - return angular.isDefined(o.service_plan) && - o.service_plan.service.guid === vm.service.metadata.guid; - }) - .map('guid') - .value(); - return serviceInstances; - } - - /** - * @function getServiceBindings - * @memberof cloud-foundry.view.applications.application.services.serviceCard.ServiceCardController - * @description Get service bindings for specified service instances - * @param {array} serviceInstanceGuids A list of service instance GUIDs - * @returns {promise} A promise object - */ - function getServiceBindings(serviceInstanceGuids) { - var q = 'service_instance_guid IN ' + serviceInstanceGuids.join(','); - var options = {q: q, 'inline-relations-depth': 1, 'include-relations': 'service_instance'}; - return bindingModel.listAllServiceBindings(vm.cnsiGuid, options) - .then(function (bindings) { - var appGuid = vm.app.summary.guid; - var appBindings = _.filter(bindings, function (o) { - return o.entity.app_guid === appGuid; - }); - vm.serviceBindings = appBindings; - vm.updateActions(); - }); - } - - /** - * @function addService - * @memberof cloud-foundry.view.applications.application.services.serviceCard.ServiceCardController - * @description Show the add service detail view - * @returns {undefined} - */ - function addService() { - var config = { - app: vm.app, - cnsiGuid: vm.cnsiGuid, - confirm: !vm.allowAddOnly, - service: vm.service + vm.cardData = { + title: vm.service.entity.name }; - - appEventService.$emit('cf.events.START_ADD_SERVICE_WORKFLOW', config); - } + }); /** * @function detach * @memberof cloud-foundry.view.applications.application.services.serviceCard.ServiceCardController + * @param {object} serviceInstance The service instance to be detached from the application * @description Detach service instance from app * @returns {undefined} */ - function detach() { - if (vm.serviceBindings.length === 1) { - var serviceBinding = vm.serviceBindings[0]; - return cfServiceInstanceService.unbindServiceFromApp( + function detach(serviceInstance) { + var serviceBinding = serviceInstance.entity.service_bindings + ? _.find(serviceInstance.entity.service_bindings, function (binding) { + return vm.app.summary.guid === binding.entity.app_guid; + }) : null; + if (serviceBinding) { + var deferred = $q.defer(); + cfServiceInstanceService.unbindServiceFromApp( vm.cnsiGuid, vm.app.summary.guid, serviceBinding.metadata.guid, - serviceBinding.entity.service_instance.entity.name + serviceInstance.entity.name, + function () { + serviceInstance.entity.service_bindings = []; + return deferred.resolve(serviceInstance); + } ); + return deferred.promise; } + return $q.resolve(serviceInstance); } /** - * @function manageInstances - * @memberof cloud-foundry.view.applications.application.services.serviceCard.ServiceCardController - * @description Show the manage services detail view - * @returns {void} - */ - function manageInstances() { - var config = { - app: vm.app, - cnsiGuid: vm.cnsiGuid, - service: vm.service - }; - - appEventService.$emit('cf.events.START_MANAGE_SERVICES', config); - } - - /** - * @function updateActions - * @memberof cloud-foundry.view.applications.application.services.serviceCard.ServiceCardController - * @description Update service actions visibility - * @returns {void} + * @function viewEnvVariables + * @memberof cloud-foundry.view.applications.ManageServicesController + * @description View environmental variables of service instance + * @param {object} instance - the service instance to view + * @returns {promise} A promise object */ - function updateActions() { - vm.numAttached = vm.serviceBindings.length; - vm.actions[1].hidden = vm.numAttached !== 1; - vm.actions[2].hidden = vm.numAttached === 0; + function viewEnvVariables(instance) { + return cfServiceInstanceService.viewEnvVariables( + vm.cnsiGuid, + vm.app.summary, + instance.entity.service_plan.entity.service.entity.label, + instance.entity + ); } - /** - * @function hideServiceActions - * @memberof cloud-foundry.view.applications.application.services.serviceCard.ServiceCardController - * @description Update service actions visibility - * @returns {*} - */ - function hideServiceActions() { - return !authModel.isAllowed(vm.cnsiGuid, - authModel.resources.managed_service_instance, - authModel.actions.create, vm.app.summary.space_guid); - } } })(); diff --git a/components/cloud-foundry/frontend/src/view/applications/application/services/service-card/service-card.html b/components/cloud-foundry/frontend/src/view/applications/application/services/service-card/service-card.html index b053d61314..1b4e667f40 100644 --- a/components/cloud-foundry/frontend/src/view/applications/application/services/service-card/service-card.html +++ b/components/cloud-foundry/frontend/src/view/applications/application/services/service-card/service-card.html @@ -1,61 +1,54 @@ -
-
- -
-
-
-
-

{{ ::serviceCardCtrl.service.entity.label }}

-
- -
- - -
-
-
-

{{ ::serviceCardCtrl.service.entity.extra.LongDescription || serviceCardCtrl.service.entity.description }}

-
-
-
-
- {{ serviceCardCtrl.numAttached }} app.app-info.app-tabs.services.card.attached + +
+
+
+
+
+ app.app-info.app-tabs.services.labels.type +
+
+ {{ ::serviceCardCtrl.service.entity.service_plan.entity.service.entity.label }} +
-
-
-
- app.app-info.app-tabs.services.card.cannot-bind + +
+
+ app.app-info.app-tabs.services.labels.description +
+
+ {{ ::serviceCardCtrl.service.entity.service_plan.entity.service.entity.description }} +
+
+
+
+ app.app-info.app-tabs.services.labels.created-date +
+
+ {{ ::serviceCardCtrl.service.metadata.created_at | momentDateFormat }} +
-
- {{ serviceCardCtrl.numAttached }} app.app-info.app-tabs.services.card.attached +
+
+ app.app-info.app-tabs.services.labels.tags +
+
+
+
{{ tag }}
+
+ - +
- - -
+
+ +
+
+
-
+ + \ No newline at end of file diff --git a/components/cloud-foundry/frontend/src/view/applications/application/services/service-card/service-card.scss b/components/cloud-foundry/frontend/src/view/applications/application/services/service-card/service-card.scss index d109b73de7..90c0a1c159 100644 --- a/components/cloud-foundry/frontend/src/view/applications/application/services/service-card/service-card.scss +++ b/components/cloud-foundry/frontend/src/view/applications/application/services/service-card/service-card.scss @@ -1,147 +1,73 @@ -service-card { - display: flex; - width: 100%; - .service-card { +.service-catalogue-card { + display: flex; + flex-direction: row; + &__meta { display: flex; - width: 100%; - min-height: $console-unit-space * 5; - - .service-icon { - display: block; - padding: 0; - padding-right: $console-unit-space; - - img { - max-height: $console-unit-space * 4; - max-width: $console-unit-space * 4; - } - } + flex: 1; + flex-direction: column; + margin-right: 25px; + } + &__icon { + flex: none; + } + &__bold-text { - .service-block { - display: flex; - flex-grow: 1; + } + &__meta-split { + display: flex; + justify-content: space-between; + margin-bottom: 10px; + @media (max-width: 900px) { flex-direction: column; - - .service-title { - display: flex; - .service-text { - display: flex; - flex: 1 1 0px; - - h4 { - margin-top: 0; - } - } - - .service-metadata { - display: flex; - padding: 0 $console-unit-space 0 $console-unit-space; - flex: none; - width: 35%; - - dl { - margin: 0; - dt { - display: inline-block; - text-transform: uppercase; - margin-right: $console-unit-space; - - &:not(:first-of-type) { - margin-top: $console-unit-space / 2; - } - } - dd { - display: inline-block; - } - } - } - - @media (max-width: 640px) { - .service-metadata { - display: none; - } - } - - .service-actions-menu { - text-align: right; - width: $console-unit-space * 2; - flex: none; - - .actions-menu { - &.open { - .actions-menu-icon { - color: $brand-primary; - } - - .actions-menu-list { - margin-top: -$console-unit-space / 2; - } - } - - .actions-menu-icon { - font-weight: $console-font-weight-semibold; - line-height: $console-unit-space; - font-size: 1.75em; - } - } - } - - } - - .service-description { - flex-grow: 1; - padding-right: $console-unit-space * 2; - } - - .service-action-block { - display: flex; - - .service-attached-msg { - flex: 1 1 0px; - margin: auto 0; - } - - .service-attached:before { - @extend .material-icons; - content: 'attachment'; - margin-right: $console-unit-space / 4; - vertical-align: bottom; - } - - .service-added:after { - @extend .material-icons; - content: 'attachment'; - margin-left: $console-unit-space / 4; - } - } - - .service-actions { - text-align: right; - - .btn-link { - margin-top: auto; - padding-right: 0; - display: flex; - align-items: center; - - &:hover, - &:focus, - &:active { - text-decoration: none; - outline: 0; - - .add-icon { - border-color: $link-hover-color; - } - } - - .add-icon { - font-size: 20px; - margin-left: $console-unit-space / 4; - } - } - } } } + &__meta-left { + text-transform: uppercase; + font-weight: bold; + } + &__meta-right { + @media (min-width: 901px) { + text-align: right; + margin-left: 10px; + margin-top: 0; + } + margin-top: 5px; + } + &__outer { + display: flex; + flex-direction: column; + height: 100%; + justify-content: space-between; + padding-bottom: 5px; + } + &__env { + padding-top: 10px; + } + &__tag { + border: 1px solid $input-border; + padding: 2px 6px; + border-radius: 4px; + margin-bottom: 2px; + display: inline-block; + } +} +.services-gallery-card { + height: 100%; + display: block; + position: relative; + padding-bottom: 20px; + .panel { + display: flex; + flex-direction: column; + height: 100%; + margin-bottom: 0; + &-body { + flex: 1; + } + } + &__container.cluster-gallery-card-container { + width: auto; + margin: 0 -10px; + } } diff --git a/components/cloud-foundry/frontend/src/view/applications/application/services/services.html b/components/cloud-foundry/frontend/src/view/applications/application/services/services.html index f9b8f5f94f..7648cf4139 100644 --- a/components/cloud-foundry/frontend/src/view/applications/application/services/services.html +++ b/components/cloud-foundry/frontend/src/view/applications/application/services/services.html @@ -1,23 +1,9 @@ -
-

- -

There are no services available for this endpoint. Please contact your Administrator to add services so you can bind them to your applications.

-

-
-
- -
- -
+
- - app.app-info.app-tabs.services.types.show +
@@ -26,17 +12,37 @@

There are no services available for this endpoint. Please contact - +

- -
-
- -
+
app.app-info.app-tabs.services.messages.currently-viewing-1 app.app-info.app-tabs.services.messages.click-here app.app-info.app-tabs.services.messages.currently-viewing-2
+
+

+ +

+

+ app.app-info.app-tabs.services.messages.no-services +

+

+ app.app-info.app-tabs.services.messages.view-catalogue + app.app-info.app-tabs.services.messages.view-catalogue-link +

+
+
+

+ app.app-info.app-tabs.services.messages.no-services-search +

+
+

+
+
+
+ \ No newline at end of file diff --git a/components/cloud-foundry/frontend/src/view/applications/application/services/services.module.js b/components/cloud-foundry/frontend/src/view/applications/application/services/services.module.js index f6c63fc065..f6fe5bd6af 100644 --- a/components/cloud-foundry/frontend/src/view/applications/application/services/services.module.js +++ b/components/cloud-foundry/frontend/src/view/applications/application/services/services.module.js @@ -8,7 +8,7 @@ function registerRoute($stateProvider) { $stateProvider.state('cf.applications.application.services', { - url: '/services', + url: '/services?serviceType', templateUrl: 'plugins/cloud-foundry/view/applications/application/services/services.html', controller: ApplicationServicesController, controllerAs: 'applicationServicesCtrl' @@ -21,7 +21,7 @@ hide: false, uiSref: 'cf.applications.application.services', uiSrefParam: function () { - return {guid: $stateParams.guid}; + return { guid: $stateParams.guid }; }, label: 'app.app-info.app-tabs.services.label' }); @@ -33,6 +33,7 @@ * @param {object} $scope - the Angular $scope service * @param {app.model.modelManager} modelManager - the model management service * @param {object} $stateParams - the UI router $stateParams service + * @param {object} $state - the UI router $state service * @property {cloud-foundry.model.space} model - the Cloud Foundry space model * @property {cloud-foundry.model.application} model - the Cloud Foundry application model * @property {string} id - the application GUID @@ -43,93 +44,133 @@ * @property {object} search - the search object for filtering * @property {object} category - the search category object for filtering */ - function ApplicationServicesController($scope, modelManager, $stateParams) { + function ApplicationServicesController( + $scope, + modelManager, + $stateParams, + $state + ) { var that = this; - this.model = modelManager.retrieve('cloud-foundry.model.space'); - this.appModel = modelManager.retrieve('cloud-foundry.model.application'); - this.id = $stateParams.guid; - this.cnsiGuid = $stateParams.cnsiGuid; - this.services = []; - this.serviceCategories = [ - { label: 'app.app-info.app-tabs.services.categories.attached', value: 'attached' }, - { label: 'app.app-info.app-tabs.services.categories.all', value: 'all' } - ]; - this.searchCategory = 'all'; - this.search = {}; - this.category = { - entity: { - extra: undefined - } - }; - this.ready = false; - - $scope.$watch(function () { - return that.appModel.application.summary.guid; - }, function () { - var summary = that.appModel.application.summary; - var spaceGuid = summary.space_guid; - if (angular.isDefined(spaceGuid)) { - that.model.listAllServicesForSpace(that.cnsiGuid, spaceGuid) - .then(function (services) { - // retrieve categories and attachment data for service filtering - var categories = []; - var attachedServices = _.chain(summary.services) - .filter(function (o) { return angular.isDefined(o.service_plan); }) - .map(function (o) { return o.service_plan.service.guid; }) - .value(); - angular.forEach(services, function (service) { - if (attachedServices.length > 0) { - if (_.includes(attachedServices, service.metadata.guid)) { - service.attached = true; - } - } - - // Parse service entity extra data JSON string - if (!_.isNil(service.entity.extra) && angular.isString(service.entity.extra)) { - service.entity.extra = angular.fromJson(service.entity.extra); - } - - // a service can belong to >1 category, so allow filtering by any of them - if (angular.isObject(service.entity.extra) && angular.isDefined(service.entity.extra.Categories)) { - var _categories = service.entity.extra.Categories; - if (angular.isString(_categories)) { - _categories = [_categories]; - } - var serviceCategories = _.map(_categories, function (o) { - return { - label: o, - value: { Categories: o }, - lower: o.toLowerCase() - }; - }); - categories = _.unionBy(categories, serviceCategories, 'lower'); - } - }); + that.model = modelManager.retrieve('cloud-foundry.model.space'); + that.appModel = modelManager.retrieve('cloud-foundry.model.application'); + that.id = $stateParams.guid; + that.cnsiGuid = $stateParams.cnsiGuid; + that.managingType = $stateParams.serviceType; + that.services = []; + that.ALL_FILTER = 'all'; + that.filterType = $stateParams.serviceType || that.ALL_FILTER; + that.search = {}; - that.services.length = 0; - [].push.apply(that.services, services); + that.ready = false; - categories = _.sortBy(categories, 'lower'); - that.serviceCategories.length = 2; - [].push.apply(that.serviceCategories, categories); - }) - .finally(function () { - that.ready = true; - }); + if ($stateParams.serviceType) { + // Make sure we clear the serviceType url param if the user changes the filter + var stopFilterWatch = $scope.$watch(function () { + return that.filterType; + }, function () { + if (that.filterType !== $stateParams.serviceType) { + stopFilterWatch(); + $stateParams.serviceType = null; + that.managingType = null; + $state.go('.', $stateParams, { notify: false }); + } + }); + } + + var stopSummaryWatch = $scope.$watch(function () { + return that.appModel.application.summary; + }, function (summary) { + if (summary.guid) { + stopSummaryWatch(); + var spaceGuid = summary.space_guid; + if (spaceGuid) { + that.model.listAllServiceInstancesForSpace(that.cnsiGuid, spaceGuid) + .then(function (serviceInstances) { + // Get any extra service data + // and get the types from services + return { + filterTypes: that.getServiceFilterTypes(serviceInstances), + services: that.getExtraServiceData(serviceInstances) + }; + }) + .then(function (data) { + // Attach data to controller + that.filterTypes = data.filterTypes; + that.services = data.services; + that.ready = true; + }); + } } }); - $scope.$watch(function () { - return that.searchCategory; - }, function (newSearchCategory) { - if (newSearchCategory === 'attached') { - that.category.entity.extra = undefined; - that.category.attached = true; - } else { - delete that.category.attached; - that.category.entity.extra = newSearchCategory === 'all' ? undefined : newSearchCategory; + function getCurrentAppBinding(serviceInstance) { + if ( + !serviceInstance.entity.service_bindings || + serviceInstance.entity.service_bindings.length === 0 + ) { + return null; } - }); + return _.find(serviceInstance.entity.service_bindings, function (binding) { + return binding.entity.app_guid === that.appModel.application.summary.guid; + }) || null; + } + + function getServiceInstanceType(serviceInstance) { + return serviceInstance.entity.service_plan.entity.service.entity.label; + } + + // Works out if the current list is being filtered or searched. + that.isFiltered = function () { + return that.search.$ || that.filterType !== that.ALL_FILTER; + }; + + that.showInstance = function (serviceInstance) { + var isOfType = !that.filterType || + that.filterType === that.ALL_FILTER || + that.filterType === getServiceInstanceType(serviceInstance); + var isBoundToThisApp = !!getCurrentAppBinding(serviceInstance); + + return isBoundToThisApp && isOfType; + }; + + that.getExtraServiceData = function (services) { + return _.chain(services) + .map(function (service) { + if (angular.isString(service.entity.extra)) { + service.entity.extra = angular.fromJson(service.entity.extra); + } + return service; + }) + .sortBy('entity.type') + .value(); + }; + + // Gets all of the types in the current list of instances + that.getServiceFilterTypes = function (serviceInstances) { + var baseFilters = [{ + label: 'app.app-info.app-tabs.services.types.all', + value: that.ALL_FILTER, + lower: that.ALL_FILTER.toLowerCase() + }]; + + var typeFilters = _.chain(serviceInstances) + .flatMap(function (serviceInstance) { + return getServiceInstanceType(serviceInstance); + }) + .compact() + .uniq() + .map(function (type) { + return { + label: type, + value: type, + lower: type.toLowerCase() + }; + }) + .sortBy('lower') + .value(); + + return baseFilters.concat(typeFilters); + }; } })(); diff --git a/components/cloud-foundry/frontend/src/view/applications/application/services/services.scss b/components/cloud-foundry/frontend/src/view/applications/application/services/services.scss index bbaae2cbb0..b99a6bdd25 100644 --- a/components/cloud-foundry/frontend/src/view/applications/application/services/services.scss +++ b/components/cloud-foundry/frontend/src/view/applications/application/services/services.scss @@ -1,5 +1,4 @@ -@import "manage-services/manage-services"; -@import "service-card/service-card"; +@import "./service-card/service-card"; .service-filter-panel { .filter-form-group { @@ -11,3 +10,7 @@ display: flex; align-items: stretch; } + +.service-manage-message { + margin-bottom: $console-unit-space; +} diff --git a/components/cloud-foundry/frontend/src/view/applications/application/summary/add-route/add-route.service.js b/components/cloud-foundry/frontend/src/view/applications/application/summary/add-route/add-route.service.js index 64f7a0cdd2..fbeeae7d8c 100644 --- a/components/cloud-foundry/frontend/src/view/applications/application/summary/add-route/add-route.service.js +++ b/components/cloud-foundry/frontend/src/view/applications/application/summary/add-route/add-route.service.js @@ -149,19 +149,19 @@ // 3) Get the route id and add it to the route object // 4) Return array of routes return getRoutesFn() - .then(function (routes) { - return _.chain(routes) - .filter(function (route) { - return !_.find(route.entity.apps, function (app) { - return app.metadata.guid === applicationGuid; - }); - }) - .map(function (route) { - route.entity.id = getRouteIdFn(route); - return route; - }) - .value(); - }); + .then(function (routes) { + return _.chain(routes) + .filter(function (route) { + return !_.find(route.entity.apps, function (app) { + return app.metadata.guid === applicationGuid; + }); + }) + .map(function (route) { + route.entity.id = getRouteIdFn(route); + return route; + }) + .value(); + }); }; var getAllRoutesForThisSpace = _.partial( @@ -223,9 +223,9 @@ }); }), getReleventRoutes() - .then(function (routes) { - data.existingRoutes = routes; - }) + .then(function (routes) { + data.existingRoutes = routes; + }) ) ); } diff --git a/components/cloud-foundry/frontend/src/view/applications/application/summary/summary.html b/components/cloud-foundry/frontend/src/view/applications/application/summary/summary.html index f2c74bdc35..bca7dbe3da 100644 --- a/components/cloud-foundry/frontend/src/view/applications/application/summary/summary.html +++ b/components/cloud-foundry/frontend/src/view/applications/application/summary/summary.html @@ -78,17 +78,30 @@
-
-
app.app-info.app-tabs.summary.summary-panel.deployed-label
-
- {{ applicationSummaryCtrl.stratosProject.timestamp * 1000 | momentDateFormat }} +
app.app-info.app-tabs.summary.summary-panel.deployed-label
+
+ {{ applicationSummaryCtrl.stratosProject.deploySource.timestamp * 1000 | momentDateFormat }}
-
app.app-info.app-tabs.summary.summary-panel.deployed-source-label
-
- - {{applicationSummaryCtrl.stratosProject.commit | limitTo:8}} +
app.app-info.app-tabs.summary.summary-panel.deployed-source-label
+
+ + {{applicationSummaryCtrl.stratosProject.deploySource.commit | limitTo:8}} + + {{applicationSummaryCtrl.stratosProject.deploySource.url}} + + app.app-info.app-tabs.summary.summary-panel.deployed-source-filefolder + app.app-info.app-tabs.summary.summary-panel.deployed-source-archive
cf.ssh.access
@@ -106,6 +119,10 @@ off-class="console-status-normal" on-class="console-status-normal"> +
app.app-info.app-tabs.summary.summary-panel.service-count
+
+ {{ ::appCtrl.model.application.summary.services.length }} +
@@ -268,40 +285,3 @@
-
-
- app.app-info.app-tabs.summary.service-instances-panel.title - - arrow_forward - app.app-info.app-tabs.summary.service-instances-panel.manage-button - -
-
-
- app.app-info.app-tabs.summary.service-instances-panel.none -
- - - - - - - - - - - - - - - - -
app.app-info.app-tabs.summary.service-instances-panel.name-labelapp.app-info.app-tabs.summary.service-instances-panel.service-labelapp.app-info.app-tabs.summary.service-instances-panel.plan-label
{{ service.name }}{{ service.service_plan.service.label }}{{ service.service_plan.name }}
-
-
- diff --git a/components/cloud-foundry/frontend/src/view/applications/application/summary/summary.module.js b/components/cloud-foundry/frontend/src/view/applications/application/summary/summary.module.js index 1f99db24de..3e06e18e05 100644 --- a/components/cloud-foundry/frontend/src/view/applications/application/summary/summary.module.js +++ b/components/cloud-foundry/frontend/src/view/applications/application/summary/summary.module.js @@ -53,7 +53,7 @@ authModel.actions.create, model.application.summary.space_guid); }, go: function (appActions, appGuid) { - $state.go('cf.applications.application.services', {guid: appGuid}); + $state.go('cf.applications.application.service-catalogue', {guid: appGuid}); } }] }); @@ -86,9 +86,9 @@ * @property {appNotificationsService} appNotificationsService - the toast notification service */ function ApplicationSummaryController($state, $stateParams, $log, $q, $translate, - modelManager, cfAddRoutes, cfEditApp, appUtilsService, - appClusterRoutesService, frameworkDialogConfirm, appNotificationsService, - cfApplicationTabs, cfUtilsService) { + modelManager, cfAddRoutes, cfEditApp, appUtilsService, + appClusterRoutesService, frameworkDialogConfirm, appNotificationsService, + cfApplicationTabs, cfUtilsService) { var vm = this; var authModel = modelManager.retrieve('cloud-foundry.model.auth'); diff --git a/components/cloud-foundry/frontend/src/view/applications/applications.module.js b/components/cloud-foundry/frontend/src/view/applications/applications.module.js index 25d6c4278f..0885c848da 100644 --- a/components/cloud-foundry/frontend/src/view/applications/applications.module.js +++ b/components/cloud-foundry/frontend/src/view/applications/applications.module.js @@ -49,9 +49,9 @@ function init() { return initialized.promise - .then(function () { - return authService.initialize(); - }); + .then(function () { + return authService.initialize(); + }); } appUtilsService.chainStateResolve('cf.applications', $state, init); diff --git a/components/cloud-foundry/frontend/src/view/applications/list/list.module.js b/components/cloud-foundry/frontend/src/view/applications/list/list.module.js index fd0b684b26..33b45c5746 100644 --- a/components/cloud-foundry/frontend/src/view/applications/list/list.module.js +++ b/components/cloud-foundry/frontend/src/view/applications/list/list.module.js @@ -34,7 +34,7 @@ * @param {appLocalStorage} appLocalStorage - service provides access to the local storage facility of the web browser */ function ApplicationsListController($scope, $translate, $state, $timeout, $q, $window, modelManager, appErrorService, - appUtilsService, cfOrganizationModel, cfAppWallActions, appLocalStorage) { + appUtilsService, cfOrganizationModel, cfAppWallActions, appLocalStorage) { var vm = this; @@ -276,6 +276,9 @@ } }); } else { + // Cluster is set to all, so should org + vm.model.filterParams.orgGuid = 'all'; + vm.filter.orgGuid = 'all'; return $q.resolve(); } } @@ -313,6 +316,9 @@ } }); } else { + // Cluster & org are set to all, so should space + vm.model.filterParams.spaceGuid = 'all'; + vm.filter.spaceGuid = 'all'; return $q.resolve(); } } @@ -407,8 +413,8 @@ } /** - * @function getClusterOrganizations - * @description Get organizations for selected cluster + * @function setCluster + * @description * @returns {void} * @public */ @@ -427,18 +433,7 @@ // changed the org and space filter needToReload = needToReload || !_.isMatch(vm.filter, {orgGuid: 'all', spaceGuid: 'all'}); - if (needToReload) { - _reload(); - } else { - if (vm.filter.cnsiGuid === 'all') { - vm.model.resetFilter(); - } else { - vm.model.filterByCluster(vm.filter.cnsiGuid); - } - vm.paginationProperties.pageNumber = 1; - vm.paginationProperties.total = _.ceil(vm.model.filteredApplications.length / vm.model.pageSize); - _loadPage(1); - } + _reload(false, !needToReload); }); } diff --git a/components/cloud-foundry/frontend/src/view/applications/list/table-view/table-view.directive.js b/components/cloud-foundry/frontend/src/view/applications/list/table-view/table-view.directive.js index aefcca17e6..481a49c418 100644 --- a/components/cloud-foundry/frontend/src/view/applications/list/table-view/table-view.directive.js +++ b/components/cloud-foundry/frontend/src/view/applications/list/table-view/table-view.directive.js @@ -41,12 +41,12 @@ var model = modelManager.retrieve('cloud-foundry.model.application'); this.stApps = []; this.tableColumns = [ - {name: 'app-wall.table.columns.appName', value: 'entity.name'}, - {name: 'app-wall.table.columns.status', value: 'state.label', noSort: true}, - {name: 'app-wall.table.columns.instances', value: 'entity.instances', descendingFirst: true}, - {name: 'app-wall.table.columns.disk', value: 'entity.disk_quota', descendingFirst: true}, - {name: 'app-wall.table.columns.memory', value: 'entity.memory', descendingFirst: true}, - {name: 'app-wall.table.columns.creation', value: 'metadata.created_at', descendingFirst: true} + { name: 'app-wall.table.columns.appName', value: 'entity.name' }, + { name: 'app-wall.table.columns.status', value: 'state.label', noSort: true }, + { name: 'app-wall.table.columns.instances', value: 'entity.instances', descendingFirst: true }, + { name: 'app-wall.table.columns.disk', value: 'entity.disk_quota', descendingFirst: true }, + { name: 'app-wall.table.columns.memory', value: 'entity.memory', descendingFirst: true }, + { name: 'app-wall.table.columns.creation', value: 'metadata.created_at', descendingFirst: true } ]; this.init = false; @@ -72,18 +72,20 @@ $scope.$watchCollection( function () { return that.apps; - }, function () { - updateStTableState(); - }); + }, + function () { + updateStTableState(); + }); $scope.$watch( function () { return that.table; - }, function () { - if (that.table) { - that.table.sortBy(model.currentSortOption, !model.sortAscending); - } - }); + }, + function () { + if (that.table) { + that.table.sortBy(model.currentSortOption, !model.sortAscending); + } + }); this.getAppSummaryLink = getAppSummaryLink; this.stMiddleware = stMiddleware; diff --git a/components/cloud-foundry/frontend/src/view/applications/services/service-instance/service-instance.service.js b/components/cloud-foundry/frontend/src/view/applications/services/service-instance/service-instance.service.js index 4d672d7c98..5ee7877291 100644 --- a/components/cloud-foundry/frontend/src/view/applications/services/service-instance/service-instance.service.js +++ b/components/cloud-foundry/frontend/src/view/applications/services/service-instance/service-instance.service.js @@ -19,7 +19,7 @@ * @returns {object} A service instance factory */ function serviceInstanceFactory($log, $translate, $q, modelManager, appNotificationsService, frameworkDetailView, - frameworkDialogConfirm) { + frameworkDialogConfirm) { var appModel = modelManager.retrieve('cloud-foundry.model.application'); var bindingModel = modelManager.retrieve('cloud-foundry.model.service-binding'); var instanceModel = modelManager.retrieve('cloud-foundry.model.service-instance'); diff --git a/components/cloud-foundry/frontend/src/view/applications/workflows/add-app-workflow/add-app-workflow.directive.js b/components/cloud-foundry/frontend/src/view/applications/workflows/add-app-workflow/add-app-workflow.directive.js index 8dc3bd1f4c..3cef856d51 100644 --- a/components/cloud-foundry/frontend/src/view/applications/workflows/add-app-workflow/add-app-workflow.directive.js +++ b/components/cloud-foundry/frontend/src/view/applications/workflows/add-app-workflow/add-app-workflow.directive.js @@ -40,7 +40,7 @@ * @property {object} options - workflow options */ function AddAppWorkflowController(modelManager, appEventService, appUtilsService, cfUtilsService, $scope, $q, - $translate) { + $translate) { var vm = this; @@ -125,10 +125,7 @@ { templateUrl: 'plugins/cloud-foundry/view/applications/workflows/add-app-workflow/add-application.html', formName: 'application-name-form', - btnText: { - next: 'buttons.add', - cancel: 'buttons.cancel' - }, + nextBtnText: 'buttons.add', showBusyOnNext: true, isLastStep: true, onEnter: function () { @@ -154,14 +151,24 @@ {appName: vm.userInput.name}) }); }, function (error) { - var msg = $translate.instant('add-app-dialog.step1.notifications.failure-part-1'); + var failurePart1, failurePart1Alt, failurePart2; + if (vm.userInput.routeCreateBindError) { + failurePart1 = 'add-app-dialog.step1.route-create-bind.failure-part-1'; + failurePart1Alt = 'add-app-dialog.step1.route-create-bind.failure-part-1-alt'; + failurePart2 = 'add-app-dialog.step1.route-create-bind.failure-part-2'; + } else { + failurePart1 = 'add-app-dialog.step1.notifications.failure-part-1'; + failurePart1Alt = 'add-app-dialog.step1.notifications.failure-part-1-alt'; + failurePart2 = 'add-app-dialog.step1.notifications.failure-part-2'; + } + + var msg = $translate.instant(failurePart1); var cloudFoundryException = appUtilsService.extractCloudFoundryError(error); if (cloudFoundryException || _.isString(error)) { - msg = $translate.instant('add-app-dialog.step1.notifications.failure-part-1-alt', - { error: cloudFoundryException || error}); + msg = $translate.instant(failurePart1Alt, { error: cloudFoundryException || error}); } - msg = msg + $translate.instant('add-app-dialog.step1.notifications.failure-part-2'); + msg = msg + $translate.instant(failurePart2); return $q.reject(msg); }); }); @@ -224,6 +231,13 @@ var routePromise = routeModel.createRoute(cnsiGuid, routeSpec) .then(function (route) { return routeModel.associateAppWithRoute(cnsiGuid, route.metadata.guid, app.metadata.guid); + }) + .then(function () { + vm.userInput.routeCreateBindError = false; + }) + .catch(function (err) { + vm.userInput.routeCreateBindError = true; + return $q.reject(err); }); appEventService.$emit('cf.events.NEW_APP_CREATED'); @@ -260,17 +274,17 @@ } if (error.exist) { - return $q.reject($translate.instant('add-app-dialog.step1.route.exists')); + return $q.reject($translate.instant('add-app-dialog.step1.route-validate.exists')); } - var msg = $translate.instant('add-app-dialog.step1.route.failure-part-1'); + var msg = $translate.instant('add-app-dialog.step1.route-validate.failure-part-1'); var cloudFoundryException = appUtilsService.extractCloudFoundryError(error); if (cloudFoundryException || _.isString(error)) { - msg = $translate.instant('add-app-dialog.step1.route.failure-part-1-alt', + msg = $translate.instant('add-app-dialog.step1.route-validate.failure-part-1-alt', {error: cloudFoundryException || error}); } - msg = msg + $translate.instant('add-app-dialog.step1.route.failure-part-2'); + msg = msg + $translate.instant('add-app-dialog.step1.route-validate.failure-part-2'); return $q.reject(msg); }); @@ -360,13 +374,13 @@ function stopWorkflow() { vm.notify(); vm.addingApplication = false; - vm.closeDialog(); + vm.closeDialog({ reload: vm.userInput.routeCreateBindError }); } function finishWorkflow() { vm.notify(); vm.addingApplication = false; - vm.dismissDialog(); + vm.dismissDialog({ reload: vm.userInput.routeCreateBindError }); } } diff --git a/components/cloud-foundry/frontend/src/view/applications/workflows/add-service-workflow/add-service-workflow.directive.js b/components/cloud-foundry/frontend/src/view/applications/workflows/add-service-workflow/add-service-workflow.directive.js index 275921e73a..c605eb62c2 100644 --- a/components/cloud-foundry/frontend/src/view/applications/workflows/add-service-workflow/add-service-workflow.directive.js +++ b/components/cloud-foundry/frontend/src/view/applications/workflows/add-service-workflow/add-service-workflow.directive.js @@ -29,15 +29,15 @@ * @param {app.model.modelManager} modelManager - the application model manager * @param {app.utils.appEventService} appEventService - the event management service * @param {app.framework.widgets.frameworkDetailView} frameworkDetailView - the detail view widget + * @param {object} cfServiceCreateServiceInstanceWorkflow - service to support creating a new service instance * @property {object} modal - the detail view modal instance * @property {object} addServiceActions - the stop and finish workflow actions */ - function AddServiceWorkflowController($q, $scope, $translate, modelManager, appEventService, frameworkDetailView) { + function AddServiceWorkflowController($q, $scope, $translate, modelManager, appEventService, frameworkDetailView, cfServiceCreateServiceInstanceWorkflow) { var vm = this; var appModel = modelManager.retrieve('cloud-foundry.model.application'); var bindingModel = modelManager.retrieve('cloud-foundry.model.service-binding'); - var instanceModel = modelManager.retrieve('cloud-foundry.model.service-instance'); var serviceModel = modelManager.retrieve('cloud-foundry.model.service'); var spaceModel = modelManager.retrieve('cloud-foundry.model.space'); var path = 'plugins/cloud-foundry/view/applications/workflows/add-service-workflow/'; @@ -55,7 +55,6 @@ }; vm.reset = reset; - vm.addService = addService; vm.addBinding = addBinding; vm.startWorkflow = startWorkflow; vm.stopWorkflow = stopWorkflow; @@ -110,18 +109,17 @@ { templateUrl: path + 'instance.html', formName: 'addInstanceForm', - nextBtnText: 'app.app-info.app-tabs.services.add.add', + nextBtnText: 'app.app-info.app-tabs.services.bind.bind-to-app', showBusyOnNext: true, stepCommit: true, onNext: function () { - return vm.addService().then(function () { - return vm.addBinding().then(function () { - return $q.resolve(); - }, function () { - return _onServiceBindingError(); - }); + var planGuid = vm.options.userInput.existingServiceInstance.entity.service_plan_guid; + vm.options.servicePlan = vm.options.servicePlanMap[planGuid]; + vm.options.serviceInstance = vm.options.userInput.existingServiceInstance; + return vm.addBinding().then(function () { + return $q.resolve(); }, function () { - return $q.reject('app.app-info.app-tabs.services.add.notifications.failure-create'); + return _onServiceBindingError(); }); } } @@ -158,6 +156,18 @@ serviceInstance: null, servicePlan: null }; + + vm.options.createServiceBinding = createServiceBinding; + } + + function createServiceBinding() { + cfServiceCreateServiceInstanceWorkflow.show(vm.data.cnsiGuid, vm.data.spaceGuid, vm.options.instanceNames, vm.options.servicePlans) + .then(function (newServiceInstance) { + _loadServiceInstances().then(function () { + // AUto-select the instance that was just created + vm.userInput.existingServiceInstance = _.find(vm.options.instances, function (o) { return o.metadata.guid === newServiceInstance.metadata.guid; }); + }); + }); } /** @@ -235,43 +245,6 @@ }); } - /** - * @function addService - * @memberof cloud-foundry.view.applications.AddServiceWorkflowController - * @description Add a new service instance to the space - * @returns {object} A promise object - */ - function addService() { - var deferred = $q.defer(); - - if (vm.options.activeTab === 0) { - var newInstance = { - name: vm.options.userInput.name, - service_plan_guid: vm.options.userInput.plan.metadata.guid, - space_guid: vm.data.spaceGuid - }; - instanceModel.createServiceInstance(vm.data.cnsiGuid, newInstance) - .then(function (newServiceInstance) { - if (angular.isDefined(newServiceInstance.metadata)) { - vm.options.serviceInstance = newServiceInstance; - deferred.resolve(); - } else { - deferred.reject(); - } - }, function () { - deferred.reject(); - }); - vm.options.servicePlan = vm.options.userInput.plan; - } else { - var planGuid = vm.options.userInput.existingServiceInstance.entity.service_plan_guid; - vm.options.servicePlan = vm.options.servicePlanMap[planGuid]; - vm.options.serviceInstance = vm.options.userInput.existingServiceInstance; - deferred.resolve(); - } - - return deferred.promise; - } - /** * @function addBinding * @memberof cloud-foundry.view.applications.AddServiceWorkflowController @@ -284,6 +257,10 @@ app_guid: vm.data.app.summary.guid }; + if (vm.options.userInput && vm.options.userInput.params) { + bindingSpec.parameters = vm.options.userInput.params; + } + return bindingModel.createServiceBinding(vm.data.cnsiGuid, bindingSpec) .then(function (newBinding) { if (angular.isDefined(newBinding.metadata)) { @@ -301,16 +278,18 @@ * @returns {object} A promise object */ function startWorkflow() { + var config = { templateUrl: 'plugins/cloud-foundry/view/applications/workflows/add-service-workflow/add-service-workflow.html', - title: 'app.app-info.app-tabs.services.add.title', - titleTranslateValues: { appName: vm.data.app.summary.name }, + title: 'app.app-info.app-tabs.service-catalogue.bind.title', + titleTranslateValues: { appName: vm.data.app.summary.name, svcName: vm.options.service.entity.label}, dialog: true, - class: 'dialog-form-larger' + class: 'dialog-form-larger add-service-workflow-dialog' }; var context = { addServiceActions: vm.addServiceActions, - options: vm.options + options: vm.options, + createServiceBinding: vm.createServiceBinding }; return frameworkDetailView(config, context); @@ -334,20 +313,19 @@ */ function finishWorkflow() { if (!vm.data.confirm) { - vm.addService().then(function () { - vm.addBinding().then(function () { - // show notification for successful binding - var successMsg = $translate.instant('app.app-info.app-tabs.services.add.notifications.success', { - service: vm.options.serviceInstance.entity.name, - appName: vm.data.app.summary.name - }); - appEventService.$emit('events.NOTIFY_SUCCESS', {message: successMsg}); - vm.modal.close(); - }, function () { - return _onServiceBindingError(); + var planGuid = vm.options.userInput.existingServiceInstance.entity.service_plan_guid; + vm.options.servicePlan = vm.options.servicePlanMap[planGuid]; + vm.options.serviceInstance = vm.options.userInput.existingServiceInstance; + return vm.addBinding().then(function () { + // show notification for successful binding + var successMsg = $translate.instant('app.app-info.app-tabs.services.add.notifications.success', { + service: vm.options.serviceInstance.entity.name, + appName: vm.data.app.summary.name }); + appEventService.$emit('events.NOTIFY_SUCCESS', {message: successMsg}); + vm.modal.close(); }, function () { - return $q.reject('app.app-info.app-tabs.services.add.notifications.failure-create'); + return _onServiceBindingError(); }); } else { vm.modal.close(); @@ -367,7 +345,7 @@ vm.options.activeTab = 1; var guid = vm.options.serviceInstance.metadata.guid; vm.userInput.existingServiceInstance = _.find(vm.options.instances, - function (o) { return o.metadata.guid === guid; }); + function (o) { return o.metadata.guid === guid; }); }); } return $q.reject('app.app-info.app-tabs.services.add.notifications.failure-bind'); diff --git a/components/cloud-foundry/frontend/src/view/applications/workflows/add-service-workflow/add-service-workflow.scss b/components/cloud-foundry/frontend/src/view/applications/workflows/add-service-workflow/add-service-workflow.scss index 1179fd5419..fd61524848 100644 --- a/components/cloud-foundry/frontend/src/view/applications/workflows/add-service-workflow/add-service-workflow.scss +++ b/components/cloud-foundry/frontend/src/view/applications/workflows/add-service-workflow/add-service-workflow.scss @@ -1,7 +1,7 @@ .add-service-workflow { .select-instance { - padding: $console-unit-space 0; + padding: 0; .loading-services-spinner { text-align: center; @@ -35,12 +35,20 @@ } .select-instance-tabs { + + min-height: 300px; + .nav.nav-tabs { .uib-tab > a { background: transparent; } } + .td-row-selector { + vertical-align: middle; + width: $console-unit-space; + } + .tab-content { padding: $console-unit-space 0; @@ -51,6 +59,33 @@ } } } + .no-bindable-instances > td { + text-align: center; + p { + margin: $console-half-space; + } + } + + table > tfoot > tr { + .td-create-service { + padding: 0; + vertical-align: middle; + + button { + display: flex; + align-items: center; + > i { + line-height: 1; + margin-right: $console-unit-space / 4; + } + + &:hover, &:focus { + outline: 0; + text-decoration: none; + } + } + } + } } .wizard-foot { diff --git a/components/cloud-foundry/frontend/src/view/applications/workflows/add-service-workflow/instance.html b/components/cloud-foundry/frontend/src/view/applications/workflows/add-service-workflow/instance.html index ff8517fffc..532ed539a9 100644 --- a/components/cloud-foundry/frontend/src/view/applications/workflows/add-service-workflow/instance.html +++ b/components/cloud-foundry/frontend/src/view/applications/workflows/add-service-workflow/instance.html @@ -1,84 +1,85 @@
-
-
- -
-
-

{{ wizardCtrl.options.service.entity.label }}

-

{{ wizardCtrl.options.service.entity.extra.LongDescription || wizardCtrl.options.service.entity.description }}

-
-
- -
-

- You can bind the Auto-scaler to this application here. Please note that you will need to use the CLI - to configure the auto-scaling policy for this application in order for it to be enabled. -

+

app.app-info.app-tabs.services.bind.warning.auto-sclaer

- - -
-
- - - .name-error-required - .name-error-unique - .name-error-pattern - - -
+

app.app-info.app-tabs.services.bind.select

-
- - - -
-
-
- -
- - - - - - - - - - - - - - - - - -
.name-label.plan-label.apps-label
- - - {{ instance.entity.name }}{{ wizardCtrl.options.servicePlanMap[instance.entity.service_plan_guid].entity.name }}{{ instance.entity.service_bindings.length || '-' }}
-
-
-
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + +
.name-label.plan-label.tags-label.apps-label
+ + + {{ instance.entity.name }}{{ wizardCtrl.options.servicePlanMap[instance.entity.service_plan_guid].entity.name }} +
+
{{ tag }}
+
+
{{ instance.entity.service_bindings.length || '-' }}
+

.title

+

.prompt

+ +
+ +
+ +
+ + app.app-info.app-tabs.services.create.edit-params +
{{ wizardCtrl.options.userInput.params | jsonString }}
+
+ +
+
+ + +
+
+
+ + + app.app-info.app-tabs.services.create.json-error-invalid + + +
+ +
+
\ No newline at end of file diff --git a/components/cloud-foundry/frontend/src/view/applications/workflows/create-service-instance/create-service-instance.html b/components/cloud-foundry/frontend/src/view/applications/workflows/create-service-instance/create-service-instance.html new file mode 100644 index 0000000000..17ce1b0bb3 --- /dev/null +++ b/components/cloud-foundry/frontend/src/view/applications/workflows/create-service-instance/create-service-instance.html @@ -0,0 +1,60 @@ +
+
+ + + .name-error-required + .name-error-unique + .name-error-pattern + + +
+ +
+ + + +
+ +
+ + +
+ +
+ + app.app-info.app-tabs.services.create.edit-params +
{{ asyncTaskDialogCtrl.context.options.userInput.params | jsonString }}
+
+
+ + +
+
+
+ + + app.app-info.app-tabs.services.create.json-error-invalid + + +
+ +
+
\ No newline at end of file diff --git a/components/cloud-foundry/frontend/src/view/applications/workflows/create-service-instance/create-service-instance.scss b/components/cloud-foundry/frontend/src/view/applications/workflows/create-service-instance/create-service-instance.scss new file mode 100644 index 0000000000..1fbb39a0a1 --- /dev/null +++ b/components/cloud-foundry/frontend/src/view/applications/workflows/create-service-instance/create-service-instance.scss @@ -0,0 +1,92 @@ +.modal.detail-view.detail-view-dialog.create-service-instance-dialog { + >.modal-dialog { + .async-dialog { + display: flex; + flex-direction: column; + height: 100%; + + .detail-view-content { + flex: 1; + width: 100%; + + > ng-include { + flex: 1 1 0; + } + } + } + } +} + +.async-dialog-param-editor { + display: none; + top: $async-dialog-header-height; + bottom: $async-dialog-footer-height; + left: $console-unit-space; + right: $console-unit-space; + + &.param-editor-open { + position: absolute; + display: block; + } + + .param-editor-container { + display: flex; + width: 100%; + height: 100%; + } +} + +.async-dialog-param-editor { + .param-editor-container { + flex-direction: column; + background-color: $white; + + p { + flex: 0 0 $console-unit-space; + margin: 0px; + } + + textarea { + flex: 1 1 0; + resize: none; + } + + .param-editor-buttons { + flex: 0 0 auto; + align-self: center; + margin: $console-half-space 0; + } + } +} + +form .form-group { + + &.tags-input-field { + width: 100%; + } + + &.form-json-editor-input { + padding-right: $console-unit-space * 3; + width: 100%; + + a.input-box-edit { + position: absolute; + right: 0; + border: 1px solid $input-border; + padding: 2px 6px; + margin-right: $console-half-space; + bottom: $padding-base-vertical; + } + + input.json-edit-box { + pointer-events: none; + } + + div.json-edit-box { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + } +} \ No newline at end of file diff --git a/components/cloud-foundry/frontend/src/view/applications/workflows/create-service-instance/create-service-instance.service.js b/components/cloud-foundry/frontend/src/view/applications/workflows/create-service-instance/create-service-instance.service.js new file mode 100644 index 0000000000..9ad7772094 --- /dev/null +++ b/components/cloud-foundry/frontend/src/view/applications/workflows/create-service-instance/create-service-instance.service.js @@ -0,0 +1,87 @@ +(function () { + 'use strict'; + + angular + .module('cloud-foundry.view.applications.services') + .factory('cfServiceCreateServiceInstanceWorkflow', cfServiceCreateServiceInstanceWorkflow); + + /** + * @memberof cloud-foundry.view.applications.services + * @name cfServiceCreateServiceInstanceWorkflow + * @description helper service for creating a new service instance + * @param {object} $q - the Angular $q service + * @param {app.model.modelManager} modelManager - the model management service + * @param {app.framework.widgets.frameworkAsyncTaskDialog} frameworkAsyncTaskDialog The framework async task dialog + * @returns {object} A service instance factory + */ + function cfServiceCreateServiceInstanceWorkflow($q, modelManager, frameworkAsyncTaskDialog) { + var instanceModel = modelManager.retrieve('cloud-foundry.model.service-instance'); + + /** + * @function addService + * @memberof cloud-foundry.view.applications.AddServiceWorkflowController + * @description Add a new service instance to the space + * @param {string} cnsiGuid - id of the Cloud Foundry to add in + * @param {string} spaceGuid - id of the space to add in + * @param {object} userInput - user input + * @returns {object} A promise object + */ + function addService(cnsiGuid, spaceGuid, userInput) { + var newInstance = { + name: userInput.name, + service_plan_guid: userInput.plan.metadata.guid, + space_guid: spaceGuid, + tags: _.map(userInput.tags, function (tag) { return tag.text; }), + parameters: userInput.params || {} + }; + + if (userInput.params) { + newInstance.parameters = userInput.params; + } + + return instanceModel.createServiceInstance(cnsiGuid, newInstance) + .then(function (newServiceInstance) { + if (angular.isDefined(newServiceInstance.metadata)) { + return newServiceInstance; + } else { + throw new Error('Failed to create instance'); + } + }); + } + + return { + show: function (cnsiGuid, spaceGuid, instanceNames, servicePlans) { + var path = 'plugins/cloud-foundry/view/applications/workflows/create-service-instance/create-service-instance.html'; + var options = { + instanceNames: instanceNames, + servicePlans: servicePlans, + userInput: { + plan: servicePlans.length ? servicePlans[0].value : null + } + }; + + var doCreate = function () { + return addService(cnsiGuid, spaceGuid, options.userInput); + }; + + return frameworkAsyncTaskDialog( + { + title: 'app.app-info.app-tabs.services.create.title', + templateUrl: path, + submitCommit: true, + buttonTitles: { + submit: 'app.app-info.app-tabs.services.create.button.yes' + }, + class: 'dialog-form-larger create-service-instance-dialog', + dialog: true + }, + { + options: options + }, + doCreate + ).result; + } + }; + } + +})(); diff --git a/components/cloud-foundry/frontend/src/view/applications/workflows/delete-app-workflow/delete-app-workflow.directive.js b/components/cloud-foundry/frontend/src/view/applications/workflows/delete-app-workflow/delete-app-workflow.directive.js index a1423dc051..c09a9a4e8e 100644 --- a/components/cloud-foundry/frontend/src/view/applications/workflows/delete-app-workflow/delete-app-workflow.directive.js +++ b/components/cloud-foundry/frontend/src/view/applications/workflows/delete-app-workflow/delete-app-workflow.directive.js @@ -41,7 +41,7 @@ * @property {object} userInput - user's input about new application */ function DeleteAppWorkflowController(modelManager, appEventService, $q, $translate, appUtilsService, - cfApplicationTabs, cfServiceDeleteAppWorkflow) { + cfApplicationTabs, cfServiceDeleteAppWorkflow) { var vm = this; @@ -321,13 +321,13 @@ appEventService.$emit(appEventService.events.REDIRECT, 'cf.applications.list.gallery-view'); vm.dismissDialog(); }) - .catch(function () { - vm.options.hasError = true; - return $q.reject(); - }) - .finally(function () { - vm.options.isDeleting = false; - }); + .catch(function () { + vm.options.hasError = true; + return $q.reject(); + }) + .finally(function () { + vm.options.isDeleting = false; + }); } } diff --git a/components/cloud-foundry/frontend/src/view/applications/workflows/workflows.scss b/components/cloud-foundry/frontend/src/view/applications/workflows/workflows.scss index a95370924a..cb465dfa81 100644 --- a/components/cloud-foundry/frontend/src/view/applications/workflows/workflows.scss +++ b/components/cloud-foundry/frontend/src/view/applications/workflows/workflows.scss @@ -1,5 +1,6 @@ @import "add-app-workflow/add-app-workflow"; @import "add-service-workflow/add-service-workflow"; +@import "create-service-instance/create-service-instance"; @import "delete-app-workflow/delete-app-workflow"; .control-title { diff --git a/components/cloud-foundry/frontend/src/view/dashboard/cluster/actions/assign-users-workflow/assign-users.service.js b/components/cloud-foundry/frontend/src/view/dashboard/cluster/actions/assign-users-workflow/assign-users.service.js index dfdabede97..f7d0548792 100644 --- a/components/cloud-foundry/frontend/src/view/dashboard/cluster/actions/assign-users-workflow/assign-users.service.js +++ b/components/cloud-foundry/frontend/src/view/dashboard/cluster/actions/assign-users-workflow/assign-users.service.js @@ -47,7 +47,7 @@ * @param {object} $uibModalInstance - the angular $uibModalInstance service used to close/dismiss a modal */ function AssignUsersWorkflowController($scope, $translate, modelManager, context, appClusterRolesService, - cfOrganizationModel,$stateParams, $q, $timeout, $uibModalInstance) { + cfOrganizationModel,$stateParams, $q, $timeout, $uibModalInstance) { var that = this; this.$uibModalInstance = $uibModalInstance; diff --git a/components/cloud-foundry/frontend/src/view/dashboard/cluster/actions/manage-user/manage-user.service.js b/components/cloud-foundry/frontend/src/view/dashboard/cluster/actions/manage-user/manage-user.service.js index 1435f51e13..54dc0357d4 100644 --- a/components/cloud-foundry/frontend/src/view/dashboard/cluster/actions/manage-user/manage-user.service.js +++ b/components/cloud-foundry/frontend/src/view/dashboard/cluster/actions/manage-user/manage-user.service.js @@ -16,7 +16,7 @@ * @param {object} cfOrganizationModel - the cfOrganizationModel service */ function ManageUsersFactory($translate, modelManager, frameworkAsyncTaskDialog, appClusterRolesService, - cfOrganizationModel) { + cfOrganizationModel) { var authModel = modelManager.retrieve('cloud-foundry.model.auth'); @@ -60,9 +60,9 @@ _.forEach(organizations, function (organization) { selectedRoles[organization.details.org.metadata.guid] = {}; disableClearAll = disableClearAll || !authModel.isAllowed(clusterGuid, - authModel.resources.organization, - authModel.actions.update, - organization.details.org.metadata.guid); + authModel.resources.organization, + authModel.actions.update, + organization.details.org.metadata.guid); }); // Async refresh roles diff --git a/components/cloud-foundry/frontend/src/view/dashboard/cluster/actions/roles-tables/roles.service.js b/components/cloud-foundry/frontend/src/view/dashboard/cluster/actions/roles-tables/roles.service.js index 4f80bd620e..970d07a0ab 100644 --- a/components/cloud-foundry/frontend/src/view/dashboard/cluster/actions/roles-tables/roles.service.js +++ b/components/cloud-foundry/frontend/src/view/dashboard/cluster/actions/roles-tables/roles.service.js @@ -36,7 +36,7 @@ * selected */ function appClusterRolesService($log, $q, $interpolate, $translate, modelManager, appEventService, - appNotificationsService, frameworkDialogConfirm, cfOrganizationModel) { + appNotificationsService, frameworkDialogConfirm, cfOrganizationModel) { var that = this; var spaceModel = modelManager.retrieve('cloud-foundry.model.space'); diff --git a/components/cloud-foundry/frontend/src/view/dashboard/cluster/cluster.module.js b/components/cloud-foundry/frontend/src/view/dashboard/cluster/cluster.module.js index 50748cf480..70036afe57 100644 --- a/components/cloud-foundry/frontend/src/view/dashboard/cluster/cluster.module.js +++ b/components/cloud-foundry/frontend/src/view/dashboard/cluster/cluster.module.js @@ -19,7 +19,7 @@ } function ClusterController($stateParams, $log, appUtilsService, $state, $q, appClusterRolesService, - modelManager, appUserSelection, cfOrganizationModel, cfUtilsService) { + modelManager, appUserSelection, cfOrganizationModel, cfUtilsService) { var that = this; var appModel = modelManager.retrieve('cloud-foundry.model.application'); var authModel = modelManager.retrieve('cloud-foundry.model.auth'); diff --git a/components/cloud-foundry/frontend/src/view/dashboard/cluster/detail/actions/cluster-actions.directive.js b/components/cloud-foundry/frontend/src/view/dashboard/cluster/detail/actions/cluster-actions.directive.js index bd61d46aee..3d48f48809 100644 --- a/components/cloud-foundry/frontend/src/view/dashboard/cluster/detail/actions/cluster-actions.directive.js +++ b/components/cloud-foundry/frontend/src/view/dashboard/cluster/detail/actions/cluster-actions.directive.js @@ -40,8 +40,8 @@ * @property {Array} actions - collection of relevant actions vm can be executed against cluster */ function ClusterActionsController($scope, modelManager, $state, $q, $stateParams, $translate, appUtilsService, - frameworkAsyncTaskDialog, appClusterAssignUsers, appUserSelection, - appNotificationsService, cfOrganizationModel) { + frameworkAsyncTaskDialog, appClusterAssignUsers, appUserSelection, + appNotificationsService, cfOrganizationModel) { var vm = this; var spaceModel = modelManager.retrieve('cloud-foundry.model.space'); var authModel = modelManager.retrieve('cloud-foundry.model.auth'); diff --git a/components/cloud-foundry/frontend/src/view/dashboard/cluster/detail/cluster-detail.html b/components/cloud-foundry/frontend/src/view/dashboard/cluster/detail/cluster-detail.html index cc445ff5b0..4b8736f50b 100644 --- a/components/cloud-foundry/frontend/src/view/dashboard/cluster/detail/cluster-detail.html +++ b/components/cloud-foundry/frontend/src/view/dashboard/cluster/detail/cluster-detail.html @@ -50,6 +50,10 @@