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 }}
+
+
+
+
+
+
+
+
+
+
+
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 @@
+