Merge branch 'develop' into webpack

pull/2670/head
Chaim Lev-Ari 2018-11-08 12:44:35 +02:00
commit dd571a792f
34 changed files with 970 additions and 43 deletions

View File

@ -3,6 +3,7 @@ package main // import "github.com/portainer/portainer"
import (
"encoding/json"
"strings"
"time"
"github.com/portainer/portainer"
"github.com/portainer/portainer/bolt"
@ -136,6 +137,7 @@ func loadSnapshotSystemSchedule(jobScheduler portainer.JobScheduler, snapshotter
CronExpression: "@every " + *flags.SnapshotInterval,
JobType: portainer.SnapshotJobType,
SnapshotJob: snapshotJob,
Created: time.Now().Unix(),
}
snapshotJobContext := cron.NewSnapshotJobContext(endpointService, snapshotter)
@ -173,6 +175,7 @@ func loadEndpointSyncSystemSchedule(jobScheduler portainer.JobScheduler, schedul
CronExpression: "@every " + *flags.SyncInterval,
JobType: portainer.EndpointSyncJobType,
EndpointSyncJob: endpointSyncJob,
Created: time.Now().Unix(),
}
endpointSyncJobContext := cron.NewEndpointSyncJobContext(endpointService, *flags.ExternalEndpoints)
@ -194,12 +197,14 @@ func loadSchedulesFromDatabase(jobScheduler portainer.JobScheduler, jobService p
for _, schedule := range schedules {
jobContext := cron.NewScriptExecutionJobContext(jobService, endpointService, fileService)
jobRunner := cron.NewScriptExecutionJobRunner(schedule.ScriptExecutionJob, jobContext)
if schedule.JobType == portainer.ScriptExecutionJobType {
jobContext := cron.NewScriptExecutionJobContext(jobService, endpointService, fileService)
jobRunner := cron.NewScriptExecutionJobRunner(schedule.ScriptExecutionJob, jobContext)
err = jobScheduler.CreateSchedule(&schedule, jobRunner)
if err != nil {
return err
err = jobScheduler.CreateSchedule(&schedule, jobRunner)
if err != nil {
return err
}
}
}

View File

@ -5,7 +5,6 @@ import (
"encoding/json"
"encoding/pem"
"io/ioutil"
"strconv"
"github.com/portainer/portainer"
@ -322,16 +321,15 @@ func (service *Service) getContentFromPEMFile(filePath string) ([]byte, error) {
return block.Bytes, nil
}
// GetScheduleFolder returns the absolute path on the FS for a schedule based
// GetScheduleFolder returns the absolute path on the filesystem for a schedule based
// on its identifier.
func (service *Service) GetScheduleFolder(scheduleIdentifier portainer.ScheduleID) string {
return path.Join(service.fileStorePath, ScheduleStorePath, strconv.Itoa(int(scheduleIdentifier)))
func (service *Service) GetScheduleFolder(identifier string) string {
return path.Join(service.fileStorePath, ScheduleStorePath, identifier)
}
// StoreScheduledJobFileFromBytes creates a subfolder in the ScheduleStorePath and stores a new file from bytes.
// It returns the path to the folder where the file is stored.
func (service *Service) StoreScheduledJobFileFromBytes(scheduleIdentifier portainer.ScheduleID, data []byte) (string, error) {
identifier := strconv.Itoa(int(scheduleIdentifier))
func (service *Service) StoreScheduledJobFileFromBytes(identifier string, data []byte) (string, error) {
scheduleStorePath := path.Join(ScheduleStorePath, identifier)
err := service.createDirectoryInStore(scheduleStorePath)
if err != nil {

View File

@ -35,6 +35,7 @@ func NewHandler(bouncer *security.RequestBouncer) *Handler {
bouncer.AdministratorAccess(httperror.LoggerHandler(h.scheduleUpdate))).Methods(http.MethodPut)
h.Handle("/schedules/{id}",
bouncer.AdministratorAccess(httperror.LoggerHandler(h.scheduleDelete))).Methods(http.MethodDelete)
h.Handle("/schedules/{id}/file",
bouncer.AdministratorAccess(httperror.LoggerHandler(h.scheduleFile))).Methods(http.MethodGet)
return h
}

View File

@ -3,6 +3,8 @@ package schedules
import (
"errors"
"net/http"
"strconv"
"time"
"github.com/asaskevich/govalidator"
httperror "github.com/portainer/libhttp/error"
@ -31,32 +33,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
@ -137,7 +139,7 @@ func (handler *Handler) createScheduleFromFile(w http.ResponseWriter, r *http.Re
func (handler *Handler) createSchedule(name, image, cronExpression string, endpoints []portainer.EndpointID, file []byte) (*portainer.Schedule, error) {
scheduleIdentifier := portainer.ScheduleID(handler.ScheduleService.GetNextIdentifier())
scriptPath, err := handler.FileService.StoreScheduledJobFileFromBytes(scheduleIdentifier, file)
scriptPath, err := handler.FileService.StoreScheduledJobFileFromBytes(strconv.Itoa(int(scheduleIdentifier)), file)
if err != nil {
return nil, err
}
@ -155,6 +157,7 @@ func (handler *Handler) createSchedule(name, image, cronExpression string, endpo
CronExpression: cronExpression,
JobType: portainer.ScriptExecutionJobType,
ScriptExecutionJob: job,
Created: time.Now().Unix(),
}
jobContext := cron.NewScriptExecutionJobContext(handler.JobService, handler.EndpointService, handler.FileService)

View File

@ -3,6 +3,7 @@ package schedules
import (
"errors"
"net/http"
"strconv"
httperror "github.com/portainer/libhttp/error"
"github.com/portainer/libhttp/request"
@ -27,7 +28,7 @@ func (handler *Handler) scheduleDelete(w http.ResponseWriter, r *http.Request) *
return &httperror.HandlerError{http.StatusBadRequest, "Cannot remove system schedules", errors.New("Cannot remove system schedule")}
}
scheduleFolder := handler.FileService.GetScheduleFolder(portainer.ScheduleID(scheduleID))
scheduleFolder := handler.FileService.GetScheduleFolder(strconv.Itoa(scheduleID))
err = handler.FileService.RemoveDirectory(scheduleFolder)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to remove the files associated to the schedule on the filesystem", err}

View File

@ -0,0 +1,41 @@
package schedules
import (
"errors"
"net/http"
httperror "github.com/portainer/libhttp/error"
"github.com/portainer/libhttp/request"
"github.com/portainer/libhttp/response"
"github.com/portainer/portainer"
)
type scheduleFileResponse struct {
ScheduleFileContent string `json:"ScheduleFileContent"`
}
// GET request on /api/schedules/:id/file
func (handler *Handler) scheduleFile(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
scheduleID, err := request.RetrieveNumericRouteVariableValue(r, "id")
if err != nil {
return &httperror.HandlerError{http.StatusBadRequest, "Invalid schedule identifier route variable", err}
}
schedule, err := handler.ScheduleService.Schedule(portainer.ScheduleID(scheduleID))
if err == portainer.ErrObjectNotFound {
return &httperror.HandlerError{http.StatusNotFound, "Unable to find a schedule with the specified identifier inside the database", err}
} else if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a schedule with the specified identifier inside the database", err}
}
if schedule.JobType != portainer.ScriptExecutionJobType {
return &httperror.HandlerError{http.StatusBadRequest, "Unable to retrieve script file", errors.New("This type of schedule do not have any associated script file")}
}
scheduleFileContent, err := handler.FileService.GetFileContent(schedule.ScriptExecutionJob.ScriptPath)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve schedule script file from disk", err}
}
return response.JSON(w, &scheduleFileResponse{ScheduleFileContent: string(scheduleFileContent)})
}

View File

@ -2,6 +2,7 @@ package schedules
import (
"net/http"
"strconv"
httperror "github.com/portainer/libhttp/error"
"github.com/portainer/libhttp/request"
@ -15,6 +16,7 @@ type scheduleUpdatePayload struct {
Image *string
CronExpression *string
Endpoints []portainer.EndpointID
FileContent *string
}
func (payload *scheduleUpdatePayload) Validate(r *http.Request) error {
@ -41,8 +43,16 @@ func (handler *Handler) scheduleUpdate(w http.ResponseWriter, r *http.Request) *
}
updateJobSchedule := updateSchedule(schedule, &payload)
if updateJobSchedule {
if payload.FileContent != nil {
_, err := handler.FileService.StoreScheduledJobFileFromBytes(strconv.Itoa(scheduleID), []byte(*payload.FileContent))
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist script file changes on the filesystem", err}
}
updateJobSchedule = true
}
if updateJobSchedule {
jobContext := cron.NewScriptExecutionJobContext(handler.JobService, handler.EndpointService, handler.FileService)
jobRunner := cron.NewScriptExecutionJobRunner(schedule.ScriptExecutionJob, jobContext)
err := handler.JobScheduler.UpdateSchedule(schedule, jobRunner)

View File

@ -251,6 +251,7 @@ type (
ID ScheduleID `json:"Id"`
Name string
CronExpression string
Created int64
JobType JobType
ScriptExecutionJob *ScriptExecutionJob
SnapshotJob *SnapshotJob
@ -653,8 +654,8 @@ type (
LoadKeyPair() ([]byte, []byte, error)
WriteJSONToFile(path string, content interface{}) error
FileExists(path string) (bool, error)
StoreScheduledJobFileFromBytes(scheduleIdentifier ScheduleID, data []byte) (string, error)
GetScheduleFolder(scheduleIdentifier ScheduleID) string
StoreScheduledJobFileFromBytes(identifier string, data []byte) (string, error)
GetScheduleFolder(identifier string) string
}
// GitService represents a service for managing Git

View File

@ -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')

View File

@ -109,7 +109,7 @@ angular.module('portainer.docker')
return $q.when();
}
ContainerService.prune({ label: ['io.portainer.job.endpoint'] }).then(function success() {
Notifications.success('Success', 'Job hisotry cleared');
Notifications.success('Success', 'Job history cleared');
$state.reload();
}).catch(function error(err) {
Notifications.error('Failure', err.message, 'Unable to clear job history');

View File

@ -6,7 +6,7 @@ angular.module('portainer.docker')
var helper = {};
helper.commandStringToArray = function(command) {
return splitargs(command);
return splitargs(command, undefined, true);
};
helper.commandArrayToString = function(array) {

View File

@ -66,9 +66,17 @@ export function ContainerViewModel(data) {
}
export function ContainerStatsViewModel(data) {
this.Date = data.read;
this.MemoryUsage = data.memory_stats.usage - data.memory_stats.stats.cache;
this.MemoryCache = data.memory_stats.stats.cache;
this.read = data.read;
this.preread = data.preread;
if(data.memory_stats.privateworkingset !== undefined) { // Windows
this.MemoryUsage = data.memory_stats.privateworkingset;
this.MemoryCache = 0;
this.NumProcs = data.num_procs;
this.isWindows = true;
} else { // Linux
this.MemoryUsage = data.memory_stats.usage - data.memory_stats.stats.cache;
this.MemoryCache = data.memory_stats.stats.cache;
}
this.PreviousCPUTotalUsage = data.precpu_stats.cpu_usage.total_usage;
this.PreviousCPUSystemUsage = data.precpu_stats.system_cpu_usage;
this.CurrentCPUTotalUsage = data.cpu_stats.cpu_usage.total_usage;

View File

@ -25,21 +25,21 @@ function ($q, $scope, $transition$, $document, $interval, ContainerService, Char
if (stats.Networks.length > 0) {
var rx = stats.Networks[0].rx_bytes;
var tx = stats.Networks[0].tx_bytes;
var label = moment(stats.Date).format('HH:mm:ss');
var label = moment(stats.read).format('HH:mm:ss');
ChartService.UpdateNetworkChart(label, rx, tx, chart);
}
}
function updateMemoryChart(stats, chart) {
var label = moment(stats.Date).format('HH:mm:ss');
var label = moment(stats.read).format('HH:mm:ss');
ChartService.UpdateMemoryChart(label, stats.MemoryUsage, stats.MemoryCache, chart);
}
function updateCPUChart(stats, chart) {
var label = moment(stats.Date).format('HH:mm:ss');
var value = calculateCPUPercentUnix(stats);
var label = moment(stats.read).format('HH:mm:ss');
var value = stats.isWindows ? calculateCPUPercentWindows(stats) : calculateCPUPercentUnix(stats);
ChartService.UpdateCPUChart(label, value, chart);
}
@ -56,6 +56,17 @@ function ($q, $scope, $transition$, $document, $interval, ContainerService, Char
return cpuPercent;
}
function calculateCPUPercentWindows(stats) {
var possIntervals = stats.NumProcs * parseFloat(
moment(stats.read, 'YYYY-MM-DDTHH:mm:ss.SSSSSSSSSZ').valueOf() - moment(stats.preread, 'YYYY-MM-DDTHH:mm:ss.SSSSSSSSSZ').valueOf());
var windowsCpuUsage = 0.0;
if(possIntervals > 0) {
windowsCpuUsage = parseFloat(stats.CurrentCPUTotalUsage - stats.PreviousCPUTotalUsage) / parseFloat(possIntervals * 100);
}
return windowsCpuUsage;
}
$scope.changeUpdateRepeater = function() {
var networkChart = $scope.networkChart;
var cpuChart = $scope.cpuChart;

View File

@ -242,6 +242,39 @@ angular.module('portainer.app', [])
}
};
var schedules = {
name: 'portainer.schedules',
url: '/schedules',
views: {
'content@': {
templateUrl: './views/schedules/schedules.html',
controller: 'SchedulesController'
}
}
};
var schedule = {
name: 'portainer.schedules.schedule',
url: '/:id',
views: {
'content@': {
templateUrl: './views/schedules/edit/schedule.html',
controller: 'ScheduleController'
}
}
};
var scheduleCreation = {
name: 'portainer.schedules.new',
url: '/new',
views: {
'content@': {
templateUrl: './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);

View File

@ -0,0 +1,101 @@
<div class="datatable">
<rd-widget>
<rd-widget-body classes="no-padding">
<div class="toolBar">
<div class="toolBarTitle">
<i class="fa" ng-class="$ctrl.titleIcon" aria-hidden="true" style="margin-right: 2px;"></i> {{ $ctrl.titleText }}
</div>
</div>
<div class="actionBar">
<button type="button" class="btn btn-sm btn-danger"
ng-disabled="$ctrl.state.selectedItemCount === 0" ng-click="$ctrl.removeAction($ctrl.state.selectedItems)">
<i class="fa fa-trash-alt space-right" aria-hidden="true"></i>Remove
</button>
<button type="button" class="btn btn-sm btn-primary" ui-sref="portainer.schedules.new">
<i class="fa fa-plus space-right" aria-hidden="true"></i>Add schedule
</button>
</div>
<div class="searchBar">
<i class="fa fa-search searchIcon" aria-hidden="true"></i>
<input type="text" class="searchInput" ng-model="$ctrl.state.textFilter" placeholder="Search..." auto-focus>
</div>
<div class="table-responsive">
<table class="table table-hover nowrap-cells">
<thead>
<tr>
<th>
<span class="md-checkbox">
<input id="select_all" type="checkbox" ng-model="$ctrl.state.selectAll" ng-change="$ctrl.selectAll()" />
<label for="select_all"></label>
</span>
<a ng-click="$ctrl.changeOrderBy('Name')">
Name
<i class="fa fa-sort-alpha-down" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Name' && !$ctrl.state.reverseOrder"></i>
<i class="fa fa-sort-alpha-up" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Name' && $ctrl.state.reverseOrder"></i>
</a>
</th>
<th>
<a ng-click="$ctrl.changeOrderBy('CronExpression')">
Cron expression
<i class="fa fa-sort-alpha-down" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'CronExpression' && !$ctrl.state.reverseOrder"></i>
<i class="fa fa-sort-alpha-up" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'CronExpression' && $ctrl.state.reverseOrder"></i>
</a>
</th>
<th>
<a ng-click="$ctrl.changeOrderBy('Created')">
Created
<i class="fa fa-sort-alpha-down" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Created' && !$ctrl.state.reverseOrder"></i>
<i class="fa fa-sort-alpha-up" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Created' && $ctrl.state.reverseOrder"></i>
</a>
</th>
</tr>
</thead>
<tbody>
<tr dir-paginate="item in ($ctrl.state.filteredDataSet = ($ctrl.dataset | filter:$ctrl.state.textFilter | orderBy:$ctrl.state.orderBy:$ctrl.state.reverseOrder | itemsPerPage: $ctrl.state.paginatedItemLimit))" ng-class="{active: item.Checked}">
<td>
<span class="md-checkbox">
<input id="select_{{ $index }}" type="checkbox" ng-model="item.Checked" ng-change="$ctrl.selectItem(item)" ng-disabled="item.JobType !== 1"/>
<label for="select_{{ $index }}"></label>
</span>
<span ng-if="item.JobType !== 1">{{ item.Name }}</span>
<a ng-if="item.JobType === 1" ui-sref="portainer.schedules.schedule({id: item.Id})">{{ item.Name }}</a>
</td>
<td>
{{ item.CronExpression }}
</td>
<td>{{ item.Created | getisodatefromtimestamp }}</td>
</tr>
<tr ng-if="!$ctrl.dataset">
<td colspan="3" class="text-center text-muted">Loading...</td>
</tr>
<tr ng-if="$ctrl.state.filteredDataSet.length === 0">
<td colspan="3" class="text-center text-muted">No schedule available.</td>
</tr>
</tbody>
</table>
</div>
<div class="footer" ng-if="$ctrl.dataset">
<div class="infoBar" ng-if="$ctrl.state.selectedItemCount !== 0">
{{ $ctrl.state.selectedItemCount }} item(s) selected
</div>
<div class="paginationControls">
<form class="form-inline">
<span class="limitSelector">
<span style="margin-right: 5px;">
Items per page
</span>
<select class="form-control" ng-model="$ctrl.state.paginatedItemLimit" ng-change="$ctrl.changePaginationLimit()">
<option value="0">All</option>
<option value="10">10</option>
<option value="25">25</option>
<option value="50">50</option>
<option value="100">100</option>
</select>
</span>
<dir-pagination-controls max-size="5"></dir-pagination-controls>
</form>
</div>
</div>
</rd-widget-body>
</rd-widget>
</div>

View File

@ -0,0 +1,13 @@
angular.module('portainer.app').component('schedulesDatatable', {
templateUrl: './schedulesDatatable.html',
controller: 'SchedulesDatatableController',
bindings: {
titleText: '@',
titleIcon: '@',
dataset: '<',
tableKey: '@',
orderBy: '@',
reverseOrder: '<',
removeAction: '<'
}
});

View File

@ -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;
}
}]);

View File

@ -0,0 +1,35 @@
angular.module('portainer.app').component('scheduleForm', {
templateUrl: './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: '<'
}
});

View File

@ -0,0 +1,165 @@
<form class="form-horizontal" name="scheduleForm">
<div class="col-sm-12 form-section-title">
Schedule configuration
</div>
<!-- name-input -->
<div class="form-group">
<label for="schedule_name" class="col-sm-1 control-label text-left">Name</label>
<div class="col-sm-11">
<input type="text" class="form-control" ng-model="$ctrl.model.Name" id="schedule_name" name="schedule_name" placeholder="backup-app-prod" required auto-focus>
</div>
</div>
<div class="form-group" ng-show="scheduleForm.schedule_name.$invalid">
<div class="col-sm-12 small text-warning">
<div ng-messages="scheduleForm.schedule_name.$error">
<p ng-message="required"><i class="fa fa-exclamation-triangle" aria-hidden="true"></i> This field is required.</p>
</div>
</div>
</div>
<!-- !name-input -->
<!-- cron-input -->
<div class="form-group">
<label for="schedule_cron" class="col-sm-1 control-label text-left">Cron rule</label>
<div class="col-sm-11">
<input type="text" class="form-control" ng-model="$ctrl.model.CronExpression" id="schedule_cron" name="schedule_cron" placeholder="0 2 * * *" required>
</div>
</div>
<div class="form-group" ng-show="scheduleForm.schedule_cron.$invalid">
<div class="col-sm-12 small text-warning">
<div ng-messages="scheduleForm.schedule_cron.$error">
<p ng-message="required"><i class="fa fa-exclamation-triangle" aria-hidden="true"></i> This field is required.</p>
</div>
</div>
</div>
<div class="form-group">
<span class="col-sm-12 text-muted small">
You can refer to the <a href="https://godoc.org/github.com/robfig/cron#hdr-CRON_Expression_Format" target="_blank">following documentation</a> to get more information about the supported cron expression format.
</span>
</div>
<!-- !cron-input -->
<div class="col-sm-12 form-section-title">
Job configuration
</div>
<!-- image-input -->
<div class="form-group">
<label for="schedule_image" class="col-sm-1 control-label text-left">Image</label>
<div class="col-sm-11">
<input type="text" class="form-control" ng-model="$ctrl.model.Job.Image" id="schedule_image" name="schedule_image" placeholder="e.g. ubuntu:latest" required>
</div>
</div>
<div class="form-group" ng-show="scheduleForm.schedule_image.$invalid">
<div class="col-sm-12 small text-warning">
<div ng-messages="scheduleForm.schedule_image.$error">
<p ng-message="required"><i class="fa fa-exclamation-triangle" aria-hidden="true"></i> This field is required.</p>
</div>
</div>
</div>
<!-- !image-input -->
<div class="form-group">
<span class="col-sm-12 text-muted small">
This schedule will be executed via a privileged container on the target hosts. You can access the host filesystem under the
<code>/host</code> folder.
</span>
</div>
<!-- execution-method -->
<div ng-if="!$ctrl.model.Id">
<div class="col-sm-12 form-section-title">
Job content
</div>
<div class="form-group"></div>
<div class="form-group" style="margin-bottom: 0">
<div class="boxselector_wrapper">
<div>
<input type="radio" id="method_editor" ng-model="$ctrl.model.Job.Method" value="editor">
<label for="method_editor">
<div class="boxselector_header">
<i class="fa fa-edit" aria-hidden="true" style="margin-right: 2px;"></i>
Web editor
</div>
<p>Use our Web editor</p>
</label>
</div>
<div>
<input type="radio" id="method_upload" ng-model="$ctrl.model.Job.Method" value="upload">
<label for="method_upload">
<div class="boxselector_header">
<i class="fa fa-upload" aria-hidden="true" style="margin-right: 2px;"></i>
Upload
</div>
<p>Upload from your computer</p>
</label>
</div>
</div>
</div>
</div>
<!-- !execution-method -->
<!-- web-editor -->
<div ng-show="$ctrl.model.Job.Method === 'editor'">
<div class="col-sm-12 form-section-title">
Web editor
</div>
<div class="form-group">
<div class="col-sm-12">
<code-editor
identifier="execute-schedule-editor"
placeholder="# Define or paste the content of your script file here"
on-change="$ctrl.editorUpdate"
value="$ctrl.model.Job.FileContent"
></code-editor>
</div>
</div>
</div>
<!-- !web-editor -->
<!-- upload -->
<div ng-show="$ctrl.model.Job.Method === 'upload'">
<div class="col-sm-12 form-section-title">
Upload
</div>
<div class="form-group">
<span class="col-sm-12 text-muted small">
You can upload a script file from your computer.
</span>
</div>
<div class="form-group">
<div class="col-sm-12">
<button class="btn btn-sm btn-primary" ngf-select ng-model="$ctrl.model.Job.File">Select file</button>
<span style="margin-left: 5px;">
{{ $ctrl.model.Job.File.name }}
<i class="fa fa-times red-icon" ng-if="!$ctrl.model.Job.File" aria-hidden="true"></i>
</span>
</div>
</div>
</div>
<!-- !upload -->
<div class="col-sm-12 form-section-title">
Target endpoints
</div>
<!-- node-selection -->
<multi-endpoint-selector
ng-if="$ctrl.endpoints && $ctrl.groups"
model="$ctrl.model.Job.Endpoints"
endpoints="$ctrl.endpoints" groups="$ctrl.groups"
></multi-endpoint-selector>
<!-- !node-selection -->
<!-- actions -->
<div class="col-sm-12 form-section-title">
Actions
</div>
<div class="form-group">
<div class="col-sm-12">
<button type="button" class="btn btn-primary btn-sm"
ng-disabled="$ctrl.actionInProgress || !scheduleForm.$valid
|| $ctrl.model.Job.Endpoints.length === 0
|| ($ctrl.model.Job.Method === 'upload' && !$ctrl.model.Job.File)"
ng-click="$ctrl.action()" button-spinner="$ctrl.actionInProgress"
>
<span ng-hide="$ctrl.actionInProgress">{{ $ctrl.formActionLabel }}</span>
<span ng-show="$ctrl.actionInProgress">In progress...</span>
</button>
<span class="text-danger" ng-if="$ctrl.state.formValidationError" style="margin-left: 5px;">
{{ $ctrl.state.formValidationError }}
</span>
</div>
</div>
<!-- !actions -->
</form>

View File

@ -0,0 +1,9 @@
angular.module('portainer.app').component('multiEndpointSelector', {
templateUrl: './multiEndpointSelector.html',
controller: 'MultiEndpointSelectorController',
bindings: {
'model': '=',
'endpoints': '<',
'groups': '<'
}
});

View File

@ -0,0 +1,14 @@
<ui-select multiple ng-model="$ctrl.model" close-on-select="false" style="margin-top: 55px">
<ui-select-match placeholder="Select one or multiple endpoint(s)">
<span>
{{ $item.Name }}
<span ng-if="$item.Tags.length">({{ $item.Tags | arraytostr }})</span>
</span>
</ui-select-match>
<ui-select-choices group-by="$ctrl.groupEndpoints" group-filter="$ctrl.sortGroups" repeat="endpoint.Id as endpoint in $ctrl.endpoints | filter: { Name: $select.search }">
<span>
{{ endpoint.Name }}
<span ng-if="endpoint.Tags.length">({{ endpoint.Tags | arraytostr }})</span>
</span>
</ui-select-choices>
</ui-select>

View File

@ -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;
});
}
});

View File

@ -0,0 +1,50 @@
export 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';
}
export 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;
this.FileContent = '';
this.Method = 'editor';
}
export 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;
}
export 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;
this.FileContent = model.Job.FileContent;
}

View File

@ -0,0 +1,13 @@
angular.module('portainer.app')
.factory('Schedules', ['$resource', 'API_ENDPOINT_SCHEDULES',
function SchedulesFactory($resource, API_ENDPOINT_SCHEDULES) {
'use strict';
return $resource(API_ENDPOINT_SCHEDULES + '/:id/:action', {}, {
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'} },
file: { method: 'GET', params: { id : '@id', action: 'file' } }
});
}]);

View File

@ -0,0 +1,65 @@
import { ScheduleModel, ScheduleCreateRequest, ScheduleUpdateRequest } from '../../models/schedule';
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;
};
service.getScriptFile = function(scheduleId) {
return Schedules.file({ id: scheduleId }).$promise;
};
return service;
}]);

View File

@ -138,6 +138,14 @@ angular.module('portainer.app')
});
};
function LimitChartItems(chart, CHART_LIMIT) {
if (chart.data.datasets[0].data.length > CHART_LIMIT) {
chart.data.labels.pop();
chart.data.datasets[0].data.pop();
chart.data.datasets[1].data.pop();
}
}
function UpdateChart(label, value, chart) {
chart.data.labels.push(label);
chart.data.datasets[0].data.push(value);
@ -153,13 +161,15 @@ angular.module('portainer.app')
service.UpdateMemoryChart = function UpdateChart(label, memoryValue, cacheValue, chart) {
chart.data.labels.push(label);
chart.data.datasets[0].data.push(memoryValue);
chart.data.datasets[1].data.push(cacheValue);
if (chart.data.datasets[0].data.length > CHART_LIMIT) {
chart.data.labels.pop();
chart.data.datasets[0].data.pop();
if(cacheValue) {
chart.data.datasets[1].data.push(cacheValue);
} else { // cache values are not available for Windows
chart.data.datasets.splice(1, 1);
}
LimitChartItems(chart);
chart.update(0);
};
service.UpdateCPUChart = UpdateChart;
@ -169,11 +179,7 @@ angular.module('portainer.app')
chart.data.datasets[0].data.push(rx);
chart.data.datasets[1].data.push(tx);
if (chart.data.datasets[0].data.length > CHART_LIMIT) {
chart.data.labels.pop();
chart.data.datasets[0].data.pop();
chart.data.datasets[1].data.pop();
}
LimitChartItems(chart);
chart.update(0);
};

View File

@ -41,6 +41,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,

View File

@ -0,0 +1,54 @@
import { ScheduleDefaultModel } from '../../../models/schedule';
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();
}]);

View File

@ -0,0 +1,23 @@
<rd-header>
<rd-header-title title-text="Create schedule"></rd-header-title>
<rd-header-content>
<a ui-sref="portainer.schedules">Schedules</a> &gt; Add schedule
</rd-header-content>
</rd-header>
<div class="row">
<div class="col-sm-12">
<rd-widget>
<rd-widget-body>
<schedule-form
model="model"
endpoints="endpoints"
groups="groups"
form-action="create"
form-action-label="Create schedule"
action-in-progress="state.actionInProgress"
></schedule-form>
</rd-widget-body>
</rd-widget>
</div>
</div>

View File

@ -0,0 +1,28 @@
<rd-header>
<rd-header-title title-text="Schedule details">
<a data-toggle="tooltip" title-text="Refresh" ui-sref="portainer.schedules.schedule({id: schedule.Id})" ui-sref-opts="{reload: true}">
<i class="fa fa-sync" aria-hidden="true"></i>
</a>
</rd-header-title>
<rd-header-content>
<a ui-sref="portainer.schedules">Schedules</a> &gt; {{ ::schedule.Name }}</a>
</rd-header-content>
</rd-header>
<div class="row">
<div class="col-sm-12">
<rd-widget>
<rd-widget-body>
<schedule-form
ng-if="schedule"
model="schedule"
endpoints="endpoints"
groups="groups"
form-action="update"
form-action-label="Update schedule"
action-in-progress="state.actionInProgress"
></schedule-form>
</rd-widget-body>
</rd-widget>
</div>
</div>

View File

@ -0,0 +1,54 @@
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;
var schedule = null;
$q.all({
schedule: ScheduleService.schedule(id),
endpoints: EndpointService.endpoints(),
groups: GroupService.groups()
})
.then(function success(data) {
schedule = data.schedule;
$scope.endpoints = data.endpoints;
$scope.groups = data.groups;
return ScheduleService.getScriptFile(schedule.Id);
})
.then(function success(data) {
schedule.Job.FileContent = data.ScheduleFileContent;
$scope.schedule = schedule;
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to retrieve endpoint list');
});
}
initView();
}]);

View File

@ -0,0 +1,19 @@
<rd-header>
<rd-header-title title-text="Schedules list">
<a data-toggle="tooltip" title="Refresh" ui-sref="portainer.schedules" ui-sref-opts="{reload: true}">
<i class="fa fa-sync" aria-hidden="true"></i>
</a>
</rd-header-title>
<rd-header-content>Schedules</rd-header-content>
</rd-header>
<div class="row">
<div class="col-sm-12">
<schedules-datatable
title-text="Schedules" title-icon="fa-clock"
dataset="schedules" table-key="schedules"
order-by="Name"
remove-action="removeAction"
></schedules-datatable>
</div>
</div>

View File

@ -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();
}]);

View File

@ -37,6 +37,12 @@
<a ui-sref="storidge.profiles" ui-sref-active="active">Profiles</a>
</div>
</li>
<li class="sidebar-title" ng-if="!applicationState.application.authentication || isAdmin">
<span>Scheduler</span>
</li>
<li class="sidebar-list" ng-if="!applicationState.application.authentication || isAdmin">
<a ui-sref="portainer.schedules" ui-sref-active="active">Host jobs <span class="menu-icon fa fa-clock fa-fw"></span></a>
</li>
<li class="sidebar-title" ng-if="!applicationState.application.authentication || isAdmin || isTeamLeader">
<span>Settings</span>
</li>