diff --git a/Dockerfile b/Dockerfile index 889d328e0..f150e7308 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,8 +1,9 @@ -FROM scratch +FROM centurylink/ca-certs COPY dist / VOLUME /data EXPOSE 9000 + ENTRYPOINT ["/ui-for-docker"] diff --git a/README.md b/README.md index f7d63d6a5..b1fde07f4 100644 --- a/README.md +++ b/README.md @@ -142,6 +142,14 @@ server { Replace `ADDRESS:PORT` with the CloudInovasi UI container details. +### Host your own apps + +You can specify an URL to your own templates (**Apps**) definitions using the `--templates` or `-t` flags. + +By default, CloudInovasi templates will be used (https://raw.githubusercontent.com/cloud-inovasi/ui-templates/master/templates.json). + +For more information about hosting your own template definition and the format, see: https://github.com/cloud-inovasi/ui-templates + ### Available options The following options are available for the `ui-for-docker` binary: @@ -157,3 +165,4 @@ The following options are available for the `ui-for-docker` binary: * `--tlskey`: Path to the TLS key (default `/certs/key.pem`) * `--hide-label`, `-l`: Hide containers with a specific label in the UI * `--logo`: URL to a picture to be displayed as a logo in the UI +* `--templates`, `-t`: URL to templates (apps) definitions diff --git a/api/api.go b/api/api.go index 0047e622e..387e21954 100644 --- a/api/api.go +++ b/api/api.go @@ -9,11 +9,12 @@ import ( type ( api struct { - endpoint *url.URL - bindAddress string - assetPath string - dataPath string - tlsConfig *tls.Config + endpoint *url.URL + bindAddress string + assetPath string + dataPath string + tlsConfig *tls.Config + templatesURL string } apiConfig struct { @@ -26,6 +27,7 @@ type ( TLSCACertPath string TLSCertPath string TLSKeyPath string + TemplatesURL string } ) @@ -48,10 +50,11 @@ func newAPI(apiConfig apiConfig) *api { } return &api{ - endpoint: endpointURL, - bindAddress: apiConfig.BindAddress, - assetPath: apiConfig.AssetPath, - dataPath: apiConfig.DataPath, - tlsConfig: tlsConfig, + endpoint: endpointURL, + bindAddress: apiConfig.BindAddress, + assetPath: apiConfig.AssetPath, + dataPath: apiConfig.DataPath, + tlsConfig: tlsConfig, + templatesURL: apiConfig.TemplatesURL, } } diff --git a/api/handler.go b/api/handler.go index 3e48f258e..fe8f2b947 100644 --- a/api/handler.go +++ b/api/handler.go @@ -25,6 +25,9 @@ func (a *api) newHandler(settings *Settings) http.Handler { mux.HandleFunc("/settings", func(w http.ResponseWriter, r *http.Request) { settingsHandler(w, r, settings) }) + mux.HandleFunc("/templates", func(w http.ResponseWriter, r *http.Request) { + templatesHandler(w, r, a.templatesURL) + }) return CSRFHandler(newCSRFWrapper(mux)) } diff --git a/api/main.go b/api/main.go index 877271926..c98c78ff2 100644 --- a/api/main.go +++ b/api/main.go @@ -19,6 +19,7 @@ func main() { swarm = kingpin.Flag("swarm", "Swarm cluster support").Default("false").Short('s').Bool() labels = pairs(kingpin.Flag("hide-label", "Hide containers with a specific label in the UI").Short('l')) logo = kingpin.Flag("logo", "URL for the logo displayed in the UI").String() + templates = kingpin.Flag("templates", "URL to the templates (apps) definitions").Default("https://raw.githubusercontent.com/cloud-inovasi/ui-templates/master/templates.json").Short('t').String() ) kingpin.Parse() @@ -32,6 +33,7 @@ func main() { TLSCACertPath: *tlscacert, TLSCertPath: *tlscert, TLSKeyPath: *tlskey, + TemplatesURL: *templates, } settings := &Settings{ diff --git a/api/settings.go b/api/settings.go index b707a8e69..2103a0c69 100644 --- a/api/settings.go +++ b/api/settings.go @@ -12,7 +12,7 @@ type Settings struct { Logo string `json:"logo"` } -// configurationHandler defines a handler function used to encode the configuration in JSON +// settingsHandler defines a handler function used to encode the configuration in JSON func settingsHandler(w http.ResponseWriter, r *http.Request, s *Settings) { json.NewEncoder(w).Encode(*s) } diff --git a/api/templates.go b/api/templates.go new file mode 100644 index 000000000..7c69a2ee7 --- /dev/null +++ b/api/templates.go @@ -0,0 +1,27 @@ +package main + +import ( + "fmt" + "io/ioutil" + "log" + "net/http" +) + +// templatesHandler defines a handler function used to retrieve the templates from a URL and put them in the response +func templatesHandler(w http.ResponseWriter, r *http.Request, templatesURL string) { + resp, err := http.Get(templatesURL) + if err != nil { + http.Error(w, fmt.Sprintf("Error making request to %s: %s", templatesURL, err.Error()), http.StatusInternalServerError) + log.Print(err) + return + } + defer resp.Body.Close() + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + http.Error(w, "Error reading body from templates URL", http.StatusInternalServerError) + log.Print(err) + return + } + w.Header().Set("Content-Type", "application/json") + w.Write(body) +} diff --git a/app/app.js b/app/app.js index 9cb8457d7..6130f5cc4 100644 --- a/app/app.js +++ b/app/app.js @@ -22,6 +22,7 @@ angular.module('uifordocker', [ 'network', 'networks', 'createNetwork', + 'templates', 'volumes', 'createVolume']) .config(['$stateProvider', '$urlRouterProvider', '$httpProvider', function ($stateProvider, $urlRouterProvider, $httpProvider) { @@ -118,6 +119,11 @@ angular.module('uifordocker', [ templateUrl: 'app/components/network/network.html', controller: 'NetworkController' }) + .state('templates', { + url: '/templates/', + templateUrl: 'app/components/templates/templates.html', + controller: 'TemplatesController' + }) .state('volumes', { url: '/volumes/', templateUrl: 'app/components/volumes/volumes.html', @@ -156,4 +162,5 @@ angular.module('uifordocker', [ .constant('DOCKER_ENDPOINT', 'dockerapi') .constant('DOCKER_PORT', '') // Docker port, leave as an empty string if no port is requred. If you have a port, prefix it with a ':' i.e. :4243 .constant('CONFIG_ENDPOINT', 'settings') + .constant('TEMPLATES_ENDPOINT', 'templates') .constant('UI_VERSION', 'v1.7.0'); diff --git a/app/components/templates/templates.html b/app/components/templates/templates.html new file mode 100644 index 000000000..20fea5fa9 --- /dev/null +++ b/app/components/templates/templates.html @@ -0,0 +1,76 @@ + + + + + + + Apps + + +
+
+ + +
+ +
+
+ +
+
+ +
{{ tpl.title }}
+
{{ tpl.comment }}
+
+
+
+
+
+
+ +
+
+ + + +
+
+
+ When using Swarm, we recommend deploying containers in a shared network. Looks like you don't have any shared network, head over the networks view to create one. +
+
+ +
+ +
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+
+
+
+
+
+ +
+
+
+ +
+ +
+
diff --git a/app/components/templates/templatesController.js b/app/components/templates/templatesController.js new file mode 100644 index 000000000..25b69d5c9 --- /dev/null +++ b/app/components/templates/templatesController.js @@ -0,0 +1,170 @@ +angular.module('templates', []) +.controller('TemplatesController', ['$scope', '$q', '$state', 'Config', 'Container', 'Image', 'Volume', 'Network', 'Templates', 'Messages', 'errorMsgFilter', +function ($scope, $q, $state, Config, Container, Image, Volume, Network, Templates, Messages, errorMsgFilter) { +$scope.templates = []; +$scope.selectedTemplate = null; +$scope.formValues = { + network: "", + name: "" +}; + +var selectedItem = -1; + +// TODO: centralize, already present in createContainerController +function createContainer(config) { + Container.create(config, function (d) { + if (d.Id) { + var reqBody = config.HostConfig || {}; + reqBody.id = d.Id; + Container.start(reqBody, function (cd) { + $('#createContainerSpinner').hide(); + Messages.send('Container Started', d.Id); + $state.go('containers', {}, {reload: true}); + }, function (e) { + $('#createContainerSpinner').hide(); + Messages.error('Error', errorMsgFilter(e)); + }); + } else { + $('#createContainerSpinner').hide(); + Messages.error('Error', errorMsgFilter(d)); + } + }, function (e) { + $('#createContainerSpinner').hide(); + Messages.error('Error', errorMsgFilter(e)); + }); +} + +// TODO: centralize, already present in createContainerController +function pullImageAndCreateContainer(imageConfig, containerConfig) { + Image.create(imageConfig, function (data) { + var err = data.length > 0 && data[data.length - 1].hasOwnProperty('error'); + if (err) { + var detail = data[data.length - 1]; + $('#createContainerSpinner').hide(); + Messages.error('Error', detail.error); + } else { + createContainer(containerConfig); + } + }, function (e) { + $('#createContainerSpinner').hide(); + Messages.error('Error', 'Unable to pull image ' + image); + }); +} + +function createConfigFromTemplate(template) { + var containerConfig = { + Env: [], + OpenStdin: false, + Tty: false, + ExposedPorts: {}, + HostConfig: { + RestartPolicy: { + Name: 'no' + }, + PortBindings: {}, + Binds: [], + NetworkMode: $scope.formValues.network, + Privileged: false + }, + Image: template.image, + Volumes: {}, + name: $scope.formValues.name + }; + if (template.env) { + template.env.forEach(function (v) { + if (v.value) { + containerConfig.Env.push(v.name + "=" + v.value); + } + }); + } + if (template.ports) { + template.ports.forEach(function (p) { + containerConfig.ExposedPorts[p] = {}; + containerConfig.HostConfig.PortBindings[p] = [{ HostPort: ""}]; + }); + } + return containerConfig; +} + +function prepareVolumeQueries(template, containerConfig) { + var volumeQueries = []; + if (template.volumes) { + template.volumes.forEach(function (vol) { + volumeQueries.push( + Volume.create({}, function (d) { + if (d.Name) { + Messages.send("Volume created", d.Name); + containerConfig.Volumes[vol] = {}; + containerConfig.HostConfig.Binds.push(d.Name + ':' + vol); + } else { + Messages.error('Unable to create volume', errorMsgFilter(d)); + } + }, function (e) { + Messages.error('Unable to create volume', e.data); + }).$promise + ); + }); + } + return volumeQueries; +} + +$scope.createTemplate = function() { + $('#createContainerSpinner').show(); + var template = $scope.selectedTemplate; + var containerConfig = createConfigFromTemplate(template); + var imageConfig = { + fromImage: template.image.split(':')[0], + tag: template.image.split(':')[1] ? template.image.split(':')[1] : 'latest' + }; + var createVolumeQueries = prepareVolumeQueries(template, containerConfig); + $q.all(createVolumeQueries).then(function (d) { + pullImageAndCreateContainer(imageConfig, containerConfig); + }); +}; + +$scope.selectTemplate = function(id) { + $('#template_' + id).toggleClass("container-template--selected"); + if (selectedItem === id) { + selectedItem = -1; + $scope.selectedTemplate = null; + } else { + $('#template_' + selectedItem).toggleClass("container-template--selected"); + selectedItem = id; + $scope.selectedTemplate = $scope.templates[id]; + } +}; + +function initTemplates() { + Templates.get(function (data) { + $scope.templates = data; + $('#loadTemplatesSpinner').hide(); + }, function (e) { + $('#loadTemplatesSpinner').hide(); + Messages.error("Unable to retrieve apps list", e.data); + }); +} + +Config.$promise.then(function (c) { + var swarm = c.swarm; + Network.query({}, function (d) { + var networks = d; + if (swarm) { + networks = d.filter(function (network) { + if (network.Scope === 'global') { + return network; + } + }); + $scope.globalNetworkCount = networks.length; + networks.push({Name: "bridge"}); + networks.push({Name: "host"}); + networks.push({Name: "none"}); + } else { + $scope.formValues.network = "bridge"; + } + $scope.availableNetworks = networks; + }, function (e) { + Messages.error("Unable to retrieve available networks", e.data); + }); + initTemplates(); +}); +}]); diff --git a/app/shared/services.js b/app/shared/services.js index 495725409..e9ff7da22 100644 --- a/app/shared/services.js +++ b/app/shared/services.js @@ -153,6 +153,11 @@ angular.module('uifordocker.services', ['ngResource', 'ngSanitize']) .factory('Config', ['$resource', 'CONFIG_ENDPOINT', function ConfigFactory($resource, CONFIG_ENDPOINT) { return $resource(CONFIG_ENDPOINT).get(); }]) + .factory('Templates', ['$resource', 'TEMPLATES_ENDPOINT', function TemplatesFactory($resource, TEMPLATES_ENDPOINT) { + return $resource(TEMPLATES_ENDPOINT, {}, { + get: {method: 'GET', isArray: true} + }); + }]) .factory('Settings', ['DOCKER_ENDPOINT', 'DOCKER_PORT', 'UI_VERSION', function SettingsFactory(DOCKER_ENDPOINT, DOCKER_PORT, UI_VERSION) { 'use strict'; var url = DOCKER_ENDPOINT; diff --git a/assets/css/app.css b/assets/css/app.css index 78fc44f15..6f59d23cd 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -210,3 +210,48 @@ input[type="radio"] { .btn-ico { margin-right: 5px; } + +.template-list { + display: flex; + flex-wrap: wrap; +} + +.container-template { + font-size: 1em; + width: 256px; + height: 128px; + margin: 10px; + padding: 10px; + 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: #f6f6f6; + color: #2d3e63; +} + +.container-template:hover { + background-color: #f6f6f6; + color: #2d3e63; +} + +.container-template .logo { + max-width: 48px; + max-height: 48px; +} + +.container-template .title { + text-align: center; +} + +.container-template .comment { + text-align: center; + font-size: 0.8em; +} diff --git a/bower.json b/bower.json index 39825602c..7956be813 100644 --- a/bower.json +++ b/bower.json @@ -34,6 +34,7 @@ "angular-ui-select": "~0.17.1", "bootstrap": "~3.3.6", "font-awesome": "~4.6.3", + "Hover": "2.0.2", "jquery": "1.11.1", "jquery.gritter": "1.7.4", "lodash": "4.12.0", diff --git a/gruntFile.js b/gruntFile.js index 86c2f3ebe..2bda73425 100644 --- a/gruntFile.js +++ b/gruntFile.js @@ -87,7 +87,8 @@ module.exports = function (grunt) { 'bower_components/font-awesome/css/font-awesome.min.css', 'bower_components/rdash-ui/dist/css/rdash.min.css', 'bower_components/angular-ui-select/dist/select.min.css', - 'bower_components/xterm.js/src/xterm.css' + 'bower_components/xterm.js/src/xterm.css', + 'bower_components/Hover/css/hover-min.css' ] }, clean: { diff --git a/index.html b/index.html index 389d0056e..371ca6bd3 100644 --- a/index.html +++ b/index.html @@ -41,6 +41,9 @@ +