diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 21febb49bd..59c8d9f32b 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -17,18 +17,18 @@ Before you file an issue or a pull request, read the following tips on how to ke
- [License](#license)
-### Prerequisites for developing Kitematic on Mac
-You will need to install:
+### Prerequisites for developing Kitematic on Mac
+You will need to install:
- The [Docker Toolbox](https://docker.com/toolbox)
- [Node.js](https://nodejs.org/)
- Wine `brew install wine` (only if you want to generate a Windows release on OS X)
-- The latest Xcode from the Apple App Store.
+- The latest Xcode from the Apple App Store.
-### Prerequisites for developing Kitematic on Windows
-You will need to install:
+### Prerequisites for developing Kitematic on Windows
+You will need to install:
- The [Docker Toolbox](https://docker.com/toolbox)
- [Node.js](https://nodejs.org/)
-- Open a command prompt (`cmd`) and run the command `mkdir ~/AppData/Roaming/npm`
+- Open a command prompt (`cmd`) and run the command `mkdir ~/AppData/Roaming/npm`
- [Visual Studio 2013 Community](https://www.visualstudio.com/en-us/products/visual-studio-community-vs.aspx) (or similar) - You do not need to install any optional packages during install.
- [Python](https://www.python.org/downloads/release/python-2710/)
diff --git a/src/actions/ContainerActions.js b/src/actions/ContainerActions.js
index a9c2f4a61c..5470a241b6 100644
--- a/src/actions/ContainerActions.js
+++ b/src/actions/ContainerActions.js
@@ -35,8 +35,8 @@ class ContainerActions {
this.dispatch();
}
- run (name, repo, tag) {
- dockerUtil.run(name, repo, tag);
+ run (name, repo, tag, local=false) {
+ dockerUtil.run(name, repo, tag, local);
}
active (name) {
diff --git a/src/actions/ImageActions.js b/src/actions/ImageActions.js
new file mode 100644
index 0000000000..518f8aaa07
--- /dev/null
+++ b/src/actions/ImageActions.js
@@ -0,0 +1,16 @@
+import alt from '../alt';
+import dockerUtil from '../utils/DockerUtil';
+
+class ImageActions {
+
+ all () {
+ this.dispatch({});
+ dockerUtil.fetchAllImages();
+ }
+
+ destroy (image) {
+ dockerUtil.removeImage(image);
+ }
+}
+
+export default alt.createActions(ImageActions);
diff --git a/src/actions/ImageServerActions.js b/src/actions/ImageServerActions.js
new file mode 100644
index 0000000000..e5c49e7bbd
--- /dev/null
+++ b/src/actions/ImageServerActions.js
@@ -0,0 +1,14 @@
+import alt from '../alt';
+
+class ImageServerActions {
+ constructor () {
+ this.generateActions(
+ 'added',
+ 'updated',
+ 'destroyed',
+ 'error'
+ );
+ }
+}
+
+export default alt.createActions(ImageServerActions);
diff --git a/src/actions/TagActions.js b/src/actions/TagActions.js
index 36ffd843e1..27e071cdd2 100644
--- a/src/actions/TagActions.js
+++ b/src/actions/TagActions.js
@@ -6,6 +6,10 @@ class TagActions {
this.dispatch({repo});
regHubUtil.tags(repo);
}
+
+ localTags (repo, tags) {
+ this.dispatch({repo, tags});
+ }
}
export default alt.createActions(TagActions);
diff --git a/src/components/ImageCard.react.js b/src/components/ImageCard.react.js
index fe979c9ef1..ed55b17020 100644
--- a/src/components/ImageCard.react.js
+++ b/src/components/ImageCard.react.js
@@ -5,6 +5,7 @@ import shell from 'shell';
import RetinaImage from 'react-retina-image';
import metrics from '../utils/MetricsUtil';
import containerActions from '../actions/ContainerActions';
+import imageActions from '../actions/ImageActions';
import containerStore from '../stores/ContainerStore';
import tagStore from '../stores/TagStore';
import tagActions from '../actions/TagActions';
@@ -14,8 +15,8 @@ var ImageCard = React.createClass({
mixins: [Router.Navigation],
getInitialState: function () {
return {
- tags: [],
- chosenTag: 'latest'
+ tags: this.props.tags || [],
+ chosenTag: this.props.chosenTag || 'latest'
};
},
componentDidMount: function () {
@@ -49,11 +50,14 @@ var ImageCard = React.createClass({
private: this.props.image.is_private,
official: this.props.image.namespace === 'library',
userowned: this.props.image.is_user_repo,
- recommended: this.props.image.is_recommended
+ recommended: this.props.image.is_recommended,
+ local: this.props.image.is_local || false
});
let name = containerStore.generateName(this.props.image.name);
- let repo = this.props.image.namespace === 'library' ? this.props.image.name : this.props.image.namespace + '/' + this.props.image.name;
- containerActions.run(name, repo, this.state.chosenTag);
+ let localImage = this.props.image.is_local || false;
+ let repo = (this.props.image.namespace === 'library' || this.props.image.namespace === 'local') ? this.props.image.name : this.props.image.namespace + '/' + this.props.image.name;
+
+ containerActions.run(name, repo, this.state.chosenTag, localImage);
this.transitionTo('containerHome', {name});
},
handleMenuOverlayClick: function () {
@@ -67,7 +71,12 @@ var ImageCard = React.createClass({
handleTagOverlayClick: function () {
let $tagOverlay = $(this.getDOMNode()).find('.tag-overlay');
$tagOverlay.fadeIn(300);
- tagActions.tags(this.props.image.namespace + '/' + this.props.image.name);
+ let localImage = this.props.image.is_local || false;
+ if (localImage) {
+ tagActions.localTags(this.props.image.namespace + '/' + this.props.image.name, this.props.tags);
+ } else {
+ tagActions.tags(this.props.image.namespace + '/' + this.props.image.name);
+ }
},
handleCloseTagOverlay: function () {
let $menuOverlay = $(this.getDOMNode()).find('.menu-overlay');
@@ -75,6 +84,11 @@ var ImageCard = React.createClass({
var $tagOverlay = $(this.getDOMNode()).find('.tag-overlay');
$tagOverlay.fadeOut(300);
},
+ handleDeleteImgClick: function (image) {
+ if (this.state.chosenTag) {
+ imageActions.destroy(image.RepoTags[0].split(':')[0] + ':' + this.state.chosenTag);
+ }
+ },
handleRepoClick: function () {
var repoUri = 'https://hub.docker.com/';
if (this.props.image.namespace === 'library') {
@@ -108,10 +122,9 @@ var ImageCard = React.createClass({
} else if(this.props.image.short_description){
description = this.props.image.short_description;
} else {
- description = "No description.";
+ description = 'No description.';
}
var logoStyle = {
- //backgroundImage: `linear-gradient(-180deg, ${this.props.image.gradient_start} 4%, ${this.props.image.gradient_end} 100%)`
backgroundColor: this.props.image.gradient_start
};
var imgsrc;
@@ -150,21 +163,75 @@ var ImageCard = React.createClass({
);
}
- let favCount = (this.props.image.star_count < 1000) ? numeral(this.props.image.star_count).value() : numeral(this.props.image.star_count).format('0.0a').toUpperCase();
- let pullCount = (this.props.image.pull_count < 1000) ? numeral(this.props.image.pull_count).value() : numeral(this.props.image.pull_count).format('0a').toUpperCase();
- return (
-
+
+ let create;
+ let overlay;
+ if (this.props.image.is_local) {
+ create = (
+
+
+ {this.state.chosenTag}
+
+
+
+
+
+
+ CREATE
+
+
+ );
+ overlay = (
SELECTED TAG: {this.state.chosenTag}
-
-
VIEW ON DOCKER HUB
+
+ Delete Tag
+
Prior to delete, stop all containers
using the above tag
+ );
+ } else {
+ let favCount = (this.props.image.star_count < 1000) ? numeral(this.props.image.star_count).value() : numeral(this.props.image.star_count).format('0.0a').toUpperCase();
+ let pullCount = (this.props.image.pull_count < 1000) ? numeral(this.props.image.pull_count).value() : numeral(this.props.image.pull_count).format('0a').toUpperCase();
+ create = (
+
+
+
+ {favCount}
+
+ {pullCount}
+
+
+
+
+
+ CREATE
+
+
+ );
+
+ overlay = (
+
+
+ SELECTED TAG: {this.state.chosenTag}
+
+
+ VIEW ON DOCKER HUB
+
+
+
+ );
+ }
+ return (
+
+ {overlay}
Please select an image tag.
{tags}
@@ -187,20 +254,7 @@ var ImageCard = React.createClass({
{description}
-
-
-
- {favCount}
-
- {pullCount}
-
-
-
-
-
- CREATE
-
-
+ {create}
);
diff --git a/src/components/NewContainerSearch.react.js b/src/components/NewContainerSearch.react.js
index 410b4e43ff..23550d06a1 100644
--- a/src/components/NewContainerSearch.react.js
+++ b/src/components/NewContainerSearch.react.js
@@ -10,6 +10,8 @@ import repositoryActions from '../actions/RepositoryActions';
import repositoryStore from '../stores/RepositoryStore';
import accountStore from '../stores/AccountStore';
import accountActions from '../actions/AccountActions';
+import imageActions from '../actions/ImageActions';
+import imageStore from '../stores/ImageStore';
var _searchPromise = null;
@@ -20,6 +22,8 @@ module.exports = React.createClass({
query: '',
loading: repositoryStore.loading(),
repos: repositoryStore.all(),
+ images: imageStore.all(),
+ imagesErr: imageStore.error,
username: accountStore.getState().username,
verified: accountStore.getState().verified,
accountLoading: accountStore.getState().loading,
@@ -34,6 +38,7 @@ module.exports = React.createClass({
this.refs.searchInput.getDOMNode().focus();
repositoryStore.listen(this.update);
accountStore.listen(this.updateAccount);
+ imageStore.listen(this.updateImage);
repositoryActions.search();
},
componentWillUnmount: function () {
@@ -51,7 +56,14 @@ module.exports = React.createClass({
currentPage: repositoryStore.getState().currentPage,
totalPage: repositoryStore.getState().totalPage,
previousPage: repositoryStore.getState().previousPage,
- nextPage: repositoryStore.getState().nextPage
+ nextPage: repositoryStore.getState().nextPage,
+ error: repositoryStore.getState().error
+ });
+ },
+ updateImage: function (imgStore) {
+ this.setState({
+ images: imgStore.images,
+ error: imgStore.error
});
},
updateAccount: function () {
@@ -79,7 +91,8 @@ module.exports = React.createClass({
currentPage: page,
previousPage: previousPage,
nextPage: nextPage,
- totalPage: totalPage
+ totalPage: totalPage,
+ error: null
});
_searchPromise = Promise.delay(200).cancellable().then(() => {
@@ -101,11 +114,17 @@ module.exports = React.createClass({
},
handleFilter: function (filter) {
+ this.setState({error: null});
+
// If we're clicking on the filter again - refresh
if (filter === 'userrepos' && this.getQuery().filter === 'userrepos') {
repositoryActions.repos();
}
+ if (filter === 'userimages' && this.getQuery().filter === 'userimages') {
+ imageActions.all();
+ }
+
if (filter === 'recommended' && this.getQuery().filter === 'recommended') {
repositoryActions.recommended();
}
@@ -187,10 +206,16 @@ module.exports = React.createClass({
) : null;
+ let errorMsg = null;
+ if (this.state.error === null || this.state.error.message.indexOf('getaddrinfo ENOTFOUND') !== -1) {
+ errorMsg = 'There was an error contacting Docker Hub.';
+ } else {
+ errorMsg = this.state.error.message.replace('HTTP code is 409 which indicates error: conflict - ', '');
+ }
if (this.state.error) {
results = (
-
There was an error contacting Docker Hub.
+ {errorMsg}
);
paginateResults = null;
@@ -268,6 +293,32 @@ module.exports = React.createClass({
{otherResults}
);
+ } else if (filter === 'userimages') {
+ let userImageItems = this.state.images.map(image => {
+ let repo = image.RepoTags[0].split(':')[0];
+ if (repo.indexOf('/') === -1) {
+ repo = 'local/' + repo;
+ }
+ [image.namespace, image.name] = repo.split('/');
+ image.description = null;
+ let tags = image.tags.join('-');
+ image.star_count = 0;
+ image.is_local = true;
+ return ();
+ });
+ let userImageResults = userImageItems.length ? (
+
+
My Images
+
+ {userImageItems}
+
+
+ ) : null;
+ results = (
+
+ {userImageResults}
+
+ );
} else {
if (this.state.query.length) {
results = (
@@ -316,6 +367,7 @@ module.exports = React.createClass({
All
Recommended
My Repos
+ My Images
diff --git a/src/stores/ImageStore.js b/src/stores/ImageStore.js
new file mode 100644
index 0000000000..984a085545
--- /dev/null
+++ b/src/stores/ImageStore.js
@@ -0,0 +1,56 @@
+import alt from '../alt';
+import imageActions from '../actions/ImageActions';
+import imageServerActions from '../actions/ImageServerActions';
+
+class ImageStore {
+ constructor () {
+ this.bindActions(imageActions);
+ this.bindActions(imageServerActions);
+ this.results = [];
+ this.images = [];
+ this.imagesLoading = false;
+ this.resultsLoading = false;
+ this.error = null;
+ }
+
+ error (error) {
+ this.setState({error: error, imagesLoading: false, resultsLoading: false});
+ }
+
+ clearError () {
+ this.setState({error: null});
+ }
+
+ destroyed (data) {
+ let images = this.images;
+ if ((data && data[1] && data[1].Deleted)) {
+ delete images[data[1].Deleted];
+ }
+ this.setState({error: null});
+ }
+
+ updated (images) {
+ let tags = {};
+ let finalImages = [];
+ images.map((image) => {
+ image.RepoTags.map(repoTags => {
+ let [name, tag] = repoTags.split(':');
+ if (typeof tags[name] !== 'undefined') {
+ finalImages[tags[name]].tags.push(tag);
+ } else {
+ image.tags = [tag];
+ tags[name] = finalImages.length;
+ finalImages.push(image);
+ }
+ });
+ });
+ this.setState({error: null, images: finalImages, imagesLoading: false});
+ }
+
+ static all () {
+ let state = this.getState();
+ return state.images;
+ }
+}
+
+export default alt.createStore(ImageStore);
diff --git a/src/stores/TagStore.js b/src/stores/TagStore.js
index 13a2b980cf..4ddb4c5a05 100644
--- a/src/stores/TagStore.js
+++ b/src/stores/TagStore.js
@@ -21,6 +21,15 @@ class TagStore {
this.emitChange();
}
+ localTags ({repo, tags}) {
+ let data = [];
+ tags.map((value) => {
+ data.push({'name': value});
+ });
+ this.loading[repo] = true;
+ this.tagsUpdated({repo, tags: data || []});
+ }
+
tagsUpdated ({repo, tags}) {
this.tags[repo] = tags;
this.loading[repo] = false;
diff --git a/src/utils/DockerUtil.js b/src/utils/DockerUtil.js
index f6b92f6feb..f107f3558f 100644
--- a/src/utils/DockerUtil.js
+++ b/src/utils/DockerUtil.js
@@ -8,6 +8,8 @@ import util from './Util';
import hubUtil from './HubUtil';
import metrics from '../utils/MetricsUtil';
import containerServerActions from '../actions/ContainerServerActions';
+import imageServerActions from '../actions/ImageServerActions';
+import Promise from 'bluebird';
import rimraf from 'rimraf';
import stream from 'stream';
import JSONStream from 'JSONStream';
@@ -15,13 +17,14 @@ import Promise from 'bluebird';
-export default {
+var DockerUtil = {
host: null,
client: null,
placeholders: {},
stream: null,
eventStream: null,
activeContainerName: null,
+ localImages: null,
setup (ip, name) {
if (!ip && !name) {
@@ -77,6 +80,7 @@ export default {
init () {
this.placeholders = JSON.parse(localStorage.getItem('placeholders')) || {};
this.fetchAllContainers();
+ this.fetchAllImages();
this.listen();
// Resume pulling containers that were previously being pulled
@@ -170,6 +174,7 @@ export default {
});
});
});
+ this.fetchAllImages();
},
fetchContainer (id) {
@@ -210,7 +215,36 @@ export default {
});
},
- run (name, repository, tag) {
+ fetchAllImages () {
+ this.client.listImages((err, list) => {
+ if (err) {
+ imageServerActions.error(err);
+ } else {
+ this.localImages = list;
+ imageServerActions.updated(list);
+ }
+ });
+ },
+
+ removeImage (selectedRepoTag) {
+ this.localImages.some((image) => {
+ image.RepoTags.map(repoTag => {
+ if (repoTag === selectedRepoTag) {
+ this.client.getImage(selectedRepoTag).remove({'force': true}, (err, data) => {
+ if (err) {
+ console.error(err);
+ imageServerActions.error(err);
+ } else {
+ imageServerActions.destroyed(data);
+ }
+ });
+ return true;
+ }
+ });
+ });
+ },
+
+ run (name, repository, tag, local = false) {
tag = tag || 'latest';
let imageName = repository + ':' + tag;
@@ -231,30 +265,33 @@ export default {
this.placeholders[name] = placeholderData;
localStorage.setItem('placeholders', JSON.stringify(this.placeholders));
-
- this.pullImage(repository, tag, error => {
- if (error) {
- containerServerActions.error({name, error});
- return;
- }
-
- if (!this.placeholders[name]) {
- return;
- }
-
+ if (local) {
this.createContainer(name, {Image: imageName, Tty: true, OpenStdin: true});
- },
+ } else {
+ this.pullImage(repository, tag, error => {
+ if (error) {
+ containerServerActions.error({name, error});
+ return;
+ }
- // progress is actually the progression PER LAYER (combined in columns)
- // not total because it's not accurate enough
- progress => {
- containerServerActions.progress({name, progress});
- },
+ if (!this.placeholders[name]) {
+ return;
+ }
+
+ this.createContainer(name, {Image: imageName, Tty: true, OpenStdin: true});
+ },
+
+ // progress is actually the progression PER LAYER (combined in columns)
+ // not total because it's not accurate enough
+ progress => {
+ containerServerActions.progress({name, progress});
+ },
- () => {
- containerServerActions.waiting({name, waiting: true});
- });
+ () => {
+ containerServerActions.waiting({name, waiting: true});
+ });
+ }
},
updateContainer (name, data) {
@@ -474,9 +511,11 @@ export default {
}
stream.setEncoding('utf8');
- stream.pipe(JSONStream.parse()).on('data', data => {
- if (data.status === 'pull' || data.status === 'untag' || data.status === 'delete' || data.status === 'attach') {
- return;
+ stream.on('data', json => {
+ let data = JSON.parse(json);
+
+ if (data.status === 'pull' || data.status === 'untag' || data.status === 'delete' || data.status === 'attach') {
+ this.fetchAllImages();
}
if (data.status === 'destroy') {
@@ -519,6 +558,7 @@ export default {
this.client.pull(repository + ':' + tag, opts, (err, stream) => {
if (err) {
+ console.log('Err: %o', err);
callback(err);
return;
}
@@ -563,7 +603,7 @@ export default {
if (i < leftOverLayers) {
layerAmount += 1;
}
- columns.progress[i] = {layerIDs: [], nbLayers:0 , maxLayers: layerAmount, value: 0.0};
+ columns.progress[i] = {layerIDs: [], nbLayers: 0, maxLayers: layerAmount, value: 0.0};
}
}
@@ -617,3 +657,5 @@ export default {
});
}
};
+
+module.exports = DockerUtil;
diff --git a/styles/new-container.less b/styles/new-container.less
index 7ca9247a3d..1a08ce9807 100644
--- a/styles/new-container.less
+++ b/styles/new-container.less
@@ -67,6 +67,10 @@
justify-content: center;
flex-shrink: 0;
+ .error {
+ color: red;
+ }
+
img {
width: 380px;
}
@@ -160,6 +164,10 @@
font-weight: 500;
margin-right: 0.7rem;
}
+ .results-userimages {
+ border-left: 1px solid @gray-lighter;
+ padding-left: 1.2rem;
+ }
}
}
}
@@ -227,6 +235,31 @@
bottom: 1rem;
right: 1rem;
}
+ .remove {
+ display: flex;
+ flex: 1 auto;
+ justify-content: center;
+ margin: 0.8rem 0 0 0;
+ a {
+ display: block;
+ text-decoration: none;
+ cursor: default;
+ &:focus {
+ outline: 0;
+ }
+ &.active {
+ .btn-delete {
+ opacity: 0.3;
+ }
+ }
+ }
+ }
+ .small {
+ color: red;
+ text-align: center;
+ padding-top: 5px;
+ font-size: 75%;
+ }
}
.tag-overlay {
z-index: 1000;