diff --git a/api/http/handler/schedules/schedule_create.go b/api/http/handler/schedules/schedule_create.go
index 6a7b0deb0..4d8fbd740 100644
--- a/api/http/handler/schedules/schedule_create.go
+++ b/api/http/handler/schedules/schedule_create.go
@@ -32,32 +32,32 @@ type scheduleFromFileContentPayload struct {
func (payload *scheduleFromFilePayload) Validate(r *http.Request) error {
name, err := request.RetrieveMultiPartFormValue(r, "Name", false)
if err != nil {
- return err
+ return errors.New("Invalid name")
}
payload.Name = name
image, err := request.RetrieveMultiPartFormValue(r, "Image", false)
if err != nil {
- return err
+ return errors.New("Invalid image")
}
payload.Image = image
- cronExpression, err := request.RetrieveMultiPartFormValue(r, "Schedule", false)
+ cronExpression, err := request.RetrieveMultiPartFormValue(r, "CronExpression", false)
if err != nil {
- return err
+ return errors.New("Invalid cron expression")
}
payload.CronExpression = cronExpression
var endpoints []portainer.EndpointID
err = request.RetrieveMultiPartFormJSONValue(r, "Endpoints", &endpoints, false)
if err != nil {
- return err
+ return errors.New("Invalid endpoints")
}
payload.Endpoints = endpoints
- file, _, err := request.RetrieveMultiPartFormFile(r, "File")
+ file, _, err := request.RetrieveMultiPartFormFile(r, "file")
if err != nil {
- return portainer.Error("Invalid Script file. Ensure that the file is uploaded correctly")
+ return portainer.Error("Invalid script file. Ensure that the file is uploaded correctly")
}
payload.File = file
diff --git a/app/constants.js b/app/constants.js
index b20d44649..0142d4c18 100644
--- a/app/constants.js
+++ b/app/constants.js
@@ -6,6 +6,7 @@ angular.module('portainer')
.constant('API_ENDPOINT_MOTD', 'api/motd')
.constant('API_ENDPOINT_REGISTRIES', 'api/registries')
.constant('API_ENDPOINT_RESOURCE_CONTROLS', 'api/resource_controls')
+.constant('API_ENDPOINT_SCHEDULES', 'api/schedules')
.constant('API_ENDPOINT_SETTINGS', 'api/settings')
.constant('API_ENDPOINT_STACKS', 'api/stacks')
.constant('API_ENDPOINT_STATUS', 'api/status')
diff --git a/app/portainer/__module.js b/app/portainer/__module.js
index d04e12b6e..e6ab9f5d2 100644
--- a/app/portainer/__module.js
+++ b/app/portainer/__module.js
@@ -242,6 +242,39 @@ angular.module('portainer.app', [])
}
};
+ var schedules = {
+ name: 'portainer.schedules',
+ url: '/schedules',
+ views: {
+ 'content@': {
+ templateUrl: 'app/portainer/views/schedules/schedules.html',
+ controller: 'SchedulesController'
+ }
+ }
+ };
+
+ var schedule = {
+ name: 'portainer.schedules.schedule',
+ url: '/:id',
+ views: {
+ 'content@': {
+ templateUrl: 'app/portainer/views/schedules/edit/schedule.html',
+ controller: 'ScheduleController'
+ }
+ }
+ };
+
+ var scheduleCreation = {
+ name: 'portainer.schedules.new',
+ url: '/new',
+ views: {
+ 'content@': {
+ templateUrl: 'app/portainer/views/schedules/create/createschedule.html',
+ controller: 'CreateScheduleController'
+ }
+ }
+ };
+
var settings = {
name: 'portainer.settings',
url: '/settings',
@@ -428,6 +461,9 @@ angular.module('portainer.app', [])
$stateRegistryProvider.register(registry);
$stateRegistryProvider.register(registryAccess);
$stateRegistryProvider.register(registryCreation);
+ $stateRegistryProvider.register(schedules);
+ $stateRegistryProvider.register(schedule);
+ $stateRegistryProvider.register(scheduleCreation);
$stateRegistryProvider.register(settings);
$stateRegistryProvider.register(settingsAuthentication);
$stateRegistryProvider.register(stacks);
diff --git a/app/portainer/components/datatables/schedules-datatable/schedulesDatatable.html b/app/portainer/components/datatables/schedules-datatable/schedulesDatatable.html
new file mode 100644
index 000000000..adccfff95
--- /dev/null
+++ b/app/portainer/components/datatables/schedules-datatable/schedulesDatatable.html
@@ -0,0 +1,101 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/portainer/components/datatables/schedules-datatable/schedulesDatatable.js b/app/portainer/components/datatables/schedules-datatable/schedulesDatatable.js
new file mode 100644
index 000000000..13a9bd34f
--- /dev/null
+++ b/app/portainer/components/datatables/schedules-datatable/schedulesDatatable.js
@@ -0,0 +1,13 @@
+angular.module('portainer.app').component('schedulesDatatable', {
+ templateUrl: 'app/portainer/components/datatables/schedules-datatable/schedulesDatatable.html',
+ controller: 'SchedulesDatatableController',
+ bindings: {
+ titleText: '@',
+ titleIcon: '@',
+ dataset: '<',
+ tableKey: '@',
+ orderBy: '@',
+ reverseOrder: '<',
+ removeAction: '<'
+ }
+});
diff --git a/app/portainer/components/datatables/schedules-datatable/schedulesDatatableController.js b/app/portainer/components/datatables/schedules-datatable/schedulesDatatableController.js
new file mode 100644
index 000000000..bac777070
--- /dev/null
+++ b/app/portainer/components/datatables/schedules-datatable/schedulesDatatableController.js
@@ -0,0 +1,58 @@
+angular.module('portainer.app')
+.controller('SchedulesDatatableController', ['PaginationService', 'DatatableService',
+function (PaginationService, DatatableService) {
+
+ this.state = {
+ selectAll: false,
+ orderBy: this.orderBy,
+ paginatedItemLimit: PaginationService.getPaginationLimit(this.tableKey),
+ displayTextFilter: false,
+ selectedItemCount: 0,
+ selectedItems: []
+ };
+
+ this.changeOrderBy = function(orderField) {
+ this.state.reverseOrder = this.state.orderBy === orderField ? !this.state.reverseOrder : false;
+ this.state.orderBy = orderField;
+ DatatableService.setDataTableOrder(this.tableKey, orderField, this.state.reverseOrder);
+ };
+
+ this.selectItem = function(item) {
+ if (item.Checked) {
+ this.state.selectedItemCount++;
+ this.state.selectedItems.push(item);
+ } else {
+ this.state.selectedItems.splice(this.state.selectedItems.indexOf(item), 1);
+ this.state.selectedItemCount--;
+ }
+ };
+
+ this.selectAll = function() {
+ for (var i = 0; i < this.state.filteredDataSet.length; i++) {
+ var item = this.state.filteredDataSet[i];
+ if (item.JobType ===1 && item.Checked !== this.state.selectAll) {
+ item.Checked = this.state.selectAll;
+ this.selectItem(item);
+ }
+ }
+ };
+
+ this.changePaginationLimit = function() {
+ PaginationService.setPaginationLimit(this.tableKey, this.state.paginatedItemLimit);
+ };
+
+ this.$onInit = function() {
+ setDefaults(this);
+
+ var storedOrder = DatatableService.getDataTableOrder(this.tableKey);
+ if (storedOrder !== null) {
+ this.state.reverseOrder = storedOrder.reverse;
+ this.state.orderBy = storedOrder.orderBy;
+ }
+ };
+
+ function setDefaults(ctrl) {
+ ctrl.showTextFilter = ctrl.showTextFilter ? ctrl.showTextFilter : false;
+ ctrl.state.reverseOrder = ctrl.reverseOrder ? ctrl.reverseOrder : false;
+ }
+}]);
diff --git a/app/portainer/components/forms/schedule-form/schedule-form.js b/app/portainer/components/forms/schedule-form/schedule-form.js
new file mode 100644
index 000000000..27b14790f
--- /dev/null
+++ b/app/portainer/components/forms/schedule-form/schedule-form.js
@@ -0,0 +1,35 @@
+angular.module('portainer.app').component('scheduleForm', {
+ templateUrl: 'app/portainer/components/forms/schedule-form/scheduleForm.html',
+ controller: function() {
+ var ctrl = this;
+
+ ctrl.state = {
+ formValidationError: ''
+ };
+
+ this.action = function() {
+ ctrl.state.formValidationError = '';
+
+ if (ctrl.model.Job.Method === 'editor' && ctrl.model.Job.FileContent === '') {
+ ctrl.state.formValidationError = 'Script file content must not be empty';
+ return;
+ }
+
+ ctrl.formAction();
+ };
+
+ this.editorUpdate = function(cm) {
+ ctrl.model.Job.FileContent = cm.getValue();
+ };
+ },
+ bindings: {
+ model: '=',
+ endpoints: '<',
+ groups: '<',
+ addLabelAction: '<',
+ removeLabelAction: '<',
+ formAction: '<',
+ formActionLabel: '@',
+ actionInProgress: '<'
+ }
+});
diff --git a/app/portainer/components/forms/schedule-form/scheduleForm.html b/app/portainer/components/forms/schedule-form/scheduleForm.html
new file mode 100644
index 000000000..1f287ece1
--- /dev/null
+++ b/app/portainer/components/forms/schedule-form/scheduleForm.html
@@ -0,0 +1,165 @@
+
diff --git a/app/portainer/components/multi-endpoint-selector/multi-endpoint-selector.js b/app/portainer/components/multi-endpoint-selector/multi-endpoint-selector.js
new file mode 100644
index 000000000..ecec019c4
--- /dev/null
+++ b/app/portainer/components/multi-endpoint-selector/multi-endpoint-selector.js
@@ -0,0 +1,9 @@
+angular.module('portainer.app').component('multiEndpointSelector', {
+ templateUrl: 'app/portainer/components/multi-endpoint-selector/multiEndpointSelector.html',
+ controller: 'MultiEndpointSelectorController',
+ bindings: {
+ 'model': '=',
+ 'endpoints': '<',
+ 'groups': '<'
+ }
+});
diff --git a/app/portainer/components/multi-endpoint-selector/multiEndpointSelector.html b/app/portainer/components/multi-endpoint-selector/multiEndpointSelector.html
new file mode 100644
index 000000000..5acf2149d
--- /dev/null
+++ b/app/portainer/components/multi-endpoint-selector/multiEndpointSelector.html
@@ -0,0 +1,14 @@
+
+
+
+ {{ $item.Name }}
+ ({{ $item.Tags | arraytostr }})
+
+
+
+
+ {{ endpoint.Name }}
+ ({{ endpoint.Tags | arraytostr }})
+
+
+
diff --git a/app/portainer/components/multi-endpoint-selector/multiEndpointSelectorController.js b/app/portainer/components/multi-endpoint-selector/multiEndpointSelectorController.js
new file mode 100644
index 000000000..418682771
--- /dev/null
+++ b/app/portainer/components/multi-endpoint-selector/multiEndpointSelectorController.js
@@ -0,0 +1,35 @@
+angular.module('portainer.app')
+.controller('MultiEndpointSelectorController', function () {
+ var ctrl = this;
+
+ this.sortGroups = function(groups) {
+ return _.sortBy(groups, ['name']);
+ };
+
+ this.groupEndpoints = function(endpoint) {
+ for (var i = 0; i < ctrl.availableGroups.length; i++) {
+ var group = ctrl.availableGroups[i];
+
+ if (endpoint.GroupId === group.Id) {
+ return group.Name;
+ }
+ }
+ };
+
+ this.$onInit = function() {
+ this.availableGroups = filterEmptyGroups(this.groups, this.endpoints);
+ };
+
+ function filterEmptyGroups(groups, endpoints) {
+ return groups.filter(function f(group) {
+ for (var i = 0; i < endpoints.length; i++) {
+
+ var endpoint = endpoints[i];
+ if (endpoint.GroupId === group.Id) {
+ return true;
+ }
+ }
+ return false;
+ });
+ }
+});
diff --git a/app/portainer/models/schedule.js b/app/portainer/models/schedule.js
new file mode 100644
index 000000000..a3bb7cd45
--- /dev/null
+++ b/app/portainer/models/schedule.js
@@ -0,0 +1,47 @@
+function ScheduleDefaultModel() {
+ this.Name = '';
+ this.CronExpression = '';
+ this.JobType = 1;
+ this.Job = new ScriptExecutionDefaultJobModel();
+}
+
+function ScriptExecutionDefaultJobModel() {
+ this.Image = '';
+ this.Endpoints = [];
+ this.FileContent = '';
+ this.File = null;
+ this.Method = 'editor';
+}
+
+function ScheduleModel(data) {
+ this.Id = data.Id;
+ this.Name = data.Name;
+ this.JobType = data.JobType;
+ this.CronExpression = data.CronExpression;
+ this.Created = data.Created;
+ if (this.JobType === 1) {
+ this.Job = new ScriptExecutionJobModel(data.ScriptExecutionJob);
+ }
+}
+
+function ScriptExecutionJobModel(data) {
+ this.Image = data.Image;
+ this.Endpoints = data.Endpoints;
+}
+
+function ScheduleCreateRequest(model) {
+ this.Name = model.Name;
+ this.CronExpression = model.CronExpression;
+ this.Image = model.Job.Image;
+ this.Endpoints = model.Job.Endpoints;
+ this.FileContent = model.Job.FileContent;
+ this.File = model.Job.File;
+}
+
+function ScheduleUpdateRequest(model) {
+ this.id = model.Id;
+ this.Name = model.Name;
+ this.CronExpression = model.CronExpression;
+ this.Image = model.Job.Image;
+ this.Endpoints = model.Job.Endpoints;
+}
diff --git a/app/portainer/rest/schedule.js b/app/portainer/rest/schedule.js
new file mode 100644
index 000000000..8bd2fa624
--- /dev/null
+++ b/app/portainer/rest/schedule.js
@@ -0,0 +1,12 @@
+angular.module('portainer.app')
+.factory('Schedules', ['$resource', 'API_ENDPOINT_SCHEDULES',
+function SchedulesFactory($resource, API_ENDPOINT_SCHEDULES) {
+ 'use strict';
+ return $resource(API_ENDPOINT_SCHEDULES + '/:id', {}, {
+ create: { method: 'POST' },
+ query: { method: 'GET', isArray: true },
+ get: { method: 'GET', params: { id: '@id' } },
+ update: { method: 'PUT', params: { id: '@id' } },
+ remove: { method: 'DELETE', params: { id: '@id'} }
+ });
+}]);
diff --git a/app/portainer/services/api/scheduleService.js b/app/portainer/services/api/scheduleService.js
new file mode 100644
index 000000000..f7723ef09
--- /dev/null
+++ b/app/portainer/services/api/scheduleService.js
@@ -0,0 +1,59 @@
+angular.module('portainer.app')
+.factory('ScheduleService', ['$q', 'Schedules', 'FileUploadService',
+function ScheduleService($q, Schedules, FileUploadService) {
+ 'use strict';
+ var service = {};
+
+ service.schedule = function(scheduleId) {
+ var deferred = $q.defer();
+
+ Schedules.get({ id: scheduleId }).$promise
+ .then(function success(data) {
+ var schedule = new ScheduleModel(data);
+ deferred.resolve(schedule);
+ })
+ .catch(function error(err) {
+ deferred.reject({ msg: 'Unable to retrieve schedule', err: err });
+ });
+
+ return deferred.promise;
+ };
+
+ service.schedules = function() {
+ var deferred = $q.defer();
+
+ Schedules.query().$promise
+ .then(function success(data) {
+ var schedules = data.map(function (item) {
+ return new ScheduleModel(item);
+ });
+ deferred.resolve(schedules);
+ })
+ .catch(function error(err) {
+ deferred.reject({ msg: 'Unable to retrieve schedules', err: err });
+ });
+
+ return deferred.promise;
+ };
+
+ service.createScheduleFromFileContent = function(model) {
+ var payload = new ScheduleCreateRequest(model);
+ return Schedules.create({ method: 'string' }, payload).$promise;
+ };
+
+ service.createScheduleFromFileUpload = function(model) {
+ var payload = new ScheduleCreateRequest(model);
+ return FileUploadService.createSchedule(payload);
+ };
+
+ service.updateSchedule = function(model) {
+ var payload = new ScheduleUpdateRequest(model);
+ return Schedules.update(payload).$promise;
+ };
+
+ service.deleteSchedule = function(scheduleId) {
+ return Schedules.remove({ id: scheduleId }).$promise;
+ };
+
+ return service;
+}]);
diff --git a/app/portainer/services/fileUpload.js b/app/portainer/services/fileUpload.js
index 2d43cc44e..15b9ffbd3 100644
--- a/app/portainer/services/fileUpload.js
+++ b/app/portainer/services/fileUpload.js
@@ -39,6 +39,19 @@ angular.module('portainer.app')
});
};
+ service.createSchedule = function(payload) {
+ return Upload.upload({
+ url: 'api/schedules?method=file',
+ data: {
+ file: payload.File,
+ Name: payload.Name,
+ CronExpression: payload.CronExpression,
+ Image: payload.Image,
+ Endpoints: Upload.json(payload.Endpoints)
+ }
+ });
+ };
+
service.createSwarmStack = function(stackName, swarmId, file, env, endpointId) {
return Upload.upload({
url: 'api/stacks?method=file&type=1&endpointId=' + endpointId,
diff --git a/app/portainer/views/schedules/create/createScheduleController.js b/app/portainer/views/schedules/create/createScheduleController.js
new file mode 100644
index 000000000..03211d804
--- /dev/null
+++ b/app/portainer/views/schedules/create/createScheduleController.js
@@ -0,0 +1,52 @@
+angular.module('portainer.app')
+.controller('CreateScheduleController', ['$q', '$scope', '$state', 'Notifications', 'EndpointService', 'GroupService', 'ScheduleService',
+function ($q, $scope, $state, Notifications, EndpointService, GroupService, ScheduleService) {
+
+ $scope.state = {
+ actionInProgress: false
+ };
+
+ $scope.create = create;
+
+ function create() {
+ var model = $scope.model;
+
+ $scope.state.actionInProgress = true;
+ createSchedule(model)
+ .then(function success() {
+ Notifications.success('Schedule successfully created');
+ $state.go('portainer.schedules', {}, {reload: true});
+ })
+ .catch(function error(err) {
+ Notifications.error('Failure', err, 'Unable to create schedule');
+ })
+ .finally(function final() {
+ $scope.state.actionInProgress = false;
+ });
+ }
+
+ function createSchedule(model) {
+ if (model.Job.Method === 'editor') {
+ return ScheduleService.createScheduleFromFileContent(model);
+ }
+ return ScheduleService.createScheduleFromFileUpload(model);
+ }
+
+ function initView() {
+ $scope.model = new ScheduleDefaultModel();
+
+ $q.all({
+ endpoints: EndpointService.endpoints(),
+ groups: GroupService.groups()
+ })
+ .then(function success(data) {
+ $scope.endpoints = data.endpoints;
+ $scope.groups = data.groups;
+ })
+ .catch(function error(err) {
+ Notifications.error('Failure', err, 'Unable to retrieve endpoint list');
+ });
+ }
+
+ initView();
+}]);
diff --git a/app/portainer/views/schedules/create/createschedule.html b/app/portainer/views/schedules/create/createschedule.html
new file mode 100644
index 000000000..801cc41ca
--- /dev/null
+++ b/app/portainer/views/schedules/create/createschedule.html
@@ -0,0 +1,23 @@
+
+
+
+ Schedules > Add schedule
+
+
+
+
diff --git a/app/portainer/views/schedules/edit/schedule.html b/app/portainer/views/schedules/edit/schedule.html
new file mode 100644
index 000000000..f049459d6
--- /dev/null
+++ b/app/portainer/views/schedules/edit/schedule.html
@@ -0,0 +1,27 @@
+
+
+
+
+
+
+
+ Schedules > {{ ::schedule.Name }}
+
+
+
+
diff --git a/app/portainer/views/schedules/edit/scheduleController.js b/app/portainer/views/schedules/edit/scheduleController.js
new file mode 100644
index 000000000..628f2d5c3
--- /dev/null
+++ b/app/portainer/views/schedules/edit/scheduleController.js
@@ -0,0 +1,47 @@
+angular.module('portainer.app')
+.controller('ScheduleController', ['$q', '$scope', '$transition$', '$state', 'Notifications', 'EndpointService', 'GroupService', 'ScheduleService',
+function ($q, $scope, $transition$, $state, Notifications, EndpointService, GroupService, ScheduleService) {
+
+ $scope.state = {
+ actionInProgress: false
+ };
+
+ $scope.update = update;
+
+ function update() {
+ var model = $scope.schedule;
+
+ $scope.state.actionInProgress = true;
+ ScheduleService.updateSchedule(model)
+ .then(function success() {
+ Notifications.success('Schedule successfully updated');
+ $state.go('portainer.schedules', {}, {reload: true});
+ })
+ .catch(function error(err) {
+ Notifications.error('Failure', err, 'Unable to update schedule');
+ })
+ .finally(function final() {
+ $scope.state.actionInProgress = false;
+ });
+ }
+
+ function initView() {
+ var id = $transition$.params().id;
+
+ $q.all({
+ schedule: ScheduleService.schedule(id),
+ endpoints: EndpointService.endpoints(),
+ groups: GroupService.groups()
+ })
+ .then(function success(data) {
+ $scope.schedule = data.schedule;
+ $scope.endpoints = data.endpoints;
+ $scope.groups = data.groups;
+ })
+ .catch(function error(err) {
+ Notifications.error('Failure', err, 'Unable to retrieve endpoint list');
+ });
+ }
+
+ initView();
+}]);
diff --git a/app/portainer/views/schedules/schedules.html b/app/portainer/views/schedules/schedules.html
new file mode 100644
index 000000000..507640db8
--- /dev/null
+++ b/app/portainer/views/schedules/schedules.html
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+ Schedules
+
+
+
diff --git a/app/portainer/views/schedules/schedulesController.js b/app/portainer/views/schedules/schedulesController.js
new file mode 100644
index 000000000..2a8632613
--- /dev/null
+++ b/app/portainer/views/schedules/schedulesController.js
@@ -0,0 +1,50 @@
+angular.module('portainer.app')
+.controller('SchedulesController', ['$scope', '$state', 'Notifications', 'ModalService', 'ScheduleService',
+function ($scope, $state, Notifications, ModalService, ScheduleService) {
+
+ $scope.removeAction = removeAction;
+
+ function removeAction(selectedItems) {
+ ModalService.confirmDeletion(
+ 'Do you want to remove the selected schedule(s) ?',
+ function onConfirm(confirmed) {
+ if(!confirmed) { return; }
+ deleteSelectedSchedules(selectedItems);
+ }
+ );
+ }
+
+ function deleteSelectedSchedules(schedules) {
+ var actionCount = schedules.length;
+ angular.forEach(schedules, function (schedule) {
+ ScheduleService.deleteSchedule(schedule.Id)
+ .then(function success() {
+ Notifications.success('Schedule successfully removed', schedule.Name);
+ var index = $scope.schedules.indexOf(schedule);
+ $scope.schedules.splice(index, 1);
+ })
+ .catch(function error(err) {
+ Notifications.error('Failure', err, 'Unable to remove schedule ' + schedule.Name);
+ })
+ .finally(function final() {
+ --actionCount;
+ if (actionCount === 0) {
+ $state.reload();
+ }
+ });
+ });
+ }
+
+ function initView() {
+ ScheduleService.schedules()
+ .then(function success(data) {
+ $scope.schedules = data;
+ })
+ .catch(function error(err) {
+ Notifications.error('Failure', err, 'Unable to retrieve schedules');
+ $scope.schedules = [];
+ });
+ }
+
+ initView();
+}]);
diff --git a/app/portainer/views/sidebar/sidebar.html b/app/portainer/views/sidebar/sidebar.html
index af042c575..031cb586f 100644
--- a/app/portainer/views/sidebar/sidebar.html
+++ b/app/portainer/views/sidebar/sidebar.html
@@ -12,6 +12,9 @@
+