From b097eb5fc9dcb37c26fd76115052608fb08b4176 Mon Sep 17 00:00:00 2001 From: lichuang Date: Tue, 4 Apr 2023 16:58:32 +0800 Subject: [PATCH] [KYLIN-5514][feature] support batch build and refresh, and added switch controls to part overlaps refresh. [KYLIN-5514][feature] support build or refresh type, support batch build by nature month. --- .../apache/kylin/common/KylinConfigBase.java | 3 + .../kylin/common/constant/JobTypeEnum.java | 7 +- .../apache/kylin/common/util/TimeUtil.java | 17 ++- .../org/apache/kylin/cube/CubeSegment.java | 86 +++++++++++++ .../apache/kylin/cube/CubeSegmentsTest.java | 73 +++++++++++ .../kylin/rest/controller/CubeController.java | 18 +++ .../apache/kylin/rest/service/JobService.java | 100 +++++++++++++++ webapp/app/js/controllers/cubes.js | 94 +++++++++++++- webapp/app/js/services/cubes.js | 1 + webapp/app/partials/cubes/cubes.html | 2 + webapp/app/partials/jobs/job_batchBuild.html | 116 ++++++++++++++++++ webapp/app/partials/models/models.html | 1 + 12 files changed, 515 insertions(+), 3 deletions(-) create mode 100644 webapp/app/partials/jobs/job_batchBuild.html diff --git a/core-common/src/main/java/org/apache/kylin/common/KylinConfigBase.java b/core-common/src/main/java/org/apache/kylin/common/KylinConfigBase.java index fc46325cb53..5877ea403be 100644 --- a/core-common/src/main/java/org/apache/kylin/common/KylinConfigBase.java +++ b/core-common/src/main/java/org/apache/kylin/common/KylinConfigBase.java @@ -912,6 +912,9 @@ public boolean isJobAutoReadyCubeEnabled() { return Boolean.parseBoolean(getOptional("kylin.job.cube-auto-ready-enabled", TRUE)); } + public boolean isBatchBuildCubeByMonthEnabled(){ + return Boolean.parseBoolean(getOptional("kylin.job.batch-build-by-month-enabled", TRUE)); + } public int getJobOutputMaxSize() { return Integer.parseInt(getOptional("kylin.job.execute-output.max-size", "10485760")); } diff --git a/core-common/src/main/java/org/apache/kylin/common/constant/JobTypeEnum.java b/core-common/src/main/java/org/apache/kylin/common/constant/JobTypeEnum.java index 7d8d6caf84b..3c1f6478d4e 100644 --- a/core-common/src/main/java/org/apache/kylin/common/constant/JobTypeEnum.java +++ b/core-common/src/main/java/org/apache/kylin/common/constant/JobTypeEnum.java @@ -50,5 +50,10 @@ public enum JobTypeEnum { /** * job of sampling table */ - TABLE_SAMPLING + TABLE_SAMPLING, + + /** + * batch build or refresh, refresh if segment exists, build if not + */ + BUILD_OR_REFRESH } diff --git a/core-common/src/main/java/org/apache/kylin/common/util/TimeUtil.java b/core-common/src/main/java/org/apache/kylin/common/util/TimeUtil.java index cd53cc657d0..32a1622d50c 100644 --- a/core-common/src/main/java/org/apache/kylin/common/util/TimeUtil.java +++ b/core-common/src/main/java/org/apache/kylin/common/util/TimeUtil.java @@ -30,7 +30,7 @@ private TimeUtil() { throw new IllegalStateException("Class TimeUtil is an utility class !"); } - private static TimeZone gmt = TimeZone.getTimeZone("GMT"); + public static TimeZone gmt = TimeZone.getTimeZone("GMT"); public static final long ONE_MINUTE_TS = 60 * 1000L; public static final long ONE_HOUR_TS = 60 * ONE_MINUTE_TS; public static final long ONE_DAY_TS = 24 * ONE_HOUR_TS; @@ -83,6 +83,21 @@ public static long getMonthStartWithTimeZone(TimeZone timeZone, long ts){ return calendar.getTimeInMillis(); } + public static long getNextMonthStart(long ts) { + return getNextMonthStartWithTimeZone(gmt, ts); + } + + public static long getNextMonthStartWithTimeZone(TimeZone timeZone, long ts) { + Calendar calendar = Calendar.getInstance(timeZone, Locale.ROOT); + calendar.setTimeInMillis(ts); + calendar.add(Calendar.MONTH, 1); + int year = calendar.get(Calendar.YEAR); + int month = calendar.get(Calendar.MONTH); + calendar.clear(); + calendar.set(year, month, 1); + return calendar.getTimeInMillis(); + } + public static long getQuarterStart(long ts) { return getQuarterStartWithTimeZone(gmt, ts); } diff --git a/core-cube/src/main/java/org/apache/kylin/cube/CubeSegment.java b/core-cube/src/main/java/org/apache/kylin/cube/CubeSegment.java index b6d134a0798..68b8e10260c 100644 --- a/core-cube/src/main/java/org/apache/kylin/cube/CubeSegment.java +++ b/core-cube/src/main/java/org/apache/kylin/cube/CubeSegment.java @@ -23,7 +23,9 @@ import java.nio.charset.StandardCharsets; import java.text.ParseException; import java.text.SimpleDateFormat; +import java.util.ArrayList; import java.util.Collection; +import java.util.Comparator; import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; @@ -40,6 +42,7 @@ import org.apache.kylin.common.util.JsonUtil; import org.apache.kylin.common.util.Pair; import org.apache.kylin.common.util.ShardingHash; +import org.apache.kylin.common.util.TimeUtil; import org.apache.kylin.cube.cuboid.CuboidScheduler; import org.apache.kylin.cube.kv.CubeDimEncMap; import org.apache.kylin.cube.kv.RowConstants; @@ -202,6 +205,89 @@ public static Pair parseSegmentName(String segmentName) { } } + public static List splitRangeByMergeInterval(long startTime, long endTime, List mergeInterval) { + List batchRange = new ArrayList<>(); + long indexTime = startTime; + while (indexTime < endTime) { + long curMaxInterval = Long.MIN_VALUE; + for (Long splitRange : mergeInterval) { + if (splitRange <= (endTime - indexTime)) { + curMaxInterval = Math.max(splitRange, curMaxInterval); + } + } + if (curMaxInterval == Long.MIN_VALUE) { + batchRange.add(new SegmentRange.TSRange(indexTime, endTime)); + break; + } + batchRange.add(new SegmentRange.TSRange(indexTime, indexTime + curMaxInterval)); + indexTime = indexTime + curMaxInterval; + } + return batchRange; + } + + public static List splitRangeByMonth(long startTime, long endTime) { + List result = new ArrayList<>(); + + long startOfNextMonth = TimeUtil.getNextMonthStart(startTime); + while (startOfNextMonth < endTime) { + result.add(new TSRange(startTime, startOfNextMonth)); + startTime = startOfNextMonth; + startOfNextMonth = TimeUtil.getNextMonthStart(startTime); + } + result.add(new TSRange(startTime, endTime)); + return result; + } + + /** + * Find overlapping segment TSRange in the specified time range + */ + public static List getOverlapsRange(Segments readySegments, Long startTime, Long endTime, boolean refreshOverlaps) { + List batchRange = new ArrayList<>(); + TSRange needRefreshTsRange = new TSRange(startTime, endTime); + for (CubeSegment readySegment : readySegments) { + TSRange tsRange = readySegment.getTSRange(); + if (refreshOverlaps && needRefreshTsRange.overlaps(tsRange)) { + batchRange.add(new TSRange(tsRange.startValue(), tsRange.endValue())); + } else if (!refreshOverlaps && needRefreshTsRange.contains(tsRange)) { + batchRange.add(new TSRange(tsRange.startValue(), tsRange.endValue())); + } + } + return batchRange; + } + + /** + * Get the segment TSRange that does not overlap within the specified time range + */ + public static List getNotOverlapsRange(Long startTime, Long endTime, List overlapsRange) { + List missingRanges = new ArrayList<>(); + overlapsRange.sort(Comparator.comparing(TSRange::startValue)); + if (overlapsRange.isEmpty()) { + // no overlapping ranges + missingRanges.add(new TSRange(startTime, endTime)); + } else { + // handle missing ranges preceding the first range + TSRange firstRange = overlapsRange.get(0); + if (startTime < firstRange.startValue()) { + missingRanges.add(new TSRange(startTime, firstRange.startValue())); + } + // Handle missing ranges between two adjacent ranges in a range list + for (int i = 0; i < overlapsRange.size() - 1; i++) { + TSRange currRange = overlapsRange.get(i); + TSRange nextRange = overlapsRange.get(i + 1); + if (currRange.endValue() < nextRange.startValue()) { + missingRanges.add(new TSRange(currRange.endValue(), nextRange.startValue())); + } + } + + // handle missing ranges after the last range + TSRange lastRange = overlapsRange.get(overlapsRange.size() - 1); + if (endTime > lastRange.endValue()) { + missingRanges.add(new TSRange(lastRange.endValue(), endTime)); + } + } + return missingRanges; + } + // ============================================================================ public KylinConfig getConfig() { diff --git a/core-cube/src/test/java/org/apache/kylin/cube/CubeSegmentsTest.java b/core-cube/src/test/java/org/apache/kylin/cube/CubeSegmentsTest.java index c4dcb596fab..cb36c5121f3 100644 --- a/core-cube/src/test/java/org/apache/kylin/cube/CubeSegmentsTest.java +++ b/core-cube/src/test/java/org/apache/kylin/cube/CubeSegmentsTest.java @@ -22,6 +22,9 @@ import static org.junit.Assert.fail; import java.io.IOException; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; import org.apache.kylin.common.util.LocalFileMetadataTestCase; import org.apache.kylin.metadata.model.PartitionDesc; @@ -156,6 +159,76 @@ public void testPartitioned() throws IOException { assertEquals(new TSRange(0L, 2000L), merge2.getSegRange()); } + @Test + public void testSplitRangeByMergeRange(){ + long startTime = 1672588800000L; // 2023-01-02 00:00:00 + long endTime = 1675094400000L; // 2023-01-31 00:00:00 + long endTime2 = 1673280000000L; // 2023-01-10 00:00:00 + + List mergeInternal = Arrays.asList(86400000L, 86400000L * 7, 86400000L * 28); + + List expected = Arrays.asList( + new SegmentRange.TSRange(1672588800000L, 1675008000000L), + new SegmentRange.TSRange(1675008000000L, 1675094400000L) + ); + List expected2 = Arrays.asList( + new SegmentRange.TSRange(1672588800000L, 1673193600000L), + new SegmentRange.TSRange(1673193600000L, 1673280000000L) + ); + + List actual = CubeSegment.splitRangeByMergeInterval(startTime, endTime, mergeInternal); + List actual2 = CubeSegment.splitRangeByMergeInterval(startTime, endTime2, mergeInternal); + + assertEquals(expected, actual); + assertEquals(expected2, actual2); + } + + @Test + public void testSplitRangeByMonth(){ + long startTime = 1667347200000L; // 2022-11-02 00:00:00 (GMT) + long endTime = 1675987200000L; // 2023-02-10 00:00:00 (GMT) + + List expected = Arrays.asList( + new SegmentRange.TSRange(1667347200000L, 1669852800000L), + new SegmentRange.TSRange(1669852800000L, 1672531200000L), + new SegmentRange.TSRange(1672531200000L, 1675209600000L), + new SegmentRange.TSRange(1675209600000L, 1675987200000L) + ); + + List actual = CubeSegment.splitRangeByMonth(startTime, endTime); + assertEquals(expected, actual); + + long startTime2 = 1667347200000L; // 2022-11-02 00:00:00 (GMT) + long endTime2 = 1669852800000L; // 2022-12-01 00:00:00 (GMT) + List expected2 = Collections.singletonList( + new TSRange(1667347200000L, 1669852800000L) + ); + + List actual2 = CubeSegment.splitRangeByMonth(startTime2, endTime2); + assertEquals(expected2, actual2); + } + + @Test + public void testGetNotOverlapsRange() { + Long startTime = 0L; + Long endTime = 100L; + List overlapsRange = Arrays.asList( + new TSRange(20L, 30L), + new TSRange(40L, 50L), + new TSRange(70L, 80L) + ); + + List expectedMissingRanges = Arrays.asList( + new TSRange(0L, 20L), + new TSRange(30L, 40L), + new TSRange(50L, 70L), + new TSRange(80L, 100L) + ); + + List missingRanges = CubeSegment.getNotOverlapsRange(startTime, endTime, overlapsRange); + assertEquals(expectedMissingRanges, missingRanges); + } + @Test public void testAllowGap() throws IOException { diff --git a/server-base/src/main/java/org/apache/kylin/rest/controller/CubeController.java b/server-base/src/main/java/org/apache/kylin/rest/controller/CubeController.java index 945bbc741ab..4ae409c6143 100644 --- a/server-base/src/main/java/org/apache/kylin/rest/controller/CubeController.java +++ b/server-base/src/main/java/org/apache/kylin/rest/controller/CubeController.java @@ -423,6 +423,24 @@ public JobInstance rebuild(@PathVariable String cubeName, @RequestBody JobBuildR req.getBuildType(), req.isForce() || req.isForceMergeEmptySegment(), req.getPriorityOffset()); } + + @RequestMapping(value = "/{cubeName}/batchRebuild", method = { RequestMethod.PUT }, produces = { "application/json" }) + @ResponseBody + public List batchBuild(@PathVariable String cubeName, @RequestBody JobBuildRequest req, + @RequestParam(defaultValue = "false") boolean refreshOverlaps) { + try { + String submitter = SecurityContextHolder.getContext().getAuthentication().getName(); + CubeInstance cube = jobService.getCubeManager().getCube(cubeName); + + checkBuildingSegment(cube); + return jobService.batchSubmitJob(cube, req.getStartTime(), req.getEndTime(), submitter, req.getPriorityOffset(), + JobTypeEnum.valueOf(req.getBuildType()), req.isForce(), refreshOverlaps); + } catch (Throwable e) { + logger.error(e.getLocalizedMessage(), e); + throw new InternalErrorException(e.getLocalizedMessage(), e); + } + } + /** * Build/Rebuild a cube segment by source offset */ diff --git a/server-base/src/main/java/org/apache/kylin/rest/service/JobService.java b/server-base/src/main/java/org/apache/kylin/rest/service/JobService.java index e0d08f91eca..d888477dcb8 100644 --- a/server-base/src/main/java/org/apache/kylin/rest/service/JobService.java +++ b/server-base/src/main/java/org/apache/kylin/rest/service/JobService.java @@ -20,6 +20,7 @@ import java.io.IOException; import java.util.ArrayList; +import java.util.Arrays; import java.util.Calendar; import java.util.Collections; import java.util.Date; @@ -229,6 +230,105 @@ public JobInstance submitJob(CubeInstance cube, TSRange tsRange, SegmentRange se return jobInstance; } + public List batchSubmitJob(CubeInstance cube, Long startTime, Long endTime, String submitter, Integer priorityOffset, + JobTypeEnum buildType, boolean force, boolean refreshOverlaps) throws IOException { + logger.info("batchSubmitJob, cube:{}, startTime:{}, endTime:{}, submitter:{}, priorityOffset:{}, buildType:{}, force:{}, refreshOverlaps:{}", + cube, startTime, endTime, submitter, priorityOffset, buildType, force, refreshOverlaps); + aclEvaluate.checkProjectOperationPermission(cube); + List jobInstances = new ArrayList<>(); + if (!cube.getDescriptor().getModel().getPartitionDesc().isPartitioned()) { + try { + jobInstances.add(submitJobInternal(cube, null, new SegmentRange(startTime, endTime), + null, null, buildType, force, submitter, priorityOffset)); + return jobInstances; + } catch (Exception e) { + logger.error("Job submission might failed, cube:{}, start:{}, end:{}", cube, startTime, endTime); + throw e; + } + } + + Segments segments = cube.getSegments(); + Segments readySegments = segments.getSegments(SegmentStatusEnum.READY); + + Map> jobsMap = new HashMap<>(); + + if (buildType == JobTypeEnum.BUILD) { + + jobsMap.put(JobTypeEnum.BUILD, getBatchBuildCubeRange(cube, startTime, endTime)); + } else if (buildType == JobTypeEnum.REFRESH) { + + jobsMap.put(JobTypeEnum.REFRESH, CubeSegment.getOverlapsRange(readySegments, startTime, endTime, refreshOverlaps)); + } else if (buildType == JobTypeEnum.BUILD_OR_REFRESH) { + List overlapsRange; + List notOverlapRange; + + if (refreshOverlaps) { + // 1. Find all existing ready segments within the time range + overlapsRange = CubeSegment.getOverlapsRange(readySegments, startTime, endTime, true); + // 2. Find all missing segments + notOverlapRange = CubeSegment.getNotOverlapsRange(startTime, endTime, overlapsRange); + } else { + List containsRange = CubeSegment.getOverlapsRange(readySegments, startTime, endTime, false); + overlapsRange = CubeSegment.getOverlapsRange(readySegments, startTime, endTime, true); + //1. fina all missing segments + notOverlapRange = CubeSegment.getNotOverlapsRange(startTime, endTime, overlapsRange); + //2. only refresh contains segments + overlapsRange = containsRange; + } + + //Divide the missing segments according to the natural month + List needBuildRange = new ArrayList<>(); + notOverlapRange.forEach(x -> needBuildRange.addAll(CubeSegment.splitRangeByMonth(x.startValue(), x.endValue()))); + //3. Cube build for missing ones, and refresh for existing ones + jobsMap.put(JobTypeEnum.BUILD, needBuildRange); + jobsMap.put(JobTypeEnum.REFRESH, overlapsRange); + } + Segments buildingSegments = segments.getBuildingSegments(); + + for (JobTypeEnum cubeBuildType : jobsMap.keySet()) { + // Exclude the range being built from the list of jobs ready to be built + List batchRange = jobsMap.get(cubeBuildType); + List invalidRange = buildingSegments.stream() + .flatMap(segment -> batchRange.stream().filter(segment.getSegRange()::contains)) + .collect(Collectors.toList()); + + batchRange.removeAll(invalidRange); + + logger.info("batch buildType: {} , batchRange:{}", cubeBuildType.name(), batchRange); + for (TSRange tsRange : batchRange) { + try { + jobInstances.add(submitJobInternal(cube, tsRange, null, null, null, + cubeBuildType, force, submitter, priorityOffset)); + } catch (IOException e) { + logger.error("Job submission might failed, cube:{}, start:{}, end:{}", cube, tsRange.start, tsRange.end); + throw e; + } + } + } + return jobInstances; + } + + + /** + * Divide the specified time range into segment TSRange + * @param cube + * @param startTime + * @param endTime + * @return + */ + private List getBatchBuildCubeRange(CubeInstance cube, Long startTime, Long endTime) { + List batchRange = new ArrayList<>(); + if (cube.getDescriptor().getConfig().isBatchBuildCubeByMonthEnabled()) { + batchRange.addAll(CubeSegment.splitRangeByMonth(startTime, endTime)); + } else { + List mergeInternal = Arrays.stream(cube.getDescriptor().getAutoMergeTimeRanges()).boxed().collect(Collectors.toList()); + // Add minimum time range, day level + mergeInternal.add(86400000L); + batchRange.addAll(CubeSegment.splitRangeByMergeInterval(startTime, endTime, mergeInternal)); + } + return batchRange; + } + public JobInstance submitJobInternal(CubeInstance cube, TSRange tsRange, SegmentRange segRange, // Map sourcePartitionOffsetStart, Map sourcePartitionOffsetEnd, // JobTypeEnum buildType, boolean force, String submitter, Integer priorityOffset) throws IOException { diff --git a/webapp/app/js/controllers/cubes.js b/webapp/app/js/controllers/cubes.js index c798c7bf6e5..3ccaec05208 100644 --- a/webapp/app/js/controllers/cubes.js +++ b/webapp/app/js/controllers/cubes.js @@ -505,6 +505,34 @@ KylinApp.controller('CubesCtrl', function ($scope, $q, $routeParams, $location, }; + $scope.startBatchRebuild = function (cube) { + + $scope.metaModel={ + model:cube.model + }; + //for partition cube build tip + if ($scope.metaModel.model.partition_desc.partition_date_column) { + $modal.open({ + templateUrl: 'jobBatchBuild.html', + controller: jobSubmitCtrl, + resolve: { + cube: function () { + return cube; + }, + metaModel:function(){ + return $scope.metaModel; + }, + buildType: function () { + return 'REFRESH'; + }, + scope:function(){ + return $scope; + } + } + }); + } + }; + $scope.startRefresh = function (cube) { $scope.metaModel={ @@ -842,10 +870,12 @@ var jobSubmitCtrl = function ($scope, $modalInstance, CubeService, MessageServic $scope.cubeList = CubeList; $scope.cube = cube; $scope.metaModel = metaModel; + $scope.refreshOverlaps = {value: 'FALSE'}; $scope.jobBuildRequest = { buildType: buildType, startTime: 0, - endTime: 0 + endTime: 0, + priorityOffset: (buildType === 'BUILD') ? 0 : -10000 }; $scope.message = ""; $scope.refreshType = 'normal'; @@ -915,6 +945,68 @@ var jobSubmitCtrl = function ($scope, $modalInstance, CubeService, MessageServic }); }; + // batch rebuild + $scope.batchRebuild = function (isForce) { + $scope.message = null; + if ($scope.jobBuildRequest.startTime >= $scope.jobBuildRequest.endTime) { + $scope.message = "WARNING: End time should be later than the start time."; + return; + } + $scope.jobBuildRequest.forceMergeEmptySegment = !!isForce; + loadingRequest.show(); + var refreshOverlapsBool = ($scope.refreshOverlaps.value === 'TRUE'); + var request = {...$scope.jobBuildRequest, refreshOverlaps: refreshOverlapsBool}; + CubeService.batchRebuildCube({cubeId: cube.name}, request, function (job) { + loadingRequest.hide(); + $modalInstance.dismiss('cancel'); + MessageBox.successNotify('batchRebuild job was submitted successfully'); + scope.refreshCube(cube).then(function(_cube){ + $scope.cubeList.cubes[$scope.cubeList.cubes.indexOf(cube)] = _cube; + }); + }, function (e) { + loadingRequest.hide(); + if (e.data && e.data.exception) { + var message = e.data.exception; + + if(message.indexOf("Empty cube segment found")!=-1){ + var _segment = message.substring(message.indexOf(":")+1,message.length-1); + SweetAlert.swal({ + title:'', + type:'info', + text: 'Empty cube segment found'+':'+_segment+', do you want to merge segments forcely ?', + showCancelButton: true, + confirmButtonColor: '#DD6B55', + closeOnConfirm: true + }, function (isConfirm) { + if (isConfirm) { + $scope.batchRebuild(true); + } + }); + return; + } + if(message.indexOf("Merging segments must not have gaps between")!=-1){ + SweetAlert.swal({ + title:'', + type:'info', + text: 'There ares gaps between segments, do you want to merge segments forcely ?', + showCancelButton: true, + confirmButtonColor: '#DD6B55', + closeOnConfirm: true + }, function (isConfirm) { + if (isConfirm) { + $scope.batchRebuild(true); + } + }); + return; + } + var msg = !!(message) ? message : 'Failed to take action.'; + SweetAlert.swal('Oops...', msg, 'error'); + } else { + SweetAlert.swal('Oops...', "Failed to take action.", 'error'); + } + }); + }; + // used by cube segment refresh $scope.segmentSelected = function (selectedSegment) { $scope.jobBuildRequest.startTime = 0; diff --git a/webapp/app/js/services/cubes.js b/webapp/app/js/services/cubes.js index 76d73b4a78d..a470b0586bd 100644 --- a/webapp/app/js/services/cubes.js +++ b/webapp/app/js/services/cubes.js @@ -48,6 +48,7 @@ KylinApp.factory('CubeService', ['$resource', function ($resource, config) { cost: {method: 'PUT', params: {action: 'cost'}, isArray: false}, rebuildLookUp: {method: 'PUT', params: {propName: 'segs', action: 'refresh_lookup'}, isArray: false}, rebuildCube: {method: 'PUT', params: {action: 'rebuild'}, isArray: false}, + batchRebuildCube: {method: 'PUT', params: {refreshOverlaps: '@refreshOverlaps', action: 'batchRebuild'}, isArray: true}, rebuildStreamingCube: {method: 'PUT', params: {action: 'build2'}, isArray: false}, disable: {method: 'PUT', params: {action: 'disable'}, isArray: false}, enable: {method: 'PUT', params: {action: 'enable'}, isArray: false}, diff --git a/webapp/app/partials/cubes/cubes.html b/webapp/app/partials/cubes/cubes.html index 54ed59da23e..6260b840e5a 100644 --- a/webapp/app/partials/cubes/cubes.html +++ b/webapp/app/partials/cubes/cubes.html @@ -98,6 +98,7 @@
  • Build
  • Refresh
  • Merge
  • +
  • BatchBuild
  • Disable
  • Enable
  • @@ -156,6 +157,7 @@
    +
    diff --git a/webapp/app/partials/jobs/job_batchBuild.html b/webapp/app/partials/jobs/job_batchBuild.html new file mode 100644 index 00000000000..152cbe18a28 --- /dev/null +++ b/webapp/app/partials/jobs/job_batchBuild.html @@ -0,0 +1,116 @@ + + + + diff --git a/webapp/app/partials/models/models.html b/webapp/app/partials/models/models.html index 3f75ff816ee..26bc4d8c343 100644 --- a/webapp/app/partials/models/models.html +++ b/webapp/app/partials/models/models.html @@ -59,6 +59,7 @@
    +