feat(global): add templates support ('apps') (#154)

pull/155/head
Anthony Lapenna 2016-08-23 18:09:14 +12:00 committed by GitHub
parent faccf2a651
commit 1c8aa35479
15 changed files with 366 additions and 13 deletions

View File

@ -1,8 +1,9 @@
FROM scratch
FROM centurylink/ca-certs
COPY dist /
VOLUME /data
EXPOSE 9000
ENTRYPOINT ["/ui-for-docker"]

View File

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

View File

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

View File

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

View File

@ -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{

View File

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

27
api/templates.go Normal file
View File

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

View File

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

View File

@ -0,0 +1,76 @@
<rd-header>
<rd-header-title title="Apps list">
<a data-toggle="tooltip" title="Refresh" ui-sref="templates" ui-sref-opts="{reload: true}">
<i class="fa fa-refresh" aria-hidden="true"></i>
</a>
</rd-header-title>
<rd-header-content>Apps</rd-header-content>
</rd-header>
<div class="row">
<div class="col-lg-12 col-md-12 col-xs-12">
<rd-widget>
<rd-widget-header icon="fa-rocket" title="Available apps">
<div class="pull-right">
<i id="loadTemplatesSpinner" class="fa fa-cog fa-2x fa-spin" style="margin-top: 5px;"></i>
</div>
</rd-widget-header>
<rd-widget-body classes="padding">
<div class="template-list">
<div ng-repeat="tpl in templates" class="container-template hvr-grow" id="template_{{ $index }}" ng-click="selectTemplate($index)">
<img class="logo" ng-src="{{ tpl.logo }}" />
<div class="title">{{ tpl.title }}</div>
<div class="comment">{{ tpl.comment }}</div>
</div>
</div>
</rd-widget-body>
</rd-widget>
</div>
</div>
<div class="row" ng-if="selectedTemplate">
<div class="col-lg-12 col-md-12 col-xs-12">
<rd-widget>
<rd-widget-header icon="fa-cogs" title="Configuration"></rd-widget-header>
<rd-widget-body classes="padding">
<form class="form-horizontal">
<div class="form-group" ng-if="globalNetworkCount === 0">
<div class="col-sm-12">
<span class="small text-muted">When using Swarm, we recommend deploying containers in a shared network. Looks like you don't have any shared network, head over the <a ui-sref="networks">networks view</a> to create one.</span>
</div>
</div>
<!-- name-and-network-inputs -->
<div class="form-group">
<label for="image_registry" class="col-sm-2 control-label text-left">Name</label>
<div class="col-sm-4">
<input type="text" class="form-control" ng-model="formValues.name" placeholder="e.g. web (optional)">
</div>
<label for="container_network" class="col-sm-2 control-label text-right">Network</label>
<div class="col-sm-4">
<select class="selectpicker form-control" ng-model="formValues.network">
<option selected disabled hidden value="">Select a network</option>
<option ng-repeat="net in availableNetworks" ng-value="net.Name">{{ net.Name }}</option>
</select>
</div>
</div>
<!-- !name-and-network-inputs -->
<div ng-repeat="var in selectedTemplate.env" class="form-group">
<label for="field_{{ $index }}" class="col-sm-2 control-label text-left">{{ var.label }}</label>
<div class="col-sm-10">
<input type="text" class="form-control" ng-model="var.value" id="field_{{ $index }}">
</div>
</div>
</form>
</rd-widget-body>
</rd-widget>
</div>
</div>
<div class="row" ng-if="selectedTemplate">
<div class="col-lg-12 col-md-12 col-xs-12" style="text-align: center;">
<div>
<i id="createContainerSpinner" class="fa fa-cog fa-3x fa-spin" style="margin-bottom: 5px; display: none;"></i>
</div>
<button type="button" class="btn btn-default btn-lg" ng-disabled="!formValues.network" ng-click="createTemplate()">Create</button>
</div>
</div>

View File

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

View File

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

View File

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

View File

@ -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",

View File

@ -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: {

View File

@ -41,6 +41,9 @@
<li class="sidebar-list">
<a ui-sref="index">Dashboard <span class="menu-icon fa fa-tachometer"></span></a>
</li>
<li class="sidebar-list">
<a ui-sref="templates">Apps <span class="menu-icon fa fa-rocket"></span></a>
</li>
<li class="sidebar-list">
<a ui-sref="containers">Containers <span class="menu-icon fa fa-server"></span></a>
</li>