[ https://issues.apache.org/jira/browse/KYLIN-3418?page=com.atlassian.jira.plugin.system.issuetabpanels:comment-tabpanel&focusedCommentId=16558168#comment-16558168 ]
ASF GitHub Bot commented on KYLIN-3418: --------------------------------------- shaofengshi closed pull request #175: KYLIN-3418 User interface for hybrid model - Frontend URL: https://github.com/apache/kylin/pull/175 This is a PR merged from a forked repository. As GitHub hides the original diff on merge, it is displayed below for the sake of provenance: As this is a foreign pull request (from a fork), the diff is supplied below (as it won't show otherwise due to GitHub magic): diff --git a/webapp/app/fonts/kylin.eot b/webapp/app/fonts/kylin.eot new file mode 100755 index 0000000000..4cb39d1272 Binary files /dev/null and b/webapp/app/fonts/kylin.eot differ diff --git a/webapp/app/fonts/kylin.svg b/webapp/app/fonts/kylin.svg new file mode 100755 index 0000000000..df507bb9a8 --- /dev/null +++ b/webapp/app/fonts/kylin.svg @@ -0,0 +1,13 @@ +<?xml version="1.0" standalone="no"?> +<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd" > +<svg xmlns="http://www.w3.org/2000/svg"> +<metadata>Generated by IcoMoon</metadata> +<defs> +<font id="icomoon" horiz-adv-x="1024"> +<font-face units-per-em="1024" ascent="960" descent="-64" /> +<missing-glyph horiz-adv-x="1024" /> +<glyph unicode=" " horiz-adv-x="512" d="" /> +<glyph unicode="" glyph-name="hybrid" d="M538.389 557.895h282.93v-140.317l199.184-107.26 2.914-2.914 0.925-2.867v-232.435c0-3.237-1.295-5.457-3.838-6.752l-200.617-108.032h-0.971c-0.647-0.647-1.619-0.971-2.914-0.971s-2.266 0.324-2.914 0.971h-0.925l-200.617 108.032c-2.59 1.295-3.885 3.515-3.885 6.752v232.435c0 0.601 0.324 1.295 0.971 1.942v0.971l2.914 2.914 156.668 84.329v110.095l-541.185 0.276v-94.685l185.815-100.061 2.914-2.914 0.925-2.867v-232.435c0-3.237-1.295-5.457-3.838-6.752l-200.617-108.032h-0.971c-0.647-0.647-1.619-0.971-2.914-0.971s-2.266 0.324-2.914 0.971h-0.925l-200.617 108.032c-2.59 1.295-3.885 3.515-3.885 6.752v232.435c0 0.601 0.324 1.295 0.971 1.942v0.971l2.914 2.914 170.036 91.525v156.281h303.139v115.95h-95.273v260.426h260.426v-260.426h-112.046v-115.95h8.222v-0.276zM646.655 300.737l169.346-91.327 169.346 91.327-169.346 91.327-169.346-91.327zM998.61 82.351v198.835l-171.951-93.147v-198.835l171.951 93.147zM38.995 300.737l169.346-91.327 169.346 91.327-169.346 91.327-169.346-91.327zM390.949 82.351v198.835l-171.951-93.147v-198.835l171.951 93.147z" /> +<glyph unicode="" glyph-name="arrows_right" d="M761.077 449.819l-456.626 392.050c-16.091 13.815-17.935 38.059-4.12 54.149s38.059 17.935 54.149 4.12l490.56-421.184c17.847-15.323 17.847-42.946 0-58.27l-490.56-421.184c-16.091-13.815-40.334-11.97-54.149 4.12s-11.97 40.334 4.12 54.149l456.626 392.050z" /> +<glyph unicode="" glyph-name="arrows_left" d="M242.824 449.819l456.626-392.050c16.091-13.815 17.935-38.059 4.12-54.149s-38.059-17.935-54.149-4.12l-490.56 421.184c-17.847 15.323-17.847 42.946 0 58.27l490.56 421.184c16.091 13.815 40.334 11.97 54.149-4.12s11.97-40.334-4.12-54.149l-456.626-392.050z" /> +</font></defs></svg> \ No newline at end of file diff --git a/webapp/app/fonts/kylin.ttf b/webapp/app/fonts/kylin.ttf new file mode 100755 index 0000000000..8294ac99f9 Binary files /dev/null and b/webapp/app/fonts/kylin.ttf differ diff --git a/webapp/app/fonts/kylin.woff b/webapp/app/fonts/kylin.woff new file mode 100755 index 0000000000..4b6f197e4c Binary files /dev/null and b/webapp/app/fonts/kylin.woff differ diff --git a/webapp/app/image/checkbox+.svg b/webapp/app/image/checkbox+.svg new file mode 100644 index 0000000000..b8630a3dd8 --- /dev/null +++ b/webapp/app/image/checkbox+.svg @@ -0,0 +1,15 @@ +<?xml version="1.0" encoding="UTF-8"?> +<svg width="16px" height="16px" viewBox="0 0 16 16" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"> + <!-- Generator: Sketch 49.2 (51160) - http://www.bohemiancoding.com/sketch --> + <title>Group Copy 2</title> + <desc>Created with Sketch.</desc> + <defs></defs> + <g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd"> + <g id="Hybrid-add-cubes" transform="translate(-34.000000, -1331.000000)"> + <g id="Group-Copy-2" transform="translate(34.000000, 1331.000000)"> + <rect id="bg" fill="#0988DE" x="0" y="0" width="16" height="16" rx="2"></rect> + <path d="M4.51040764,8.01040764 L13.5104076,8.01040764 L13.5104076,10.0104076 L2.51040764,10.0104076 L2.51040764,8.01040764 L2.51040764,4.01040764 L4.51040764,4.01040764 L4.51040764,8.01040764 Z" id="check" fill="#FFFFFF" transform="translate(8.010408, 7.010408) rotate(-45.000000) translate(-8.010408, -7.010408) "></path> + </g> + </g> + </g> +</svg> \ No newline at end of file diff --git a/webapp/app/image/checkbox-.svg b/webapp/app/image/checkbox-.svg new file mode 100644 index 0000000000..6de232a15b --- /dev/null +++ b/webapp/app/image/checkbox-.svg @@ -0,0 +1,17 @@ +<?xml version="1.0" encoding="UTF-8"?> +<svg width="16px" height="16px" viewBox="0 0 16 16" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"> + <!-- Generator: Sketch 49.2 (51160) - http://www.bohemiancoding.com/sketch --> + <title>bg copy 2</title> + <desc>Created with Sketch.</desc> + <defs> + <rect id="path-1" x="34" y="395" width="16" height="16" rx="2"></rect> + </defs> + <g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd"> + <g id="Hybrid-add-cubes" transform="translate(-34.000000, -395.000000)"> + <g id="bg-copy-2"> + <use fill="#FFFFFF" fill-rule="evenodd" xlink:href="#path-1"></use> + <rect stroke="#CFD8DC" stroke-width="1" x="34.5" y="395.5" width="15" height="15" rx="2"></rect> + </g> + </g> + </g> +</svg> \ No newline at end of file diff --git a/webapp/app/index.html b/webapp/app/index.html index 12daaa2486..8f7e9540d2 100644 --- a/webapp/app/index.html +++ b/webapp/app/index.html @@ -149,6 +149,7 @@ <script src="js/services/acl.js"></script> <!--New GUI--> <script src="js/services/models.js"></script> +<script src="js/services/hybridInstance.js"></script> <script src="js/services/dashboard.js"></script> <script src="js/model/cubeConfig.js"></script> @@ -172,6 +173,7 @@ <!--New GUI--> <script src="js/model/modelsManager.js"></script> +<script src="js/model/hybridInstanceManager.js"></script> <script src="js/services/badQuery.js"></script> <script src="js/utils/utils.js"></script> <script src="js/controllers/page.js"></script> @@ -210,6 +212,8 @@ <!--New GUI--> <script src="js/controllers/models.js"></script> +<script src="js/controllers/hybridInstanceSchema.js"></script> +<script src="js/controllers/hybridInstance.js"></script> <script src="js/controllers/dashboard.js"></script> <!-- endref --> @@ -256,6 +260,24 @@ <h4>Model Schema</h4> </div> </script> +<!-- static template for hybrid cube save/update result notification --> +<script type="text/ng-template" id="hybridResultError.html"> + <div class="callout callout-info"> + <h4>Error Message</h4> + <p>{{text}}</p> + </div> + <div class="callout callout-danger"> + <h4>Hybrid Instance Schema</h4> + <pre>{{schema}}</pre> + </div> +</script> + +<script type="text/ng-template" id="hybridResultSuccess.html"> + <div class="callout callout-info"> + <p>{{text}}</p> + </div> +</script> + <!-- static template for cube save/update result notification --> <script type="text/ng-template" id="streamingResultError.html"> <div class="callout"> diff --git a/webapp/app/js/controllers/hybridInstance.js b/webapp/app/js/controllers/hybridInstance.js new file mode 100644 index 0000000000..152feab1e0 --- /dev/null +++ b/webapp/app/js/controllers/hybridInstance.js @@ -0,0 +1,110 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +KylinApp.controller('HybridInstanceCtrl', function ( + $scope, $q, $location, + ProjectModel, hybridInstanceManager, SweetAlert, HybridInstanceService, loadingRequest +) { + $scope.projectModel = ProjectModel; + $scope.hybridInstanceManager = hybridInstanceManager; + + //trigger init with directive [] + $scope.list = function () { + var defer = $q.defer(); + var queryParam = {}; + if (!$scope.projectModel.isSelectedProjectValid()) { + defer.resolve([]); + return defer.promise; + } + + if (!$scope.projectModel.projects.length) { + defer.resolve([]); + return defer.promise; + } + queryParam.project = $scope.projectModel.selectedProject; + return hybridInstanceManager.list(queryParam).then(function (resp) { + defer.resolve(resp); + hybridInstanceManager.loading = false; + return defer.promise; + }); + }; + + $scope.list(); + + $scope.$watch('projectModel.selectedProject', function() { + $scope.list(); + }); + + $scope.editHybridInstance = function(hybridInstance){ + // check for empty project of header, break the operation. + if (ProjectModel.selectedProject === null) { + SweetAlert.swal('Oops...', 'Please select your project first.', 'warning'); + $location.path("/models"); + return; + } + + $location.path("/hybrid/edit/" + hybridInstance.name); + }; + + $scope.dropHybridInstance = function (hybridInstance) { + + // check for empty project of header, break the operation. + if (ProjectModel.selectedProject === null) { + SweetAlert.swal('Oops...', 'Please select your project first.', 'warning'); + $location.path("/models"); + return; + } + + SweetAlert.swal({ + title: '', + text: 'Are you sure to drop this hybrid?', + type: '', + showCancelButton: true, + confirmButtonColor: '#DD6B55', + confirmButtonText: "Yes", + closeOnConfirm: true + }, function (isConfirm) { + if (isConfirm) { + var schema = { + hybrid: hybridInstance.name, + model: hybridInstance.model, + project: hybridInstance.project, + }; + + loadingRequest.show(); + HybridInstanceService.drop(schema, {}, function (result) { + loadingRequest.hide(); + SweetAlert.swal('Success!', 'Hybrid drop is done successfully', 'success'); + location.reload(); + }, function (e) { + loadingRequest.hide(); + if (e.data && e.data.exception) { + var message = e.data.exception; + var msg = !!(message) ? message : 'Failed to take action.'; + SweetAlert.swal('Oops...', msg, 'error'); + } else { + SweetAlert.swal('Oops...', "Failed to take action.", 'error'); + } + }); + } + + }); + }; +}); \ No newline at end of file diff --git a/webapp/app/js/controllers/hybridInstanceSchema.js b/webapp/app/js/controllers/hybridInstanceSchema.js new file mode 100644 index 0000000000..033e3c155e --- /dev/null +++ b/webapp/app/js/controllers/hybridInstanceSchema.js @@ -0,0 +1,404 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +KylinApp.controller('HybridInstanceSchema', function ( + $scope, $q, $location, $interpolate, $templateCache, $routeParams, + CubeList, HybridInstanceService, ProjectModel, modelsManager, SweetAlert, MessageService, loadingRequest, CubeService, CubeDescService +) { + + // check for empty project of header, break the operation. + if (!$scope.isEdit && ProjectModel.selectedProject === null) { + SweetAlert.swal('Oops...', 'Please select your project first.', 'warning'); + $location.path("/models"); + return; + } + + $scope.LEFT = 'LEFT'; + $scope.RIGHT = 'RIGHT'; + $scope.isFormDisabled = false; + + $scope.cubeList = CubeList; + $scope.projectModel = ProjectModel; + $scope.modelsManager = modelsManager; + + $scope.route = { params: $routeParams.hybridName }; + $scope.isEdit = !!$routeParams.hybridName; + + $scope.isEditInitialized = false; + $scope.isLockEditModel = false; + + $scope.form = { + name: '', + model: '' + }; + + resetPageData(); + + /** + * Computed: get the model's cubes + * + * @param {'LEFT' | 'RIGHT'} dir + */ + $scope.getFiltedModelCube = function(dir) { + var dataRows = $scope.table[dir].dataRows; + + return dataRows.filter(function(row) { + return row.model === $scope.form.model; + }); + } + + /** + * Computed: get the count of the model cubes + * + * @param {'LEFT' | 'RIGHT'} dir + */ + $scope.getFiltedModelCubeCount = function(dir) { + return $scope.getFiltedModelCube(dir).length; + } + + /** + * Computed: judge that current cube row is checked + * + * @param {'LEFT' | 'RIGHT'} dir + * @param {*} cube + */ + $scope.isCubeChecked = function(dir, cube) { + return $scope.table[dir].checkedCubeIds.indexOf(function(cubeId) { + return cubeId === cube.uuid; + }) !== -1; + }; + + /** + * Computed: judge that all rows of the table are checked + * + * @param {'LEFT' | 'RIGHT'} dir + */ + $scope.isCheckAll = function(dir) { + var dataRows = $scope.getFiltedModelCube(dir); + + return dataRows.length ? dataRows.every(function(row) { + return row.isChecked === true; + }) : false; + }; + + $scope.toggleCube = function(cube) { + cube.isChecked = !cube.isChecked; + } + + /** + * Computed: judge that model select component can be chosen + */ + $scope.isModelSelectDisabled = function() { + return !modelsManager.models.length + || $scope.table[$scope.RIGHT].dataRows.length; + } + + /** + * Computed: judge is form valid + */ + $scope.isFormValid = function() { + // get schema data + var schema = getSchema(); + + return Object.keys(schema).every(function(key) { + // Array.length for checking select cubes count >= 2 + // otherwise checking empty value + return schema[key] instanceof Array ? schema[key].length > 1 : schema[key]; + }); + }; + + /** + * Action: toggle all rows' check status of the table + * + * @param {'LEFT' | 'RIGHT'} dir + * @param {undefined | boolean} toStatus: for change all the table's cubes hardly + */ + $scope.toggleAll = function(dir, toStatus) { + var isCheckAll = $scope.isCheckAll(dir); + var dataRows = $scope.getFiltedModelCube(dir); + + dataRows.forEach(function(row) { + if(toStatus !== undefined) { + row.isChecked = toStatus; + } else { + row.isChecked = !isCheckAll; + } + }); + }; + + /** + * Action: transfer checked rows from destination table to source table + * + * @param {'LEFT' | 'RIGHT'} dir + */ + $scope.transferTo = function(dir) { + var toDir = dir; + var fromDir = dir === $scope.RIGHT ? $scope.LEFT : $scope.RIGHT; + var srcTable = $scope.table[fromDir]; + var disTable = $scope.table[toDir]; + + // get checked rows from source table to transfer rows + var transferRows = srcTable.dataRows.filter(function(row) { + return row.isChecked; + }); + + // filter unchecked row to source table rows + srcTable.dataRows = srcTable.dataRows.filter(function(row) { + return !row.isChecked; + }); + + // clean transfer rows check status + transferRows.forEach(function(row) { + row.isChecked = false; + }); + + // push transfer rows to destination table + disTable.dataRows = disTable.dataRows.concat(transferRows); + } + + /** + * Action: page edit cancel handler + */ + $scope.cancel = function() { + history.go(-1); + }; + + /** + * Action: page edit submit handler + */ + $scope.submit = function() { + // get form data + var schema = getSchema(); + // show save warning + saveWarning(function() { + // show loading + loadingRequest.show(); + // save the hybrid cube + if(!$scope.isEdit) { + HybridInstanceService.save({}, schema, successHandler, failedHandler); + } else { + HybridInstanceService.update({}, schema, successHandler, failedHandler); + } + }); + + function successHandler(request) { + if(request.successful === false) { + var message = request.message; + var msg = !!message ? message : 'Failed to take action.'; + var template = hybridInstanceResultTmpl({ text: msg, schema: schema }); + MessageService.sendMsg(template, 'error', {}, true, 'top_center'); + } else { + if($scope.isEdit) { + SweetAlert.swal('', 'Update hybrid cube successfully.', 'success'); + } else { + SweetAlert.swal('', 'Create hybrid cube successfully.', 'success'); + } + $location.path('/models'); + } + // hide global loading + loadingRequest.hide(); + } + + function failedHandler(e) { + if (e.data && e.data.exception) { + var message = e.data.exception; + var msg = !!(message) ? message : 'Failed to take action.'; + var template = hybridInstanceResultTmpl({ text: msg, schema: schema }); + MessageService.sendMsg(template, 'error', {}, true, 'top_center'); + } else { + var template = hybridInstanceResultTmpl({ text: 'Failed to take action.', schema: schema }); + MessageService.sendMsg(template, 'error', {}, true, 'top_center'); + } + // hide global loading + loadingRequest.hide(); + } + } + + doPerpare(); + + /** + * Init: initialize watcher + */ + function doPerpare() { + $scope.$watch('projectModel.selectedProject', function (newValue, oldValue) { + if (newValue != oldValue || newValue == null) { + CubeList.removeAll(); + resetPageData(); + listModels(); + } + }); + + $scope.$watch('modelsManager.models', function() { + $scope.form.model = modelsManager.models[0] && modelsManager.models[0].name || ''; + }); + + $scope.$watch('form.model', function() { + if(!$scope.isLockEditModel) { + cleanCubeStatus(); + } + $scope.isLockEditModel = false; + }); + + $scope.$watch('cubeList.cubes', function() { + loadTableData(); + + if ($scope.isEdit && !$scope.isEditInitialized && CubeList.cubes.length) { + getEditHybridInstance(); + $scope.isEditInitialized = true; + } + }); + } + + /** + * Helper: get form data + */ + function getSchema() { + const schema = { + hybrid: $scope.form.name, + project: $scope.projectModel.selectedProject, + model: $scope.form.model, + cubes: $scope.table[$scope.RIGHT].dataRows.map(function(row) { + return row.name; + }) + }; + return schema; + } + + /** + * Helper: reset page data + */ + function resetPageData() { + $scope.table = {}; + $scope.form.model = ''; + $scope.table[$scope.LEFT] = { + dataRows: [] + }; + $scope.table[$scope.RIGHT] = { + dataRows: [] + }; + } + + /** + * Helper: ajax request models + */ + function listModels () { + var defer = $q.defer(); + var queryParam = {}; + if (!$scope.projectModel.isSelectedProjectValid()) { + defer.resolve([]); + return defer.promise; + } + + if (!$scope.projectModel.projects.length) { + defer.resolve([]); + return defer.promise; + } + queryParam.projectName = $scope.projectModel.selectedProject; + return modelsManager.list(queryParam).then(function (resp) { + defer.resolve(resp); + modelsManager.loading = false; + return defer.promise; + }); + }; + + /** + * Helper: clean left table and reset status + */ + function loadTableData() { + var cubesData = Object.create($scope.cubeList.cubes); + var unusedCubeTable = $scope.table[$scope.LEFT].dataRows = []; + + cubesData.forEach(function(cubeData) { + cubeData.isChecked = false; + unusedCubeTable.push(cubeData); + }); + } + + function hybridInstanceResultTmpl(notification) { + // Get the static notification template. + var tmpl = notification.type == 'success' ? 'hybridResultSuccess.html' : 'hybridResultError.html'; + return $interpolate($templateCache.get(tmpl))(notification); + }; + + function saveWarning(callback) { + SweetAlert.swal({ + title: $scope.isEdit + ? 'Are you sure to update the Hybrid Cube?' + : 'Are you sure to save the Hybrid Cube?', + text: $scope.isEdit + ? '' + : '', + type: 'warning', + showCancelButton: true, + confirmButtonColor: '#DD6B55', + confirmButtonText: "Yes", + closeOnConfirm: true + }, function(isConfirm) { + if(isConfirm) { + callback(); + } + })}; + + /** + * Helper: if $scope.form.model is changed, clean all the selected cube. + */ + function cleanCubeStatus() { + // clean left table cubes + $scope.table[$scope.LEFT].dataRows.forEach(function(row) { + row.isChecked = false; + }); + // check right table cubes + $scope.table[$scope.RIGHT].dataRows.forEach(function(row) { + row.isChecked = true; + }); + // move right table cubes to left table + $scope.transferTo($scope.LEFT); + } + + /** + * Helper: get edit hybrid cube + */ + function getEditHybridInstance() { + loadingRequest.show(); + + HybridInstanceService.getByName({ hybrid_name: $routeParams.hybridName }, function (_hybridInstance) { + var hybridInstance = _hybridInstance.hybridInstance; + + $scope.form.uuid = hybridInstance.uuid; + $scope.form.name = hybridInstance.name; + + hybridInstance.realizations.forEach(function(realizationItem) { + var usedCubeName = realizationItem.realization; + var unusedCubeTable = $scope.table[$scope.LEFT]; + + unusedCubeTable.dataRows.forEach(function(row) { + if(row.name === usedCubeName) { + row.isChecked = true; + $scope.form.model = row.model; + } + }); + }); + + $scope.transferTo($scope.RIGHT); + loadingRequest.hide(); + $scope.isLockEditModel = true; + }); + } +}); \ No newline at end of file diff --git a/webapp/app/js/directives/directives.js b/webapp/app/js/directives/directives.js index d6ed304fd9..13716079fb 100644 --- a/webapp/app/js/directives/directives.js +++ b/webapp/app/js/directives/directives.js @@ -27,14 +27,14 @@ KylinApp.directive('kylinPagination', function ($parse, $q) { templateUrl: 'partials/directives/pagination.html', link: function (scope, element, attrs) { var _this = this; - scope.limit = 15; scope.hasMore = false; scope.data = $parse(attrs.data)(scope.$parent); scope.action = $parse(attrs.action)(scope.$parent); scope.loadFunc = $parse(attrs.loadFunc)(scope.$parent); + scope.isHideTotal = $parse(attrs.isHideTotal)(); + scope.limit = $parse(attrs.limit)() || 15; scope.autoLoad = true; - scope.$watch("action.reload", function (newValue, oldValue) { if (newValue != oldValue) { scope.reload(); diff --git a/webapp/app/js/model/hybridInstanceManager.js b/webapp/app/js/model/hybridInstanceManager.js new file mode 100644 index 0000000000..5a86ae30e2 --- /dev/null +++ b/webapp/app/js/model/hybridInstanceManager.js @@ -0,0 +1,60 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. +*/ + +KylinApp.service('hybridInstanceManager', function($q, HybridInstanceService, ProjectModel) { + var _this = this; + this.hybridInstances = []; + this.hybridInstanceNameList = []; + + //tracking models loading status + this.loading = false; + + //list hybrid cubes + this.list = function(queryParam) { + _this.loading = true; + + var defer = $q.defer(); + + HybridInstanceService.list(queryParam, function(_hybridInstances) { + _hybridInstances = _hybridInstances.map(function(_hybridInstance) { + var instance = _hybridInstance.hybridInstance; + instance.project = _hybridInstance.projectName; + instance.model = _hybridInstance.modelName; + + return instance; + }); + + angular.forEach(_hybridInstances, function(hybridInstance) { + _this.hybridInstanceNameList.push(hybridInstance.name); + // hybridInstance.project = ProjectModel.getProjectByCubeModel(hybridInstance.name); + }); + + _hybridInstances = _.filter(_hybridInstances, function(hybridInstance) { + return hybridInstance.name !== undefined; + }); + + _this.hybridInstances = _hybridInstances; + _this.loading = false; + }, + function() { + defer.reject('Failed to load models'); + }); + + return defer.promise; + }; +}) diff --git a/webapp/app/js/services/hybridInstance.js b/webapp/app/js/services/hybridInstance.js new file mode 100644 index 0000000000..06aed2000b --- /dev/null +++ b/webapp/app/js/services/hybridInstance.js @@ -0,0 +1,28 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. +*/ + +KylinApp.factory('HybridInstanceService', ['$resource', function ($resource, config) { + return $resource(Config.service.url + 'hybrids/:hybrid_name', {}, { + list: { method: 'GET', params: {}, isArray: true }, + getByName: { method: 'GET', isArray: false }, + drop: {method: 'DELETE', params: {}, isArray: false}, + // clone: {method: 'PUT', params: {action: 'clone'}, isArray: false}, + save: {method: 'POST', params: {}, isArray: false}, + update: {method: 'PUT', params: {}, isArray: false} + }); +}]); diff --git a/webapp/app/less/app.less b/webapp/app/less/app.less index cf9748e291..cb59fab4ff 100644 --- a/webapp/app/less/app.less +++ b/webapp/app/less/app.less @@ -950,4 +950,110 @@ div[ng-controller="ModelConditionsSettingsCtrl"] { width: 49%; margin-top: 0; } +} + +.form-title { + font-size: 24px; + color: #3A87AD; + line-height: 1; + margin: 0; +} + +#main { + min-height: 100vh; +} + +div[ng-controller="HybridInstanceSchema"] { + .form-title { + margin-bottom: 43px; + } + .control-label { + margin-top: 7px; + } + .split-line { + border-bottom: 1px solid #DDDDDD; + margin: 5px 0 20px 0; + } + .table { + margin-bottom: 0; + } + .table-header { + box-sizing: border-box; + border: 1px solid #DDDDDD; + border-bottom: none; + } + .transter-actions { + position: relative; + top: 50%; + transform: translateY(-50%); + } + .transter-action-item { + text-align: center; + margin-bottom: 10px; + } + .transter-action-item i { + width: 32px; + height: 32px; + display: inline-block; + cursor: pointer; + background: #FFFFFF; + border: 1px solid #DDDDDD; + border-radius: 2px; + padding-top: 7px; + color: #808080; + font-size: 16px; + &:hover { + border-color: #3A87AD; + color: #3A87AD; + } + } + .data-empty { + text-align: center; + font-size: 14px; + color: #808080; + line-height: 22px; + padding: 8px 0; + border: 1px solid #DDDDDD; + } + .col-xs-5 { + z-index: 1; + } + .fix-height-table { + max-height: 408px; + overflow: auto; + } + .edit-operator { + position: relative; + margin-bottom: 30px; + } + div[tooltip] + .tooltip { + white-space: nowrap; + .tooltip-inner { + max-width: 1000px; + } + } + .table-checkbox { + text-align: center; + width: 38px; + height: 38px; + img { + cursor: pointer; + } + } + .status-center { + text-align: center; + width: 150px; + } +} + +.kylin-icon-hybrid { + margin-right: 10px; +} +.col-xs-offset-5 { + margin-left: 41.66666667%; +} + +.text-info { + font-size: 18px; + color: #263238; } \ No newline at end of file diff --git a/webapp/app/less/build.less b/webapp/app/less/build.less index 4271bacf95..241eee8a90 100644 --- a/webapp/app/less/build.less +++ b/webapp/app/less/build.less @@ -20,3 +20,4 @@ @import 'app.less'; @import 'component.less'; @import 'animation.less'; +@import 'font.less'; \ No newline at end of file diff --git a/webapp/app/less/font.less b/webapp/app/less/font.less new file mode 100644 index 0000000000..1f5484d992 --- /dev/null +++ b/webapp/app/less/font.less @@ -0,0 +1,36 @@ +@font-face { + font-family: 'kylin'; + src: url('../fonts/kylin.eot?pdayeh'); + src: url('fonts/kylin.eot?pdayeh#iefix') format('embedded-opentype'), + url('../fonts/kylin.ttf?pdayeh') format('truetype'), + url('../fonts/kylin.woff?pdayeh') format('woff'), + url('../fonts/kylin.svg?pdayeh#kylin') format('svg'); + font-weight: normal; + font-style: normal; +} + +[class^='kylin-icon-'], +[class*=' kylin-icon-'] { + /* use !important to prevent issues with browser extensions that change fonts */ + font-family: 'kylin' !important; + speak: none; + font-style: normal; + font-weight: normal; + font-variant: normal; + text-transform: none; + line-height: 1; + + /* Better Font Rendering =========== */ + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +.kylin-icon-hybrid:before { + content: '\e900'; +} +.kylin-icon-arrows_right:before { + content: '\e901'; +} +.kylin-icon-arrows_left:before { + content: '\e902'; +} diff --git a/webapp/app/partials/cubes/hybrid_edit.html b/webapp/app/partials/cubes/hybrid_edit.html new file mode 100644 index 0000000000..21b130fc32 --- /dev/null +++ b/webapp/app/partials/cubes/hybrid_edit.html @@ -0,0 +1,176 @@ +<!-- +* Licensed to the Apache Software Foundation (ASF) under one +* or more contributor license agreements. See the NOTICE file +* distributed with this work for additional information +* regarding copyright ownership. The ASF licenses this file +* to you under the Apache License, Version 2.0 (the +* "License"); you may not use this file except in compliance +* with the License. You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +--> + +<div ng-controller="CubesCtrl"> + <div class="page-header" style="height: 50px;"> + <!--Project--> + <form class="navbar-form navbar-left" style="margin-top: 0px !important;" ng-if="userService.isAuthorized()"> + <div class="form-group"> + <a class="btn btn-xs btn-info" href="projects" tooltip="Manage Project"> + <i class="fa fa-gears"></i> + </a> + <a class="btn btn-xs btn-primary" ng-if="userService.hasRole('ROLE_ADMIN')" style="width: 29px" tooltip="Add Project" ng-click="toCreateProj()"> + <i class="fa fa-plus"></i> + </a> + </div> + </form> + </div> + + <div class="row" ng-controller="HybridInstanceSchema"> + <div class="col-xs-12"> + <form role="form" name="hybrid_cube_form form-inline" novalidate> + + <h1 class="form-title">Hybrid Designer</h1> + + <div class="row"> + <div class="col-xs-5 form-group"> + <div class="row"> + <label class="col-xs-3 control-label no-padding-right font-color-default" for="hybridName">Hybrid Name</label> + <div class="col-xs-9"> + <input type="text" class="form-control" id="hybridName" placeholder="Hybrid Name" ng-disabled="isFormDisabled || isEdit" ng-model="form.name"> + </div> + </div> + </div> + </div> + + <div class="split-line"></div> + + <div class="row edit-operator"> + <div class="col-xs-5"> + <div class="dataTables_wrapper no-footer"> + <div class="row table-header"> + <label class="col-xs-2 control-label no-padding-right font-color-default" for="modelName">Model</label> + <div class="col-xs-10"> + <div ng-show="!isModelSelectDisabled()"> + <select width="'100%'" chosen ng-model="form.model" ng-change="toggleAll(LEFT, false)" ng-options="model.name as model.name for model in modelsManager.models" ng-disabled="isModelSelectDisabled()"></select> + </div> + <div tooltip="If you want to switch model, remove the selected cubes." ng-show="isModelSelectDisabled()"> + <select width="'100%'" chosen ng-model="form.model" ng-change="toggleAll(LEFT, false)" ng-options="model.name as model.name for model in modelsManager.models" ng-disabled="isModelSelectDisabled()"></select> + </div> + </div> + </div> + <div class="row fix-height-table"> + <table class="table table-striped table-bordered table-hover dataTable no-footer ng-scope" ng-if="getFiltedModelCubeCount(LEFT)"> + <thead> + <tr> + <th class="table-checkbox"> + <img ng-if="!isCheckAll(LEFT)" src="image/checkbox-.svg" ng-click="toggleAll(LEFT)" /> + <img ng-if="isCheckAll(LEFT)" src="image/checkbox+.svg" ng-click="toggleAll(LEFT)" /> + </th> + <th>Name</th> + <th class="status-center">Status</th> + </tr> + </thead> + <tbody> + <tr ng-repeat="cube in table[LEFT].dataRows" ng-if="cube.model === form.model" ng-click="toggleCube(cube)" style="cursor: pointer;"> + <td class="table-checkbox"> + <img ng-if="!cube.isChecked" src="image/checkbox-.svg" /> + <img ng-if="cube.isChecked" src="image/checkbox+.svg" /> + </td> + <td>{{ cube.name }}</td> + <td class="status-center"> + <span class="label" ng-class="{'label-success': cube.status=='READY', 'label-default': cube.status=='DISABLED', 'label-warning': cube.status=='DESCBROKEN'}"> + {{ cube.status }} + </span> + </td> + </tr> + </tbody> + </table> + </div> + + <div class="data-empty" ng-if="!getFiltedModelCubeCount(LEFT)"> + Empty + </div> + + <div class="row"> + <div class="col-xs-12"> + <kylin-pagination data="cubeList.cubes" load-func="list" action="action" is-hide-total="true" limit="999999" /> + </div> + </div> + </div> + </div> + + <div class="col-xs-2 col-lg-1"></div> + + <div style="position: absolute; height: 100%; width: 100%;"> + <div class="col-xs-offset-5 col-xs-2 col-lg-1 transter-actions"> + <div class="transter-action-item"> + <i class="kylin-icon-arrows_right" ng-click="transferTo(RIGHT)"></i> + </div> + <div class="transter-action-item"> + <i class="kylin-icon-arrows_left" ng-click="transferTo(LEFT)"></i> + </div> + </div> + </div> + + <div class="col-xs-5"> + <div class="dataTables_wrapper no-footer"> + <div class="row table-header"> + <label class="col-xs-12 control-label no-padding-right">Selected Cubes: {{table[RIGHT].dataRows.length}}</label> + </div> + <div class="row fix-height-table"> + <table class="table table-striped table-bordered table-hover dataTable no-footer ng-scope" ng-if="table[RIGHT].dataRows.length"> + <thead> + <tr> + <th class="table-checkbox"> + <img ng-if="!isCheckAll(RIGHT)" src="image/checkbox-.svg" ng-click="toggleAll(RIGHT)" /> + <img ng-if="isCheckAll(RIGHT)" src="image/checkbox+.svg" ng-click="toggleAll(RIGHT)" /> + </th> + <th>Name</th> + <th class="status-center">Status</th> + </tr> + </thead> + <tbody> + <tr ng-repeat="cube in table[RIGHT].dataRows" ng-click="toggleCube(cube)" style="cursor: pointer;"> + <td class="table-checkbox"> + <img ng-if="!cube.isChecked" src="image/checkbox-.svg" /> + <img ng-if="cube.isChecked" src="image/checkbox+.svg" /> + </td> + <td>{{ cube.name }}</td> + <td class="status-center"> + <span class="label" ng-class="{'label-success': cube.status=='READY', 'label-default': cube.status=='DISABLED', 'label-warning': cube.status=='DESCBROKEN'}"> + {{ cube.status }} + </span> + </td> + </tr> + </tbody> + </table> + </div> + + <div class="data-empty" ng-if="!table[RIGHT].dataRows.length"> + Empty + </div> + </div> + </div> + </div> + + <div class="split-line"></div> + + <div class="row"> + <div class="col-xs-12"> + <div class="pull-right"> + <button class="btn btn-sm btn-default" ng-click="cancel()">Cancel</button> + <button class="btn btn-sm btn-primary" ng-click="submit()" ng-disabled="!isFormValid()">Submit</button> + </div> + </div> + </div> + + </form> + </div> + </div> +</div> \ No newline at end of file diff --git a/webapp/app/partials/directives/pagination.html b/webapp/app/partials/directives/pagination.html index dba9fae5fb..d8fd242bab 100644 --- a/webapp/app/partials/directives/pagination.html +++ b/webapp/app/partials/directives/pagination.html @@ -21,7 +21,7 @@ <i class="icon-plus icon-white"></i> <span>{{ loaded ? 'More' : 'Loading...' }}</span> </button> <div class="clearfix" style="margin: 5px"></div> - <div class="pull-left" style="padding-right: 3%"> + <div class="pull-left" style="padding-right: 3%" ng-if="!isHideTotal"> <span class="pull-left font-color-default" style="font-size: 15px"><strong>Total: {{getLength(data)}}</strong></span> </div> </div> diff --git a/webapp/app/partials/models/models_tree.html b/webapp/app/partials/models/models_tree.html index 4a91b0066a..d2064a29de 100644 --- a/webapp/app/partials/models/models_tree.html +++ b/webapp/app/partials/models/models_tree.html @@ -17,57 +17,86 @@ --> <div class="tree-border"> - <div class="row"> - <div class="col-xs-12" style="margin-top:10px;"> - <!--<i class="fa fa-plus fa-2x" style="color:green;"> New</i>--> - <a ng-if="userService.hasRole('ROLE_ADMIN') || hasPermission('project',projectModel, permissions.ADMINISTRATION.mask, permissions.MANAGEMENT.mask)" class="dropdown-toggle" data-toggle="dropdown" href="#" aria-expanded="true"> - <i class="fa fa-plus fa-2x" style="color:#2e8965;"> New<span class="caret"></span></i> - <!--<i> New </i> <span class="caret"></span>--> - </a> - <ul class="dropdown-menu"> - <li ng-if="userService.hasRole('ROLE_ADMIN') || hasPermission('project',projectModel, permissions.ADMINISTRATION.mask, permissions.MANAGEMENT.mask)"> - <a href="models/add"><i class="fa fa-star"></i>New Model</a> - </li> - <li ng-if="userService.hasRole('ROLE_ADMIN') || hasPermission('project',projectModel, permissions.ADMINISTRATION.mask, permissions.MANAGEMENT.mask)"> - <a href="cubes/add"><i class="fa fa-cube"></i>New Cube</a> - </li> + <div class="row"> + <div class="col-xs-12" style="margin-top:10px;"> + <!--<i class="fa fa-plus fa-2x" style="color:green;"> New</i>--> + <a ng-if="userService.hasRole('ROLE_ADMIN') || hasPermission('project',projectModel, permissions.ADMINISTRATION.mask, permissions.MANAGEMENT.mask)" class="dropdown-toggle" data-toggle="dropdown" href="#" aria-expanded="true"> + <i class="fa fa-plus fa-2x" style="color:#2e8965;"> New<span class="caret"></span></i> + <!--<i> New </i> <span class="caret"></span>--> + </a> + <ul class="dropdown-menu"> + <li ng-if="userService.hasRole('ROLE_ADMIN') || hasPermission('project',projectModel, permissions.ADMINISTRATION.mask, permissions.MANAGEMENT.mask)"> + <a href="models/add"><i class="fa fa-star"></i>New Model</a> + </li> + <li ng-if="userService.hasRole('ROLE_ADMIN') || hasPermission('project',projectModel, permissions.ADMINISTRATION.mask, permissions.MANAGEMENT.mask)"> + <a href="cubes/add"><i class="fa fa-cube"></i>New Cube</a> + </li> + <li ng-if="userService.hasRole('ROLE_ADMIN') || hasPermission('project',projectModel, permissions.ADMINISTRATION.mask, permissions.MANAGEMENT.mask)"> + <a href="hybrid/add"><i class="kylin-icon-hybrid"></i>New Hybrid</a> + </li> + </ul> + </div> - </ul> - </div> + </div> + <div class="space-4 box-header with-border"></div> + <!--tree--> - </div> - <div class="space-4 box-header with-border"></div> - <!--tree--> <div> - <h3 class="text-info">Models</h3> + <h3 class="text-info">Models ({{modelsManager.models.length}})</h3> </div> -<!--{{window}}px --> - <div id="cube_model_trees" style="width:100%; height:250px; overflow:auto;margin-top: 20px;" class="cube_model_trees"> + <!--{{window}}px --> + <div id="cube_model_trees" style="width:100%; height:250px; overflow:auto;margin-top: 20px;" class="cube_model_trees"> - <ul class="list-group models-tree" id="models-tree"> - <li class="list-group-item" ng-repeat="model in modelsManager.models"> + <ul class="list-group models-tree" id="models-tree"> + <li class="list-group-item" ng-repeat="model in modelsManager.models"> - <div class="pull-right" showonhoverparent style="display:none;" > - <div ng-click="$event.stopPropagation();" class="btn-group"> - <button type="button" class="btn btn-default btn-xs dropdown-toggle" data-toggle="dropdown"> - Action <span class="ace-icon fa fa-caret-down icon-on-right"></span> - </button> - <ul class="dropdown-menu" role="menu" style="right:0;left:auto;" ng-if="(userService.hasRole('ROLE_ADMIN') || hasPermission('model',model, permissions.ADMINISTRATION.mask, permissions.MANAGEMENT.mask))"> - <li><a ng-click="editModel(model, false)" title="Edit Model" style="cursor:pointer;margin-right: 8px;" >Edit</a></li> - <li><a ng-click="cloneModel(model)" title="Clone Model" style="cursor:pointer;margin-right: 8px;" >Clone </a></li> - <li><a ng-click="dropModel(model)" title="Drop Model" style="cursor:pointer;margin-right: 8px;">Drop</a></li> - <li ng-if="userService.hasRole('ROLE_ADMIN')"> - <a ng-click="editModel(model, true)">Edit(JSON)</a></li> - </ul> - </div> + <div class="pull-right" showonhoverparent style="display:none;" > + <div ng-click="$event.stopPropagation();" class="btn-group"> + <button type="button" class="btn btn-default btn-xs dropdown-toggle" data-toggle="dropdown"> + Action <span class="ace-icon fa fa-caret-down icon-on-right"></span> + </button> + <ul class="dropdown-menu" role="menu" style="right:0;left:auto;" ng-if="(userService.hasRole('ROLE_ADMIN') || hasPermission('model',model, permissions.ADMINISTRATION.mask, permissions.MANAGEMENT.mask))"> + <li><a ng-click="editModel(model, false)" title="Edit Model" style="cursor:pointer;margin-right: 8px;" >Edit</a></li> + <li><a ng-click="cloneModel(model)" title="Clone Model" style="cursor:pointer;margin-right: 8px;" >Clone </a></li> + <li><a ng-click="dropModel(model)" title="Drop Model" style="cursor:pointer;margin-right: 8px;">Drop</a></li> + <li ng-if="userService.hasRole('ROLE_ADMIN')"> + <a ng-click="editModel(model, true)">Edit(JSON)</a></li> + </ul> </div> - <span class="strong"><a style="cursor: pointer;word-break:break-all;" ng-click="openModal(model)">{{model.name}}</a></span> + </div> + <span class="strong"><a style="cursor: pointer;word-break:break-all;" ng-click="openModal(model)">{{model.name}}</a></span> + + </li> + </ul> + <div ng-if="modelsManager.loading==true"><i class="fa fa-2x fa-spinner fa-spin"></i> Loading..</div> + <div no-result ng-if="modelsManager.loading!=true&&modelsManager.models.length==0"></div> + </div> - </li> - </ul> - <div ng-if="modelsManager.loading==true"><i class="fa fa-2x fa-spinner fa-spin"></i> Loading..</div> - <div no-result ng-if="modelsManager.loading!=true&&modelsManager.models.length==0"></div> + <div ng-controller="HybridInstanceCtrl"> + <div> + <h3 class="text-info">Hybrids ({{hybridInstanceManager.hybridInstances.length}})</h3> + </div> + <div id="hybrid_cube_trees" style="width:100%; height:250px; overflow:auto;margin-top: 20px;" class="cube_model_trees"> + <ul class="list-group models-tree" id="hybrid-tree"> + <li class="list-group-item" ng-repeat="hybridInstance in hybridInstanceManager.hybridInstances"> + <div class="pull-right" showonhoverparent style="display:none;" > + <div ng-click="$event.stopPropagation();" class="btn-group"> + <button type="button" class="btn btn-default btn-xs dropdown-toggle" data-toggle="dropdown"> + Action <span class="ace-icon fa fa-caret-down icon-on-right"></span> + </button> + <ul class="dropdown-menu" role="menu" style="right:0;left:auto;" ng-if="(userService.hasRole('ROLE_ADMIN') || hasPermission('model',model, permissions.ADMINISTRATION.mask, permissions.MANAGEMENT.mask))"> + <li><a ng-click="editHybridInstance(hybridInstance)" title="Edit Hybrid" style="cursor:pointer;margin-right: 8px;" >Edit</a></li> + <li><a ng-click="dropHybridInstance(hybridInstance)" title="Drop Hybrid" style="cursor:pointer;margin-right: 8px;">Drop</a></li> + </ul> + </div> + </div> + <span class="strong"><a style="cursor: pointer;word-break:break-all;" ng-click="openHybridInstance(hybridInstance)">{{hybridInstance.name}}</a></span> + </li> + </ul> + <div ng-if="hybridInstanceManager.loading === true"><i class="fa fa-2x fa-spinner fa-spin"></i> Loading..</div> + <div no-result ng-if="hybridInstanceManager.loading!==true && hybridInstanceManager.hybridInstances.length === 0"></div> </div> + </div> </div> <div ng-include="'partials/models/model_detail.html'"></div> diff --git a/webapp/app/routes.json b/webapp/app/routes.json index 65140a05c1..eefe35b04e 100644 --- a/webapp/app/routes.json +++ b/webapp/app/routes.json @@ -120,5 +120,19 @@ "tab": "dashboard", "controller": "DashboardCtrl" } + }, + { + "url": "/hybrid/add", + "params": { + "templateUrl": "partials/cubes/hybrid_edit.html", + "tab": "models" + } + }, + { + "url": "/hybrid/edit/:hybridName", + "params": { + "templateUrl": "partials/cubes/hybrid_edit.html", + "tab": "models" + } } ] ---------------------------------------------------------------- This is an automated message from the Apache Git Service. To respond to the message, please log on GitHub and use the URL above to go to the specific comment. For queries about this service, please contact Infrastructure at: us...@infra.apache.org > User interface for hybrid model > ------------------------------- > > Key: KYLIN-3418 > URL: https://issues.apache.org/jira/browse/KYLIN-3418 > Project: Kylin > Issue Type: Improvement > Components: Web > Reporter: Shaofeng SHI > Assignee: Roger > Priority: Major > Fix For: v2.5.0 > > > Hybrid model is useful for model change. While now there is no entry for it > from GUI, this makes many users don't see such feature. -- This message was sent by Atlassian JIRA (v7.6.3#76005)