diff --git a/app/components/sidebar/sidebarController.js b/app/components/sidebar/sidebarController.js index eef6a0430..5bd466c70 100644 --- a/app/components/sidebar/sidebarController.js +++ b/app/components/sidebar/sidebarController.js @@ -19,7 +19,7 @@ function ($scope, $state, Settings, Config, EndpointService, StateManager, Endpo $state.go('dashboard'); }) .catch(function error(err) { - Notifications.error("Failure", err, "Unable to connect to the Docker endpoint"); + Notifications.error('Failure', err, 'Unable to connect to the Docker endpoint'); EndpointProvider.setEndpointID(activeEndpointID); EndpointProvider.setEndpointPublicURL(activeEndpointPublicURL); StateManager.updateEndpointState(true) diff --git a/app/components/templates/templates.html b/app/components/templates/templates.html index cc33b4cc2..784ef3271 100644 --- a/app/components/templates/templates.html +++ b/app/components/templates/templates.html @@ -1,4 +1,4 @@ -<rd-header> +<rd-header id="view-top"> <rd-header-title title="Application templates list"> <a data-toggle="tooltip" title="Refresh" ui-sref="templates" ui-sref-opts="{reload: true}"> <i class="fa fa-refresh" aria-hidden="true"></i> @@ -7,34 +7,52 @@ </rd-header-title> <rd-header-content>Templates</rd-header-content> </rd-header> -<div id="selectedTemplate" class="row" ng-if="state.selectedTemplate"> - <div class="col-lg-12 col-md-12 col-xs-12"> + +<div class="row" style="height: 90%"> + + <div class="col-sm-12" ng-if="state.selectedTemplate"> <rd-widget> <rd-widget-custom-header icon="state.selectedTemplate.Logo" title="state.selectedTemplate.Image"> + <div class="pull-right"> + <button type="button" class="btn btn-sm btn-primary" ng-click="unselectTemplate()">Hide</button> + </div> </rd-widget-custom-header> <rd-widget-body classes="padding"> + <form class="form-horizontal"> <!-- description --> - <div class="form-group" ng-if="state.selectedTemplate.Note"> - <div class="col-sm-12"> - <span class="small" style="margin-left: 5px;" ng-bind-html="state.selectedTemplate.Note"></span> + <div ng-if="state.selectedTemplate.Note"> + <div class="col-sm-12 form-section-title"> + Information + </div> + <div class="form-group"> + <div class="col-sm-12"> + <span class="template-note" ng-bind-html="state.selectedTemplate.Note"></span> + </div> </div> </div> <!-- !description --> - <!-- name-and-network-inputs --> + <div class="col-sm-12 form-section-title"> + Configuration + </div> + <!-- name-input --> <div class="form-group"> - <label for="container_name" class="col-sm-1 control-label text-left">Name</label> - <div class="col-sm-5"> + <label for="container_name" class="col-sm-2 control-label text-left">Name</label> + <div class="col-sm-10"> <input type="text" name="container_name" class="form-control" ng-model="formValues.name" placeholder="e.g. web (optional)"> </div> - <label for="container_network" class="col-sm-2 col-lg-1 control-label text-right">Network</label> - <div class="col-sm-4 col-lg-5"> + </div> + <!-- !name-input --> + <!-- network-input --> + <div class="form-group"> + <label for="container_network" class="col-sm-2 control-label text-left">Network</label> + <div class="col-sm-10"> <select class="form-control" ng-options="net.Name for net in availableNetworks" ng-model="formValues.network"> <option disabled hidden value="">Select a network</option> </select> </div> </div> - <!-- !name-and-network-inputs --> + <!-- !network-input --> <!-- env --> <div ng-repeat="var in state.selectedTemplate.Env" ng-if="!var.set" class="form-group"> <label for="field_{{ $index }}" class="col-sm-2 control-label text-left">{{ var.label }}</label> @@ -63,6 +81,7 @@ </div> </div> <!-- !ownership --> + <!-- advanced-options --> <div class="form-group"> <div class="col-sm-12"> <a class="small interactive" ng-if="!state.showAdvancedOptions" ng-click="state.showAdvancedOptions = true;"> @@ -73,7 +92,6 @@ </a> </div> </div> - <!-- advanced-options --> <div ng-if="state.showAdvancedOptions"> <!-- port-mapping --> <div class="form-group" > @@ -120,7 +138,6 @@ </div> </div> </div> - </div> <!-- !port-mapping-input-list --> <!-- volume-mapping --> @@ -192,6 +209,7 @@ <!-- !volume-mapping --> </div> <!-- !advanced-options --> + <!-- actions --> <div class="form-group"> <div class="col-sm-12"> <button type="button" class="btn btn-primary btn-sm" ng-disabled="!formValues.network" ng-click="createTemplate()">Create</button> @@ -205,45 +223,90 @@ </span> </div> </div> + <!-- !actions --> </form> + </rd-widget-body> </rd-widget> </div> -</div> -<div class="row"> - <div class="col-lg-12 col-md-12 col-xs-12"> - <rd-widget> - <rd-widget-header icon="fa-rocket" title="Available templates"> - <div class="pull-right"> - Items per page: - <select ng-model="state.pagination_count" ng-change="changePaginationCount()"> - <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> + <div class="col-sm-12" style="height: 100%"> + <rd-template-widget> + <rd-widget-header icon="fa-rocket" title="Templates"> + <div ng-if="availableCategories.length > 0" class="pull-right"> + Category + <select ng-model="state.filters.Categories"> + <option value="!">All</option> + <option ng-repeat="category in availableCategories" value="{{ category }}">{{ category }}</option> </select> </div> </rd-widget-header> - <rd-widget-body classes="padding"> + <rd-widget-taskbar> + <div> + <!-- Platform --> + <span class="btn-group btn-group-sm" style="margin-right: 15px;"> + <label class="btn btn-primary" ng-model="state.filters.Platform" uib-btn-radio="'!'"> + All + </label> + <label class="btn btn-primary" ng-model="state.filters.Platform" uib-btn-radio="'windows'"> + <i class="fa fa-windows" aria-hidden="true"></i> + Windows + </label> + <label class="btn btn-primary" ng-model="state.filters.Platform" uib-btn-radio="'linux'"> + <i class="fa fa-linux" aria-hidden="true"></i> + Linux + </label> + </span> + </div> + </rd-widget-taskbar> + <rd-widget-body classes="padding template-widget-body"> <div class="template-list"> - <div dir-paginate="tpl in templates | itemsPerPage: state.pagination_count" class="container-template hvr-underline-from-center" id="template_{{ tpl.index }}" ng-click="selectTemplate(tpl.index)"> - <img class="logo" ng-src="{{ tpl.Logo }}" /> - <div class="title">{{ tpl.Title }}</div> - <div class="description" ng-if="tpl.Description && !state.hideDescriptions">{{ tpl.Description }}</div> + <!-- template --> + <div dir-paginate="tpl in templates | filter:state.filters:true | itemsPerPage: state.pagination_count" class="template-container" id="template_{{ tpl.index }}" ng-click="selectTemplate(tpl.index, $index)"> + <div class="template-main"> + <!-- template-image --> + <span class=""> + <img class="template-logo" ng-src="{{ tpl.Logo }}" /> + </span> + <!-- !template-image --> + <!-- template-details --> + <span class="col-sm-12"> + <!-- template-line1 --> + <div class="template-line"> + <span class="template-title"> + {{ tpl.Title }} + </span> + <span> + <i class="fa fa-windows" aria-hidden="true" ng-if="tpl.Platform === 'windows'"></i> + <i class="fa fa-linux" aria-hidden="true" ng-if="tpl.Platform === 'linux'"></i> + <!-- Arch / Platform --> + </span> + </div> + <!-- !template-line1 --> + <!-- template-line2 --> + <div class="template-line"> + <span class="template-description"> + {{ tpl.Description }} + </span> + <span class="small text-muted" ng-if="tpl.Categories.length > 0"> + {{ tpl.Categories.join(', ') }} + </span> + </div> + <!-- !template-line2 --> + </span> + <!-- !template-details --> + </div> + <!-- !template --> </div> <div ng-if="!templates" class="text-center text-muted"> Loading... </div> - <div ng-if="templates.length == 0" class="text-center text-muted"> + <div ng-if="(templates | filter:state.filters:true | itemsPerPage: state.pagination_count).length == 0" class="text-center text-muted"> No templates available. </div> </div> - <div ng-if="templates"> - <dir-pagination-controls></dir-pagination-controls> - </div> </rd-widget-body> - </rd-widget> + </rd-template-widget> </div> + </div> diff --git a/app/components/templates/templatesController.js b/app/components/templates/templatesController.js index 42bbe6de5..9cec4c602 100644 --- a/app/components/templates/templatesController.js +++ b/app/components/templates/templatesController.js @@ -1,16 +1,20 @@ angular.module('templates', []) -.controller('TemplatesController', ['$scope', '$q', '$state', '$stateParams', '$anchorScroll', 'Config', 'ContainerService', 'ContainerHelper', 'ImageService', 'NetworkService', 'TemplateService', 'TemplateHelper', 'VolumeService', 'Notifications', 'Pagination', 'ResourceControlService', 'Authentication', -function ($scope, $q, $state, $stateParams, $anchorScroll, Config, ContainerService, ContainerHelper, ImageService, NetworkService, TemplateService, TemplateHelper, VolumeService, Notifications, Pagination, ResourceControlService, Authentication) { +.controller('TemplatesController', ['$scope', '$q', '$state', '$stateParams', '$anchorScroll', '$filter', 'Config', 'ContainerService', 'ContainerHelper', 'ImageService', 'NetworkService', 'TemplateService', 'TemplateHelper', 'VolumeService', 'Notifications', 'Pagination', 'ResourceControlService', 'Authentication', +function ($scope, $q, $state, $stateParams, $anchorScroll, $filter, Config, ContainerService, ContainerHelper, ImageService, NetworkService, TemplateService, TemplateHelper, VolumeService, Notifications, Pagination, ResourceControlService, Authentication) { $scope.state = { selectedTemplate: null, showAdvancedOptions: false, hideDescriptions: $stateParams.hide_descriptions, - pagination_count: Pagination.getPaginationCount('templates') + pagination_count: Pagination.getPaginationCount('templates'), + filters: { + Categories: '!', + Platform: '!' + } }; $scope.formValues = { Ownership: $scope.applicationState.application.authentication ? 'private' : '', - network: "", - name: "", + network: '', + name: '' }; $scope.changePaginationCount = function() { @@ -74,32 +78,38 @@ function ($scope, $q, $state, $stateParams, $anchorScroll, Config, ContainerServ }); }; - var selectedItem = -1; - $scope.selectTemplate = function(idx) { - $('#template_' + idx).toggleClass("container-template--selected"); - if (selectedItem === idx) { - unselectTemplate(); + $scope.unselectTemplate = function() { + var currentTemplateIndex = $scope.state.selectedTemplate.index; + $('#template_' + currentTemplateIndex).toggleClass('template-container--selected'); + $scope.state.selectedTemplate = null; + }; + + $scope.selectTemplate = function(index, pos) { + if ($scope.state.selectedTemplate && $scope.state.selectedTemplate.index !== index) { + $scope.unselectTemplate(); + } + + var templates = $filter('filter')($scope.templates, $scope.state.filters, true); + var template = templates[pos]; + if (template === $scope.state.selectedTemplate) { + $scope.unselectTemplate(); } else { - selectTemplate(idx); + selectTemplate(index, pos, templates); } }; - function unselectTemplate() { - selectedItem = -1; - $scope.state.selectedTemplate = null; - } - - function selectTemplate(idx) { - $('#template_' + selectedItem).toggleClass("container-template--selected"); - selectedItem = idx; - var selectedTemplate = $scope.templates[idx]; + function selectTemplate(index, pos, filteredTemplates) { + $('#template_' + index).toggleClass('template-container--selected'); + var selectedTemplate = filteredTemplates[pos]; $scope.state.selectedTemplate = selectedTemplate; + if (selectedTemplate.Network) { $scope.formValues.network = _.find($scope.availableNetworks, function(o) { return o.Name === selectedTemplate.Network; }); } else { - $scope.formValues.network = _.find($scope.availableNetworks, function(o) { return o.Name === "bridge"; }); + $scope.formValues.network = _.find($scope.availableNetworks, function(o) { return o.Name === 'bridge'; }); } - $anchorScroll('selectedTemplate'); + + $anchorScroll('view-top'); } function createTemplateConfiguration(template) { @@ -114,7 +124,7 @@ function ($scope, $q, $state, $stateParams, $anchorScroll, Config, ContainerServ var containerMapping = 'BY_CONTAINER_IP'; if (endpointProvider === 'DOCKER_SWARM' && network.Scope === 'global') { containerMapping = 'BY_SWARM_CONTAINER_NAME'; - } else if (network.Name !== "bridge") { + } else if (network.Name !== 'bridge') { containerMapping = 'BY_CONTAINER_NAME'; } return containerMapping; @@ -144,18 +154,19 @@ function ($scope, $q, $state, $stateParams, $anchorScroll, Config, ContainerServ volumes: VolumeService.getVolumes() }) .then(function success(data) { - var templates = data.templates; - if (templatesKey === 'linuxserver.io') { - templates = TemplateService.filterLinuxServerIOTemplates(templates); - } - $scope.templates = templates; + $scope.templates = data.templates; + var availableCategories = []; + angular.forEach($scope.templates, function(template) { + availableCategories = availableCategories.concat(template.Categories); + }); + $scope.availableCategories = _.sortBy(_.uniq(availableCategories)); $scope.runningContainers = data.containers; $scope.availableNetworks = filterNetworksBasedOnProvider(data.networks); $scope.availableVolumes = data.volumes.Volumes; }) .catch(function error(err) { $scope.templates = []; - Notifications.error("Failure", err, "An error occured during apps initialization."); + Notifications.error('Failure', err, 'An error occured during apps initialization.'); }) .finally(function final(){ $('#loadTemplatesSpinner').hide(); diff --git a/app/directives/template-widget.js b/app/directives/template-widget.js new file mode 100644 index 000000000..51f902cbf --- /dev/null +++ b/app/directives/template-widget.js @@ -0,0 +1,13 @@ +angular +.module('portainer') +.directive('rdTemplateWidget', function rdWidget() { + var directive = { + scope: { + 'ngModel': '=' + }, + transclude: true, + template: '<div class="widget template-widget" id="template-widget" ng-transclude></div>', + restrict: 'EA' + }; + return directive; +}); diff --git a/app/directives/widget.js b/app/directives/widget.js index 56fea70e5..6426fe147 100644 --- a/app/directives/widget.js +++ b/app/directives/widget.js @@ -3,7 +3,7 @@ angular .directive('rdWidget', function rdWidget() { var directive = { scope: { - "ngModel": "=" + 'ngModel': '=' }, transclude: true, template: '<div class="widget" ng-transclude></div>', diff --git a/app/helpers/templateHelper.js b/app/helpers/templateHelper.js index 7cdda76fe..a97bdd978 100644 --- a/app/helpers/templateHelper.js +++ b/app/helpers/templateHelper.js @@ -28,7 +28,7 @@ angular.module('portainer.helpers') }; ports.forEach(function (p) { if (p.containerPort) { - var key = p.containerPort + "/" + p.protocol; + var key = p.containerPort + '/' + p.protocol; var binding = {}; if (p.hostPort) { binding.HostPort = p.hostPort; @@ -60,7 +60,7 @@ angular.module('portainer.helpers') value = $filter('swarmcontainername')(envvar.value); } } - env.push(envvar.name + "=" + value); + env.push(envvar.name + '=' + value); } }); return env; @@ -108,8 +108,8 @@ angular.module('portainer.helpers') helper.filterLinuxServerIOTemplates = function(templates) { return templates.filter(function f(template) { var valid = false; - if (template.Category) { - angular.forEach(template.Category, function(category) { + if (template.Categories) { + angular.forEach(template.Categories, function(category) { if (_.startsWith(category, 'Network')) { valid = true; } diff --git a/app/models/template.js b/app/models/template.js index 0b8dbf3eb..6d8d5497a 100644 --- a/app/models/template.js +++ b/app/models/template.js @@ -1,8 +1,9 @@ function TemplateViewModel(data) { this.Title = data.title; this.Description = data.description; - this.Note = data.note ? data.note : data.description; - this.Category = data.category; + this.Note = data.note; + this.Categories = data.categories ? data.categories : []; + this.Platform = data.platform ? data.platform : ''; this.Logo = data.logo; this.Image = data.image; this.Registry = data.registry ? data.registry : ''; diff --git a/app/models/templateLinuxServer.js b/app/models/templateLinuxServer.js new file mode 100644 index 000000000..ae65c214d --- /dev/null +++ b/app/models/templateLinuxServer.js @@ -0,0 +1,33 @@ +function TemplateLSIOViewModel(data) { + this.Title = data.title; + this.Note = data.description; + this.Categories = data.category ? data.category : []; + this.Platform = data.platform ? data.platform : 'linux'; + this.Logo = data.logo; + this.Image = data.image; + this.Registry = data.registry ? data.registry : ''; + this.Command = data.command ? data.command : ''; + this.Network = data.network ? data.network : ''; + this.Env = data.env ? data.env : []; + this.Privileged = data.privileged ? data.privileged : false; + this.Volumes = []; + if (data.volumes) { + this.Volumes = data.volumes.map(function (v) { + return { + readOnly: false, + containerPath: v, + type: 'auto' + }; + }); + } + this.Ports = []; + if (data.ports) { + this.Ports = data.ports.map(function (p) { + var portAndProtocol = _.split(p, '/'); + return { + containerPort: portAndProtocol[0], + protocol: portAndProtocol[1] + }; + }); + } +} diff --git a/app/services/templateService.js b/app/services/templateService.js index b339135f6..4be7d7cbe 100644 --- a/app/services/templateService.js +++ b/app/services/templateService.js @@ -8,10 +8,18 @@ angular.module('portainer.services') Template.get({key: key}).$promise .then(function success(data) { var templates = data.map(function (tpl, idx) { - var template = new TemplateViewModel(tpl); + var template; + if (key === 'linuxserver.io') { + template = new TemplateLSIOViewModel(tpl); + } else { + template = new TemplateViewModel(tpl); + } template.index = idx; return template; }); + if (key === 'linuxserver.io') { + templates = TemplateHelper.filterLinuxServerIOTemplates(templates); + } deferred.resolve(templates); }) .catch(function error(err) { @@ -20,10 +28,6 @@ angular.module('portainer.services') return deferred.promise; }; - service.filterLinuxServerIOTemplates = function(templates) { - return TemplateHelper.filterLinuxServerIOTemplates(templates); - }; - service.createTemplateConfiguration = function(template, containerName, network, containerMapping) { var imageConfiguration = ImageHelper.createImageConfigForContainer(template.Image, template.Registry); var containerConfiguration = service.createContainerConfiguration(template, containerName, network, containerMapping); diff --git a/assets/css/app.css b/assets/css/app.css index d0d1cd0f5..13c4d0eb0 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -64,6 +64,13 @@ html, body, #content-wrapper, .page-content, #view { color: #777; } +.form-section-title { + border-bottom: 1px solid #777; + margin-top: 5px; + margin-bottom: 15px; + color: #777; +} + .form-horizontal .control-label.text-left{ text-align: left; font-size: 0.9em; @@ -149,11 +156,6 @@ a[ng-click]{ cursor: pointer; } -.template-list { - display: flex; - flex-wrap: wrap; -} - .custom-header-ico { max-width: 32px; max-height: 32px; @@ -176,81 +178,6 @@ a[ng-click]{ } } -/* Underline From Center */ -.hvr-underline-from-center { - display: inline-block; - vertical-align: middle; - -webkit-transform: translateZ(0); - transform: translateZ(0); - box-shadow: 0 0 1px rgba(0, 0, 0, 0); - -webkit-backface-visibility: hidden; - backface-visibility: hidden; - -moz-osx-font-smoothing: grayscale; - position: relative; - overflow: hidden; -} -.hvr-underline-from-center:before { - content: ""; - position: absolute; - z-index: -1; - left: 50%; - right: 50%; - bottom: 0; - background: #85898b; - height: 2px; - -webkit-transition-property: left, right; - transition-property: left, right; - -webkit-transition-duration: 0.3s; - transition-duration: 0.3s; - -webkit-transition-timing-function: ease-out; - transition-timing-function: ease-out; -} -.hvr-underline-from-center:hover:before, .hvr-underline-from-center:focus:before, .hvr-underline-from-center:active:before { - left: 0; - right: 0; -} - -.container-template { - font-size: 1em; - width: 256px; - height: 128px; - margin: 15px; - padding: 5px; - display: flex; - flex-direction: column; - justify-content: center; - align-items: center; - border-radius: 10px; - cursor: pointer; - border: 2px solid #f6f6f6; - color: #30426a; -} - -.container-template--selected { - background-color: #ececec; - color: #2d3e63; -} - -.container-template:hover { - background-color: #ececec; - color: #2d3e63; -} - -.container-template .logo { - max-width: 48px; - max-height: 48px; -} - -.container-template .title { - text-align: center; -} - -.container-template .description { - text-align: center; - font-size: 0.8em; - margin-bottom: 5px; -} - .page-wrapper { height: 100%; width: 100%; @@ -418,3 +345,68 @@ ul.sidebar .sidebar-list .sidebar-sublist a.active { #toast-container > div { opacity: 0.9; } + +.template-widget { + height: 100%; +} + +.template-widget-body { + max-height: 86%; + overflow-y: auto; +} + +.template-list { + display: flex; + flex-direction: column; +} + +.template-logo { + width: 100%; + max-width: 60px; + height: 100%; + max-height: 60px; +} + +.template-container { + padding: 0.7rem; + margin-bottom: 0.7rem; + cursor: pointer; + border: 1px solid #333333; + border-radius: 2px; + box-shadow: 0 3px 10px -2px rgba(161, 170, 166, 0.5); +} + +.template-container--selected { + border: 2px solid #333333; + background-color: #ececec; + color: #2d3e63; +} + +.template-container:hover { + background-color: #ececec; + color: #2d3e63; +} + +.template-main { + display: flex; +} + +.template-note { + padding: 0.5em; + font-size: 0.9em; +} + +.template-title { + font-size: 1.8em; + font-weight: bold; +} + +.template-description { + font-size: 0.9em; + padding-right: 1em; +} + +.template-line { + display: flex; + justify-content: space-between; +}