Merge branch 'jmorgan-fix-transitionto'

This commit is contained in:
Jeffrey Morgan 2015-01-25 13:30:29 -05:00
commit 8812d533d4
49 changed files with 943 additions and 628 deletions

8
.gitignore vendored
View File

@ -5,16 +5,10 @@ node_modules
npm-debug.log npm-debug.log
# Signing Identity # Signing Identity
script/identity identity
# Resources # Resources
resources/virtualbox-*.pkg
resources/boot2docker* resources/boot2docker*
resources/mongod
resources/MONGOD_LICENSE.txt
resources/node
resources/NODE_LICENSE.txt
resources/settings.json
# Cache # Cache
cache cache

View File

@ -15,15 +15,15 @@ Kitematic's documentation and other information can be found at [http://kitemati
### Development ### Development
- `sudo npm install -g less` - `sudo npm install -g less`
- `./script/npm install` - `npm install`
To run the app in development: To run the app in development:
- `./script/gulp` - `npm start`
### Building the Mac OS X Package ### Building the Mac OS X Package
- `./script/release` - `npm run release`
## Uninstalling ## Uninstalling

View File

@ -1,8 +1,7 @@
var _ = require('underscore'); var _ = require('underscore');
var React = require('react'); var $ = require('jquery');
var React = require('react/addons');
var Router = require('react-router'); var Router = require('react-router');
var Convert = require('ansi-to-html');
var convert = new Convert();
var ContainerStore = require('./ContainerStore'); var ContainerStore = require('./ContainerStore');
var docker = require('./docker'); var docker = require('./docker');
var exec = require('exec'); var exec = require('exec');
@ -17,85 +16,70 @@ var RouteHandler = Router.RouteHandler;
var ContainerDetails = React.createClass({ var ContainerDetails = React.createClass({
mixins: [Router.State], mixins: [Router.State],
_oldHeight: 0,
PAGE_LOGS: 'logs',
PAGE_SETTINGS: 'settings',
getInitialState: function () { getInitialState: function () {
return { return {
logs: [] logs: [],
page: this.PAGE_LOGS
}; };
}, },
componentWillReceiveProps: function () { componentWillReceiveProps: function () {
this.update();
this.setState({ this.setState({
logs: [] page: this.PAGE_LOGS
}); });
var self = this; ContainerStore.fetchLogs(this.getParams().name, function () {
var logs = []; this.updateLogs();
var index = 0; }.bind(this));
docker.client().getContainer(this.getParams().name).logs({
follow: false,
stdout: true,
stderr: true,
timestamps: true
}, function (err, stream) {
stream.setEncoding('utf8');
stream.on('data', function (buf) {
// Every other message is a header
if (index % 2 === 1) {
var time = buf.substr(0,buf.indexOf(' '));
var msg = buf.substr(buf.indexOf(' ')+1);
logs.push(convert.toHtml(self._escapeHTML(msg)));
}
index += 1;
});
stream.on('end', function (buf) {
self.setState({logs: logs});
docker.client().getContainer(self.getParams().name).logs({
follow: true,
stdout: true,
stderr: true,
timestamps: true,
tail: 0
}, function (err, stream) {
stream.setEncoding('utf8');
stream.on('data', function (buf) {
// Every other message is a header
if (index % 2 === 1) {
var time = buf.substr(0,buf.indexOf(' '));
var msg = buf.substr(buf.indexOf(' ')+1);
logs.push(convert.toHtml(self._escapeHTML(msg)));
self.setState({logs: logs});
}
index += 1;
});
});
});
});
},
componentWillMount: function () {
this.update();
}, },
componentDidMount: function () { componentDidMount: function () {
ContainerStore.addChangeListener(ContainerStore.CONTAINERS, this.update); ContainerStore.on(ContainerStore.SERVER_PROGRESS_EVENT, this.updateProgress);
ContainerStore.addChangeListener(ContainerStore.PROGRESS, this.update); ContainerStore.on(ContainerStore.SERVER_LOGS_EVENT, this.updateLogs);
}, },
componentWillUnmount: function () { componentWillUnmount: function () {
ContainerStore.removeChangeListener(ContainerStore.CONTAINERS, this.update); ContainerStore.removeListener(ContainerStore.SERVER_PROGRESS_EVENT, this.updateProgress);
ContainerStore.removeChangeListener(ContainerStore.PROGRESS, this.update); ContainerStore.removeListener(ContainerStore.SERVER_LOGS_EVENT, this.updateLogs);
}, },
update: function () { componentDidUpdate: function () {
var name = this.getParams().name; var parent = $('.details-logs');
if (!parent.length) {
return;
}
if (parent.scrollTop() >= this._oldHeight) {
parent.stop();
parent.scrollTop(parent[0].scrollHeight - parent.height());
}
this._oldHeight = parent[0].scrollHeight - parent.height();
},
updateLogs: function (name) {
if (name && name !== this.getParams().name) {
return;
}
this.setState({ this.setState({
container: ContainerStore.container(name), logs: ContainerStore.logs(this.getParams().name)
progress: ContainerStore.progress(name)
}); });
}, },
_escapeHTML: function (html) { updateProgress: function (name) {
var text = document.createTextNode(html); console.log('progress', name, ContainerStore.progress(name));
var div = document.createElement('div'); if (name === this.getParams().name) {
div.appendChild(text); this.setState({
return div.innerHTML; progress: ContainerStore.progress(name)
});
}
},
showLogs: function () {
this.setState({
page: this.PAGE_LOGS
});
},
showSettings: function () {
this.setState({
page: this.PAGE_SETTINGS
});
}, },
handleClick: function (name) { handleClick: function (name) {
var container = this.state.container; var container = this.props.container;
boot2docker.ip(function (err, ip) { boot2docker.ip(function (err, ip) {
var ports = _.map(container.NetworkSettings.Ports, function (value, key) { var ports = _.map(container.NetworkSettings.Ports, function (value, key) {
var portProtocolPair = key.split('/'); var portProtocolPair = key.split('/');
@ -113,7 +97,6 @@ var ContainerDetails = React.createClass({
} }
return res; return res;
}); });
console.log(ports);
exec(['open', ports[0].url], function (err) { exec(['open', ports[0].url], function (err) {
if (err) { throw err; } if (err) { throw err; }
}); });
@ -130,26 +113,19 @@ var ContainerDetails = React.createClass({
return <p key={i} dangerouslySetInnerHTML={{__html: l}}></p>; return <p key={i} dangerouslySetInnerHTML={{__html: l}}></p>;
}); });
if (!this.state.container) { if (!this.props.container) {
return false; return false;
} }
var state; var state;
if (this.state.container.State.Running) { if (this.props.container.State.Running) {
state = <h2 className="status">running</h2>; state = <h2 className="status running">running</h2>;
} else if (this.state.container.State.Restarting) { } else if (this.props.container.State.Restarting) {
state = <h2 className="status">restarting</h2>; state = <h2 className="status restarting">restarting</h2>;
} } else if (this.props.container.State.Paused) {
state = <h2 className="status paused">paused</h2>;
var progress; } else if (this.props.container.State.Downloading) {
if (this.state.progress > 0 && this.state.progress != 1) { state = <h2 className="status">downloading</h2>;
progress = (
<div className="details-progress">
<ProgressBar now={this.state.progress * 100} label="%(percent)s%" />
</div>
);
} else {
progress = <div></div>;
} }
var button; var button;
@ -159,19 +135,75 @@ var ContainerDetails = React.createClass({
button = <a className="btn btn-primary disabled" onClick={this.handleClick}>View</a>; button = <a className="btn btn-primary disabled" onClick={this.handleClick}>View</a>;
} }
var name = this.state.container.Name.replace('/', ''); var body;
if (this.props.container.State.Downloading) {
body = (
<div className="details-progress">
<ProgressBar now={this.state.progress * 100} label="%(percent)s%" />
</div>
);
} else {
if (this.state.page === this.PAGE_LOGS) {
body = (
<div className="details-logs">
<div className="logs">
{logs}
</div>
</div>
);
} else {
body = (
<div className="details-logs">
<div className="settings">
</div>
</div>
);
}
}
var textButtonClasses = React.addons.classSet({
'btn': true,
'btn-action': true,
'only-icon': true,
'active': this.state.page === this.PAGE_LOGS
});
var gearButtonClass = React.addons.classSet({
'btn': true,
'btn-action': true,
'only-icon': true,
'active': this.state.page === this.PAGE_SETTINGS
});
var name = this.props.container.Name;
var image = this.props.container.Config.Image;
return ( return (
<div className="details"> <div className="details">
<div className="details-header"> <div className="details-header">
<h1>{name}</h1> <a className="btn btn-primary" onClick={this.handleClick}>View</a> <div className="details-header-info">
</div> <h1>{name}</h1>{state}<h2 className="image-label">Image</h2><h2 className="image">{image}</h2>
{progress} </div>
<div className="details-logs"> <div className="details-header-actions">
<div className="logs"> <div className="action btn-group">
{logs} <a className="btn btn-action with-icon" onClick={this.handleClick}><span className="icon icon-preview-2"></span><span className="content">View</span></a><a className="btn btn-action with-icon dropdown-toggle"><span className="icon-dropdown icon icon-arrow-37"></span></a>
</div>
<div className="action">
<a className="btn btn-action with-icon dropdown-toggle" onClick={this.handleClick}><span className="icon icon-folder-1"></span> <span className="content">Volumes</span> <span className="icon-dropdown icon icon-arrow-37"></span></a>
</div>
<div className="action">
<a className="btn btn-action with-icon" onClick={this.handleClick}><span className="icon icon-refresh"></span> <span className="content">Restart</span></a>
</div>
<div className="action">
<a className="btn btn-action with-icon" onClick={this.handleClick}><span className="icon icon-window-code-3"></span> <span className="content">Terminal</span></a>
</div>
<div className="details-header-actions-rhs tabs btn-group">
<a className={textButtonClasses} onClick={this.showLogs}><span className="icon icon-text-wrapping-2"></span></a>
<a className={gearButtonClass} onClick={this.showSettings}><span className="icon icon-setting-gear"></span></a>
</div>
</div> </div>
</div> </div>
{body}
</div> </div>
); );
} }

View File

@ -7,40 +7,16 @@ var Modal = require('react-bootstrap/Modal');
var RetinaImage = require('react-retina-image'); var RetinaImage = require('react-retina-image');
var ModalTrigger = require('react-bootstrap/ModalTrigger'); var ModalTrigger = require('react-bootstrap/ModalTrigger');
var ContainerModal = require('./ContainerModal.react'); var ContainerModal = require('./ContainerModal.react');
var ContainerStore = require('./ContainerStore');
var Header = require('./Header.react'); var Header = require('./Header.react');
var docker = require('./docker'); var docker = require('./docker');
var Link = Router.Link;
var RouteHandler = Router.RouteHandler;
var Navigation= Router.Navigation;
var ContainerList = React.createClass({ var ContainerList = React.createClass({
getInitialState: function () {
return {
containers: []
};
},
componentDidMount: function () {
this.updateContainers();
ContainerStore.addChangeListener(ContainerStore.CONTAINERS, this.updateContainers);
},
componentWillMount: function () { componentWillMount: function () {
this._start = Date.now(); this._start = Date.now();
}, },
componentWillUnmount: function () {
ContainerStore.removeChangeListener(ContainerStore.CONTAINERS, this.updateContainers);
},
updateContainers: function () {
// Sort by name
var containers = _.values(ContainerStore.containers()).sort(function (a, b) {
return a.Name.localeCompare(b.Name);
});
this.setState({containers: containers});
},
render: function () { render: function () {
var self = this; var self = this;
var containers = this.state.containers.map(function (container) { var containers = this.props.containers.map(function (container) {
var downloadingImage = null, downloading = false; var downloadingImage = null, downloading = false;
var env = container.Config.Env; var env = container.Config.Env;
if (env.length) { if (env.length) {
@ -76,22 +52,20 @@ var ContainerList = React.createClass({
state = <div className="state state-stopped"></div>; state = <div className="state state-stopped"></div>;
} }
var name = container.Name.replace('/', '');
return ( return (
<Link key={name} data-container={name} to="container" params={{name: name}}> <Router.Link key={container.Name} data-container={name} to="container" params={{name: container.Name}}>
<li> <li>
{state} {state}
<div className="info"> <div className="info">
<div className="name"> <div className="name">
{name} {container.Name}
</div> </div>
<div className="image"> <div className="image">
{imageName} {imageName}
</div> </div>
</div> </div>
</li> </li>
</Link> </Router.Link>
); );
}); });
return ( return (

View File

@ -1,34 +1,49 @@
var async = require('async'); var async = require('async');
var $ = require('jquery'); var $ = require('jquery');
var React = require('react'); var React = require('react/addons');
var Router = require('react-router');
var Modal = require('react-bootstrap/Modal'); var Modal = require('react-bootstrap/Modal');
var RetinaImage = require('react-retina-image'); var RetinaImage = require('react-retina-image');
var ContainerStore = require('./ContainerStore'); var ContainerStore = require('./ContainerStore');
var Navigation = Router.Navigation;
var ContainerModal = React.createClass({ var ContainerModal = React.createClass({
mixins: [Navigation],
_searchRequest: null, _searchRequest: null,
getInitialState: function () { getInitialState: function () {
return { return {
query: '', query: '',
results: [], results: ContainerStore.recommended(),
recommended: ContainerStore.recommended() loading: false,
}; };
}, },
componentDidMount: function () { componentDidMount: function () {
this.refs.searchInput.getDOMNode().focus(); this.refs.searchInput.getDOMNode().focus();
ContainerStore.on(ContainerStore.SERVER_RECOMMENDED_EVENT, this.update);
},
update: function () {
if (!this.state.query.length) {
this.setState({
results: ContainerStore.recommended()
});
}
}, },
search: function (query) { search: function (query) {
if (this._searchRequest) {
this._searchRequest.abort();
this._searchRequest = null;
}
this.setState({
loading: true
});
var self = this; var self = this;
this._searchRequest = $.get('https://registry.hub.docker.com/v1/search?q=' + query, function (result) { this._searchRequest = $.get('https://registry.hub.docker.com/v1/search?q=' + query, function (result) {
self._searchRequest.abort(); self.setState({
query: query,
loading: false
});
self._searchRequest = null; self._searchRequest = null;
if (self.isMounted()) { if (self.isMounted()) {
self.setState(result); self.setState(result);
console.log(result);
} }
}); });
}, },
@ -39,83 +54,108 @@ var ContainerModal = React.createClass({
return; return;
} }
if (this._searchRequest) {
console.log('Cancel');
this._searchRequest.abort();
this._searchRequest = null;
}
clearTimeout(this.timeout); clearTimeout(this.timeout);
var self = this; if (!query.length) {
this.timeout = setTimeout(function () { this.setState({
self.search(query); query: query,
}, 250); results: ContainerStore.recommended()
});
} else {
var self = this;
this.timeout = setTimeout(function () {
self.search(query);
}, 200);
}
}, },
handleClick: function (event) { handleClick: function (event) {
var name = event.target.getAttribute('name'); var name = event.target.getAttribute('name');
var self = this; var self = this;
ContainerStore.create(name, 'latest', function (err, containerName) { ContainerStore.create(name, 'latest', function (err, containerName) {
// this.transitionTo('containers', {container: containerName});
self.props.onRequestHide(); self.props.onRequestHide();
}.bind(this)); });
}, },
render: function () { render: function () {
var self = this; var self = this;
var data = this.state.results.slice(0, 7);
var data; var results;
if (this.state.query) { if (data.length) {
data = this.state.results.splice(0, 7); var items = data.map(function (r) {
} else { var name;
data = this.state.recommended; if (r.is_official) {
} name = <span><RetinaImage src="official.png"/>{r.name}</span>;
var results = data.map(function (r) { } else {
var name; name = <span>{r.name}</span>;
if (r.is_official) { }
name = <span><RetinaImage src="official.png"/>{r.name}</span>; return (
} else { <li key={r.name}>
name = <span>{r.name}</span>; <div className="info">
} <div className="name">
return ( {name}
<li key={r.name}> </div>
<div className="info"> <div className="properties">
<div className="name"> <div className="icon icon-star-9"></div>
{name} <div className="star-count">{r.star_count}</div>
</div>
</div> </div>
<div className="stars"> <div className="action">
<div className="icon icon-star-9"></div> <div className="btn-group">
<div className="star-count">{r.star_count}</div> <a className="btn btn-action" name={r.name} onClick={self.handleClick}>Create</a>
<a className="btn btn-action with-icon dropdown-toggle"><span className="icon-dropdown icon icon-arrow-58"></span></a>
</div>
</div> </div>
</div> </li>
<div className="action"> );
<button className="btn btn-primary" name={r.name} onClick={self.handleClick}>Create</button> });
</div>
</li> results = (
<div className="result-list">
<ul>
{items}
</ul>
</div>
); );
});
var title;
if (this.state.query) {
title = <div className="title">Results</div>;
} else { } else {
title = <div className="title">Recommended</div>; results = (
<div className="no-results">
<h3>
No Results
</h3>
</div>
);
} }
var title = this.state.query ? 'Results' : 'Recommended';
var loadingClasses = React.addons.classSet({
hidden: !this.state.loading,
loading: true
});
var magnifierClasses = React.addons.classSet({
hidden: this.state.loading,
icon: true,
'icon-magnifier': true,
'search-icon': true
});
return ( return (
<Modal {...this.props} animation={false} className="create-modal"> <Modal {...this.props} animation={false} className="create-modal">
<div className="modal-body"> <div className="modal-body">
<section className="search"> <section className="search">
<input type="search" ref="searchInput" className="form-control" placeholder="Find an existing image" onChange={this.handleChange}/> <div className="search-bar">
<input type="search" ref="searchInput" className="form-control" placeholder="Find an existing image" onChange={this.handleChange}/>
<div className={magnifierClasses}></div>
<RetinaImage className={loadingClasses} src="loading.png"/>
</div>
<div className="question"> <div className="question">
<a href="#"><span>What&#39;s an image?</span></a> <a href="#"><span>What&#39;s an image?</span></a>
</div> </div>
<div className="results"> <div className="results">
{title} <div className="title">{title}</div>
<ul> {results}
{results}
</ul>
</div> </div>
</section> </section>
<aside className="custom"> <aside className="custom">
<div className="title">Create a Custom Container</div> <h4 className="title">Create a Custom Container</h4>
</aside> </aside>
</div> </div>
</Modal> </Modal>

View File

@ -1,21 +1,25 @@
var EventEmitter = require('events').EventEmitter; var EventEmitter = require('events').EventEmitter;
var async = require('async'); var async = require('async');
var assign = require('react/lib/Object.assign'); var assign = require('object-assign');
var Stream = require('stream');
var Convert = require('ansi-to-html');
var convert = new Convert();
var docker = require('./docker'); var docker = require('./docker');
var registry = require('./registry'); var registry = require('./registry');
var $ = require('jquery'); var $ = require('jquery');
var _ = require('underscore'); var _ = require('underscore');
// Merge our store with Node's Event Emitter var _recommended = [];
var _containers = {};
var _progress = {};
var _logs = {};
var ContainerStore = assign(EventEmitter.prototype, { var ContainerStore = assign(EventEmitter.prototype, {
CONTAINERS: 'containers', CLIENT_CONTAINER_EVENT: 'client_container',
PROGRESS: 'progress', SERVER_CONTAINER_EVENT: 'server_container',
LOGS: 'logs', SERVER_PROGRESS_EVENT: 'server_progress',
RECOMMENDED: 'recommended', SERVER_RECOMMENDED_EVENT: 'server_recommended_event',
_recommended: [], SERVER_LOGS_EVENT: 'server_logs',
_containers: {},
_progress: {},
_logs: {},
_pullScratchImage: function (callback) { _pullScratchImage: function (callback) {
var image = docker.client().getImage('scratch:latest'); var image = docker.client().getImage('scratch:latest');
image.inspect(function (err, data) { image.inspect(function (err, data) {
@ -36,32 +40,98 @@ var ContainerStore = assign(EventEmitter.prototype, {
} }
}); });
}, },
_pullImage: function (repository, tag, callback, progressCallback) {
registry.layers(repository, tag, function (err, layerSizes) {
// TODO: Support v2 registry API
// TODO: clean this up- It's messy to work with pulls from both the v1 and v2 registry APIs
// Use the per-layer pull progress % to update the total progress.
docker.client().listImages({all: 1}, function(err, images) {
var existingIds = new Set(images.map(function (image) {
return image.Id.slice(0, 12);
}));
var layersToDownload = layerSizes.filter(function (layerSize) {
return !existingIds.has(layerSize.Id);
});
var totalBytes = layersToDownload.map(function (s) { return s.size; }).reduce(function (pv, sv) { return pv + sv; }, 0);
docker.client().pull(repository + ':' + tag, function (err, stream) {
stream.setEncoding('utf8');
var layerProgress = layersToDownload.reduce(function (r, layer) {
if (_.findWhere(images, {Id: layer.Id})) {
r[layer.Id] = 100;
} else {
r[layer.Id] = 0;
}
return r;
}, {});
stream.on('data', function (str) {
var data = JSON.parse(str);
console.log(data);
if (data.status === 'Already exists') {
layerProgress[data.id] = 1;
} else if (data.status === 'Downloading') {
var current = data.progressDetail.current;
var total = data.progressDetail.total;
var layerFraction = current / total;
layerProgress[data.id] = layerFraction;
}
var chunks = layersToDownload.map(function (s) {
return layerProgress[s.Id] * s.size;
});
var totalReceived = chunks.reduce(function (pv, sv) {
return pv + sv;
});
var totalProgress = totalReceived / totalBytes;
progressCallback(totalProgress);
});
stream.on('end', function () {
callback();
});
});
});
});
},
_escapeHTML: function (html) {
var text = document.createTextNode(html);
var div = document.createElement('div');
div.appendChild(text);
return div.innerHTML;
},
_createContainer: function (image, name, callback) { _createContainer: function (image, name, callback) {
var existing = docker.client().getContainer(name); var existing = docker.client().getContainer(name);
var self = this;
existing.remove(function (err, data) { existing.remove(function (err, data) {
console.log('Placeholder removed.');
docker.client().createContainer({ docker.client().createContainer({
Image: image, Image: image,
Tty: false, Tty: false,
name: name name: name,
User: 'root'
}, function (err, container) { }, function (err, container) {
if (err) { if (err) {
callback(err, null); callback(err, null);
return; return;
} }
console.log('Created container: ' + container.id);
container.start({ container.start({
PublishAllPorts: true PublishAllPorts: true
}, function (err) { }, function (err) {
if (err) { callback(err, null); return; } if (err) {
console.log('Started container: ' + container.id); callback(err);
callback(null, container); return;
}
self.fetchContainer(name, callback);
}); });
}); });
}); });
}, },
_createPlaceholderContainer: function (imageName, name, callback) { _createPlaceholderContainer: function (imageName, name, callback) {
console.log('_createPlaceholderContainer', imageName, name); var self = this;
this._pullScratchImage(function (err) { this._pullScratchImage(function (err) {
if (err) { if (err) {
callback(err); callback(err);
@ -77,7 +147,11 @@ var ContainerStore = assign(EventEmitter.prototype, {
Cmd: 'placeholder', Cmd: 'placeholder',
name: name name: name
}, function (err, container) { }, function (err, container) {
callback(err, container); if (err) {
callback(err);
return;
}
self.fetchContainer(name, callback);
}); });
}); });
}, },
@ -86,7 +160,7 @@ var ContainerStore = assign(EventEmitter.prototype, {
var count = 1; var count = 1;
var name = base; var name = base;
while (true) { while (true) {
var exists = _.findWhere(_.values(this._containers), {Name: '/' + name}) || _.findWhere(_.values(this._containers), {Name: name}); var exists = _.findWhere(_.values(_containers), {Name: name}) || _.findWhere(_.values(_containers), {Name: name});
if (!exists) { if (!exists) {
return name; return name;
} else { } else {
@ -95,82 +169,97 @@ var ContainerStore = assign(EventEmitter.prototype, {
} }
} }
}, },
init: function (callback) { _resumePulling: function () {
// TODO: Load cached data from db on loading var downloading = _.filter(_.values(_containers), function (container) {
return container.State.Downloading;
});
// Refresh with docker & hook into events // Recover any pulls that were happening
var self = this; var self = this;
this.update(function (err) { downloading.forEach(function (container) {
self.updateRecommended(function (err) { docker.client().pull(container.KitematicDownloadingImage, function (err, stream) {
callback();
});
var downloading = _.filter(_.values(self._containers), function (container) {
var env = container.Config.Env;
return _.indexOf(env, 'KITEMATIC_DOWNLOADING=true') !== -1;
});
// Recover any pulls that were happening
downloading.forEach(function (container) {
var env = _.object(container.Config.Env.map(function (e) {
return e.split('=');
}));
docker.client().pull(env.KITEMATIC_DOWNLOADING_IMAGE, function (err, stream) {
stream.setEncoding('utf8');
stream.on('data', function (data) {
console.log(data);
});
stream.on('end', function () {
self._createContainer(env.KITEMATIC_DOWNLOADING_IMAGE, container.Name.replace('/', ''), function () {
});
});
});
});
docker.client().getEvents(function (err, stream) {
stream.setEncoding('utf8'); stream.setEncoding('utf8');
stream.on('data', function (data) { stream.on('data', function (data) {});
console.log(data); stream.on('end', function () {
self._createContainer(container.KitematicDownloadingImage, container.Name, function () {});
// TODO: Dont refresh on deleting placeholder containers
var deletingPlaceholder = data.status === 'destroy' && self.container(data.id) && self.container(data.id).Config.Env.indexOf('KITEMATIC_DOWNLOADING=true') !== -1;
console.log(deletingPlaceholder);
if (!deletingPlaceholder) {
self.update(function (err) {
console.log('Updated container data.');
});
}
}); });
}); });
}); });
}, },
update: function (callback) { _startListeningToEvents: function () {
docker.client().getEvents(function (err, stream) {
if (stream) {
stream.setEncoding('utf8');
stream.on('data', this._dockerEvent.bind(this));
}
}.bind(this));
},
_dockerEvent: function (json) {
var data = JSON.parse(json);
console.log(data);
// If the event is delete, remove the container
if (data.status === 'destroy') {
var container = _.findWhere(_.values(_containers), {Id: data.id});
delete _containers[container.Name];
this.emit(this.SERVER_CONTAINER_EVENT, container.Name, data.status);
} else {
this.fetchContainer(data.id, function (err) {
var container = _.findWhere(_.values(_containers), {Id: data.id});
this.emit(this.SERVER_CONTAINER_EVENT, container ? container.Name : null, data.status);
}.bind(this));
}
},
init: function (callback) {
// TODO: Load cached data from db on loading
this.fetchAllContainers(function (err) {
callback();
this.emit(this.CLIENT_CONTAINER_EVENT);
this.fetchRecommended(function (err) {
this.emit(this.SERVER_RECOMMENDED_EVENT);
}.bind(this));
this._resumePulling();
this._startListeningToEvents();
}.bind(this));
},
fetchContainer: function (id, callback) {
docker.client().getContainer(id).inspect(function (err, container) {
if (err) {
callback(err);
} else {
// Fix leading slash in container names
container.Name = container.Name.replace('/', '');
// Add Downloading State (stored in environment variables) to containers for Kitematic
var env = _.object(container.Config.Env.map(function (e) { return e.split('='); }));
container.State.Downloading = !!env.KITEMATIC_DOWNLOADING;
container.KitematicDownloadingImage = env.KITEMATIC_DOWNLOADING_IMAGE;
_containers[container.Name] = container;
callback(null, container);
}
});
},
fetchAllContainers: function (callback) {
var self = this; var self = this;
docker.client().listContainers({all: true}, function (err, containers) { docker.client().listContainers({all: true}, function (err, containers) {
if (err) { if (err) {
callback(err); callback(err);
return; return;
} }
async.map(containers, function(container, callback) { async.map(containers, function (container, callback) {
docker.client().getContainer(container.Id).inspect(function (err, data) { self.fetchContainer(container.Id, function (err) {
callback(err, data); callback(err);
}); });
}, function (err, results) { }, function (err, results) {
if (err) { callback(err);
callback(err);
return;
}
var containers = {};
results.forEach(function (r) {
containers[r.Name.replace('/', '')] = r;
});
self._containers = containers;
self.emit(self.CONTAINERS);
callback(null);
}); });
}); });
}, },
updateRecommended: function (callback) { fetchRecommended: function (callback) {
if (_recommended.length) {
return;
}
var self = this; var self = this;
$.ajax({ $.ajax({
url: 'https://kitematic.com/recommended.json', url: 'https://kitematic.com/recommended.json',
@ -185,7 +274,7 @@ var ContainerStore = assign(EventEmitter.prototype, {
})); }));
}); });
}, function (err, results) { }, function (err, results) {
self._recommended = results; _recommended = results;
callback(); callback();
}); });
}, },
@ -194,6 +283,54 @@ var ContainerStore = assign(EventEmitter.prototype, {
} }
}); });
}, },
fetchLogs: function (name, callback) {
if (_logs[name]) {
callback();
}
_logs[name] = [];
var index = 0;
var self = this;
docker.client().getContainer(name).logs({
follow: false,
stdout: true,
stderr: true,
timestamps: true
}, function (err, stream) {
stream.setEncoding('utf8');
stream.on('data', function (buf) {
// Every other message is a header
if (index % 2 === 1) {
var time = buf.substr(0,buf.indexOf(' '));
var msg = buf.substr(buf.indexOf(' ')+1);
_logs[name].push(convert.toHtml(self._escapeHTML(msg)));
self.emit(self.SERVER_LOGS_EVENT, name);
}
index += 1;
});
stream.on('end', function (buf) {
callback();
docker.client().getContainer(name).logs({
follow: true,
stdout: true,
stderr: true,
timestamps: true,
tail: 0
}, function (err, stream) {
stream.setEncoding('utf8');
stream.on('data', function (buf) {
// Every other message is a header
if (index % 2 === 1) {
var time = buf.substr(0,buf.indexOf(' '));
var msg = buf.substr(buf.indexOf(' ')+1);
_logs[name].push(convert.toHtml(self._escapeHTML(msg)));
self.emit(self.SERVER_LOGS_EVENT, name);
}
index += 1;
});
});
});
});
},
create: function (repository, tag, callback) { create: function (repository, tag, callback) {
tag = tag || 'latest'; tag = tag || 'latest';
var self = this; var self = this;
@ -205,104 +342,57 @@ var ContainerStore = assign(EventEmitter.prototype, {
if (!data) { if (!data) {
// Pull image // Pull image
self._createPlaceholderContainer(imageName, containerName, function (err, container) { self._createPlaceholderContainer(imageName, containerName, function (err, container) {
if (err) { _containers[containerName] = container;
console.log(err); self.emit(self.CLIENT_CONTAINER_EVENT, containerName, 'create');
} _progress[containerName] = 0;
registry.layers(repository, tag, function (err, layerSizes) { self._pullImage(repository, tag, function () {
if (err) { self._createContainer(imageName, containerName, function (err, container) {
callback(err); delete _progress[containerName];
}
// TODO: Support v2 registry API
// TODO: clean this up- It's messy to work with pulls from both the v1 and v2 registry APIs
// Use the per-layer pull progress % to update the total progress.
docker.client().listImages({all: 1}, function(err, images) {
var existingIds = new Set(images.map(function (image) {
return image.Id.slice(0, 12);
}));
var layersToDownload = layerSizes.filter(function (layerSize) {
return !existingIds.has(layerSize.Id);
});
var totalBytes = layersToDownload.map(function (s) { return s.size; }).reduce(function (pv, sv) { return pv + sv; }, 0);
docker.client().pull(imageName, function (err, stream) {
callback(null, containerName);
stream.setEncoding('utf8');
var layerProgress = layersToDownload.reduce(function (r, layer) {
if (_.findWhere(images, {Id: layer.Id})) {
r[layer.Id] = 100;
} else {
r[layer.Id] = 0;
}
return r;
}, {});
self._progress[containerName] = 0;
stream.on('data', function (str) {
console.log(str);
var data = JSON.parse(str);
if (data.status === 'Already exists') {
layerProgress[data.id] = 1;
} else if (data.status === 'Downloading') {
var current = data.progressDetail.current;
var total = data.progressDetail.total;
var layerFraction = current / total;
layerProgress[data.id] = layerFraction;
}
var chunks = layersToDownload.map(function (s) {
return layerProgress[s.Id] * s.size;
});
var totalReceived = chunks.reduce(function (pv, sv) {
return pv + sv;
});
var totalProgress = totalReceived / totalBytes;
self._progress[containerName] = totalProgress;
self.emit(self.PROGRESS);
});
stream.on('end', function () {
self._createContainer(imageName, containerName, function () {
delete self._progress[containerName];
});
});
});
}); });
}, function (progress) {
_progress[containerName] = progress;
self.emit(self.SERVER_PROGRESS_EVENT, containerName);
}); });
callback(null, containerName);
}); });
} else { } else {
// If not then directly create the container // If not then directly create the container
self._createContainer(imageName, containerName, function () { self._createContainer(imageName, containerName, function (err, container) {
self.emit(ContainerStore.CLIENT_CONTAINER_EVENT, containerName, 'create');
callback(null, containerName); callback(null, containerName);
}); });
} }
}); });
}, },
containers: function() { containers: function() {
return this._containers; return _containers;
}, },
container: function (name) { container: function (name) {
return this._containers[name]; return _containers[name];
},
sorted: function () {
return _.values(_containers).sort(function (a, b) {
var active = function (container) {
return container.State.Running || container.State.Restarting || container.State.Downloading;
};
if (active(a) && !active(b)) {
return -1;
} else if (!active(a) && active(b)) {
return 1;
} else {
return a.Name.localeCompare(b.Name);
}
});
}, },
recommended: function () { recommended: function () {
return this._recommended; return _recommended;
}, },
progress: function (name) { progress: function (name) {
return this._progress[name]; return _progress[name];
}, },
logs: function (name) { logs: function (name) {
return logs[name]; return _logs[name] || [];
}, }
addChangeListener: function(eventType, callback) {
this.on(eventType, callback);
},
removeChangeListener: function(eventType, callback) {
this.removeListener(eventType, callback);
},
}); });
module.exports = ContainerStore; module.exports = ContainerStore;

View File

@ -12,15 +12,51 @@ var _ = require('underscore');
var docker = require('./docker'); var docker = require('./docker');
var $ = require('jquery'); var $ = require('jquery');
var Link = Router.Link;
var RouteHandler = Router.RouteHandler;
var Containers = React.createClass({ var Containers = React.createClass({
mixins: [Router.Navigation, Router.State],
getInitialState: function () { getInitialState: function () {
return { return {
sidebarOffset: 0 sidebarOffset: 0,
containers: ContainerStore.containers(),
sorted: ContainerStore.sorted(),
}; };
}, },
componentDidMount: function () {
this.update();
ContainerStore.on(ContainerStore.SERVER_CONTAINER_EVENT, this.update);
ContainerStore.on(ContainerStore.CLIENT_CONTAINER_EVENT, this.updateFromClient);
if (this.state.sorted.length) {
this.transitionTo('container', {name: this.state.sorted[0].Name});
}
},
componentDidUnmount: function () {
ContainerStore.removeListener(ContainerStore.SERVER_CONTAINER_EVENT, this.update);
ContainerStore.removeListener(ContainerStore.CLIENT_CONTAINER_EVENT, this.updateFromClient);
},
update: function (name, status) {
this.setState({
containers: ContainerStore.containers(),
sorted: ContainerStore.sorted()
});
if (status === 'destroy') {
if (this.state.sorted.length) {
this.transitionTo('container', {name: this.state.sorted[0].Name});
} else {
this.transitionTo('containers');
}
}
},
updateFromClient: function (name, status) {
this.setState({
containers: ContainerStore.containers(),
sorted: ContainerStore.sorted()
});
if (status === 'create') {
console.log('transition');
this.transitionTo('container', {name: name});
}
},
handleScroll: function (e) { handleScroll: function (e) {
if (e.target.scrollTop > 0 && !this.state.sidebarOffset) { if (e.target.scrollTop > 0 && !this.state.sidebarOffset) {
this.setState({ this.setState({
@ -37,26 +73,25 @@ var Containers = React.createClass({
if (this.state.sidebarOffset) { if (this.state.sidebarOffset) {
sidebarHeaderClass += ' sep'; sidebarHeaderClass += ' sep';
} }
return ( return (
<div className="containers"> <div className="containers">
<Header/> <Header/>
<div className="containers-body"> <div className="containers-body">
<div className="sidebar"> <div className="sidebar">
<section className={sidebarHeaderClass}> <section className={sidebarHeaderClass}>
<h3>containers</h3> <h4>My Containers</h4>
<div className="create"> <div className="create">
<ModalTrigger modal={<ContainerModal/>}> <ModalTrigger modal={<ContainerModal/>}>
<div className="wrapper"> <a className="btn btn-action only-icon"><span className="icon icon-add-1"></span></a>
<span className="icon icon-add-3"></span>
</div>
</ModalTrigger> </ModalTrigger>
</div> </div>
</section> </section>
<section className="sidebar-containers" onScroll={this.handleScroll}> <section className="sidebar-containers" onScroll={this.handleScroll}>
<ContainerList/> <ContainerList containers={this.state.sorted}/>
</section> </section>
</div> </div>
<RouteHandler/> <Router.RouteHandler container={this.state.containers[this.getParams().name]}/>
</div> </div>
</div> </div>
); );

View File

@ -2,6 +2,11 @@ var React = require('react/addons');
var remote = require('remote'); var remote = require('remote');
var Header = React.createClass({ var Header = React.createClass({
getInitialState: function () {
return {
fullscreen: false
};
},
componentDidMount: function () { componentDidMount: function () {
document.addEventListener('keyup', this.handleDocumentKeyUp, false); document.addEventListener('keyup', this.handleDocumentKeyUp, false);
}, },
@ -22,14 +27,16 @@ var Header = React.createClass({
}, },
handleFullscreen: function () { handleFullscreen: function () {
remote.getCurrentWindow().setFullScreen(!remote.getCurrentWindow().isFullScreen()); remote.getCurrentWindow().setFullScreen(!remote.getCurrentWindow().isFullScreen());
this.forceUpdate(); this.setState({
fullscreen: remote.getCurrentWindow().isFullScreen()
});
}, },
handleFullscreenHover: function () { handleFullscreenHover: function () {
this.update(); this.update();
}, },
render: function () { render: function () {
var buttons; var buttons;
if (remote.getCurrentWindow().isFullScreen()) { if (this.state.fullscreen) {
return ( return (
<div className="header no-drag"> <div className="header no-drag">
<div className="buttons"> <div className="buttons">

14
app/NoContainers.react.js Normal file
View File

@ -0,0 +1,14 @@
var React = require('react/addons');
var RetinaImage = require('react-retina-image');
var NoContainers = React.createClass({
render: function () {
return (
<div className="no-containers">
<h3>No Containers</h3>
</div>
);
}
});
module.exports = NoContainers;

View File

@ -1,4 +1,4 @@
var React = require('react'); var React = require('react/addons');
var Router = require('react-router'); var Router = require('react-router');
var Radial = require('./Radial.react.js'); var Radial = require('./Radial.react.js');
var async = require('async'); var async = require('async');
@ -134,6 +134,12 @@ var setupSteps = [
var Setup = React.createClass({ var Setup = React.createClass({
mixins: [ Router.Navigation ], mixins: [ Router.Navigation ],
getInitialState: function () {
return {
message: '',
progress: 0
};
},
render: function () { render: function () {
var radial; var radial;
if (this.state.progress) { if (this.state.progress) {

Binary file not shown.

After

Width:  |  Height:  |  Size: 208 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 412 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 276 B

After

Width:  |  Height:  |  Size: 272 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 555 B

After

Width:  |  Height:  |  Size: 563 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 410 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 852 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 619 B

After

Width:  |  Height:  |  Size: 618 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 807 B

After

Width:  |  Height:  |  Size: 572 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 359 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 731 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 536 B

After

Width:  |  Height:  |  Size: 527 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 252 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 461 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 355 B

After

Width:  |  Height:  |  Size: 340 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 654 B

After

Width:  |  Height:  |  Size: 626 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 349 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 729 B

View File

@ -1,15 +1,16 @@
var module = require('module');
require.main.paths.splice(0, 0, process.env.NODE_PATH);
var Bugsnag = require('bugsnag-js');
var React = require('react'); var React = require('react');
var Router = require('react-router'); var Router = require('react-router');
var RetinaImage = require('react-retina-image'); var RetinaImage = require('react-retina-image');
var Raven = require('raven');
var async = require('async'); var async = require('async');
var docker = require('./docker.js'); var docker = require('./docker');
var boot2docker = require('./boot2docker.js'); var router = require('./router');
var Setup = require('./Setup.react'); var boot2docker = require('./boot2docker');
var Containers = require('./Containers.react');
var ContainerDetails = require('./ContainerDetails.react');
var ContainerStore = require('./ContainerStore'); var ContainerStore = require('./ContainerStore');
var Radial = require('./Radial.react'); var app = require('remote').require('app');
var Route = Router.Route; var Route = Router.Route;
var NotFoundRoute = Router.NotFoundRoute; var NotFoundRoute = Router.NotFoundRoute;
@ -17,53 +18,23 @@ var DefaultRoute = Router.DefaultRoute;
var Link = Router.Link; var Link = Router.Link;
var RouteHandler = Router.RouteHandler; var RouteHandler = Router.RouteHandler;
var App = React.createClass({ Bugsnag.apiKey = 'fc51aab02ce9dd1bb6ebc9fe2f4d43d7';
render: function () { Bugsnag.autoNotify = true;
return ( Bugsnag.releaseStage = process.env.NODE_ENV === 'development' ? 'development' : 'production';
<RouteHandler/> Bugsnag.notifyReleaseStages = [];
); Bugsnag.appVersion = app.getVersion();
}
});
var NoContainers = React.createClass({ if (window.location.hash === '#/') {
render: function () { router.run(function (Handler) {
return ( React.render(<Handler/>, document.body);
<div> });
No Containers } else {
</div> boot2docker.ip(function (err, ip) {
);
}
});
var routes = (
<Route name="app" path="/" handler={App}>
<Route name="containers" handler={Containers}>
<Route name="container" path=":name" handler={ContainerDetails}>
</Route>
<DefaultRoute handler={NoContainers}/>
</Route>
<DefaultRoute handler={Setup}/>
<Route name="setup" handler={Setup}>
</Route>
</Route>
);
boot2docker.ip(function (err, ip) {
if (window.location.hash !== '#/') {
docker.setHost(ip); docker.setHost(ip);
ContainerStore.init(function () { ContainerStore.init(function () {
Router.run(routes, function (Handler) { router.run(function (Handler) {
React.render(<Handler/>, document.body); React.render(<Handler/>, document.body);
}); });
}); });
} else { });
Router.run(routes, function (Handler) {
React.render(<Handler/>, document.body);
});
}
});
if (process.env.NODE_ENV !== 'development') {
Raven.config('https://0a5f032d745d4acaae94ce46f762c586@app.getsentry.com/35057', {
}).install();
} }

8
app/router.js Normal file
View File

@ -0,0 +1,8 @@
var Router = require('react-router');
var routes = require('./routes');
var router = Router.create({
routes: routes
});
module.exports = router;

33
app/routes.js Normal file
View File

@ -0,0 +1,33 @@
var React = require('react/addons');
var Setup = require('./Setup.react');
var Containers = require('./Containers.react');
var ContainerDetails = require('./ContainerDetails.react');
var NoContainers = require('./NoContainers.react');
var Router = require('react-router');
var Route = Router.Route;
var DefaultRoute = Router.DefaultRoute;
var RouteHandler = Router.RouteHandler;
var App = React.createClass({
render: function () {
return (
<RouteHandler/>
);
}
});
var routes = (
<Route name="app" path="/" handler={App}>
<Route name="containers" handler={Containers}>
<Route name="container" path=":name" handler={ContainerDetails}>
</Route>
<DefaultRoute handler={NoContainers}/>
</Route>
<DefaultRoute handler={Setup}/>
<Route name="setup" handler={Setup}>
</Route>
</Route>
);
module.exports = routes;

View File

@ -10,7 +10,7 @@
.sidebar { .sidebar {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-width: 240px; min-width: 280px;
margin: 0; margin: 0;
box-sizing: border-box; box-sizing: border-box;
border-right: 1px solid #eee; border-right: 1px solid #eee;
@ -22,49 +22,36 @@
display: flex; display: flex;
border-bottom: 1px solid transparent; border-bottom: 1px solid transparent;
transition: border-bottom 0.25s; transition: border-bottom 0.25s;
padding: 0px 10px 0px 10px;
&.sep { &.sep {
border-bottom: 1px solid #eee; border-bottom: 1px solid #eee;
box-shadow: 0px 2px 3px 0px rgba(0,0,0,0.03); box-shadow: 0px 2px 3px 0px rgba(0,0,0,0.03);
} }
h3 { h4 {
align-self: flex-start; align-self: flex-start;
color: #CCD3D5;
font-size: 18px;
font-weight: 400;
padding: 0 24px; padding: 0 24px;
margin: 10px 0 0; margin: 14px 0 0;
font-variant: small-caps;
display: inline-block; display: inline-block;
font-size: 14px;
position: relative; position: relative;
} }
.create { .create {
flex: 1 auto; flex: 1 auto;
text-align: right; text-align: right;
.btn {
.wrapper { margin-top: 4px;
text-align: center; padding: 4px 7px;
display: inline-block; font-size: 16px;
width: 50px; position: relative;
.icon {
span.icon { position: relative;
margin-top: 5px; top: 3px;
margin-left: auto; left: 1px;
display: inline-block;
border-radius: 20px;
font-size: 26px;
color: @brand-primary;
}
&:hover {
span.icon {
color: darken(@brand-primary, 20%);
}
} }
} }
} }
} }
@ -83,6 +70,7 @@
margin: 0; margin: 0;
min-width: 240px; min-width: 240px;
padding: 0; padding: 0;
margin-top: 4px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -91,12 +79,41 @@
color: inherit; color: inherit;
flex-shrink: 0; flex-shrink: 0;
cursor: default; cursor: default;
margin: 0px 3px 0px 8px;
outline: none;
padding: 4px 5px;
&.active { &.active {
background: #eee; li {
border-bottom: none; border-bottom: none;
border-radius: 40px;
background: @brand-primary;
.name {
color: white;
}
.image {
color: white;
opacity: 0.9;
}
&:hover { .state-running {
.at2x('running-white.png', 20px, 20px);
.runningwave {
.at2x('runningwave-white.png', 20px, 20px);
}
}
.state-stopped {
.at2x('stopped-white.png', 20px, 20px);
}
.state-downloading {
.at2x('downloading-white.png', 20px, 20px);
.downloading-arrow {
.at2x('downloading-arrow-white.png', 20px, 20px);
}
}
} }
} }
@ -111,8 +128,7 @@
li { li {
vertical-align: middle; vertical-align: middle;
padding-bottom: 14px; padding: 10px 16px 10px 16px;
margin: 16px 24px 0px;
display: flex; display: flex;
flex-direction: row; flex-direction: row;
@ -127,10 +143,10 @@
overflow: hidden; overflow: hidden;
font-size: 14px; font-size: 14px;
font-weight: 400; font-weight: 400;
color: #555; color: @gray-darkest;
} }
.image { .image {
color: #999; color: @gray-lighter;
font-size: 12px; font-size: 12px;
font-weight: 400; font-weight: 400;
text-overflow: ellipsis; text-overflow: ellipsis;
@ -210,11 +226,21 @@
} }
} }
} }
}
.status { .no-containers {
font-size: 12px; flex: 1 auto;
font-variant: small-caps; display: flex;
color: @brand-primary; align-items: center;
justify-content: center;
flex-direction: column;
position: relative;
h3 {
position: relative;
top: -44px;
font-size: 18px;
color: #C7D7D7;
} }
} }
@ -231,29 +257,69 @@
.details-header { .details-header {
flex: 0 auto; flex: 0 auto;
display: flex; display: flex;
flex-direction: row; flex-direction: column;
padding: 0px 45px 14px; padding: 4px 40px 10px 40px;
position: relative; position: relative;
a { border-bottom: 1px solid #eee;
position: absolute;
right: 30px;
top: -4px;
}
h1 {
font-size: 24px;
margin: 0;
}
h2 {
margin-left: 18px;
font-size: 14px;
font-variant: small-caps;
&.status { .details-header-actions {
color: @brand-success; flex: 0 auto;
display: flex;
flex-direction: row;
margin-top: 24px;
margin-bottom: 6px;
position: relative;
border-bottom: 1px solid transparent;
transition: border-bottom 0.25s;
.action {
flex: 0 auto;
margin-right: 24px;
} }
.details-header-actions-rhs {
flex: 1 auto;
display: flex;
align-items: right;
justify-content: flex-end;
a.btn {
z-index: 0;
}
}
}
&.image { .details-header-info {
display: flex;
flex-direction: row;
a {
position: absolute;
right: 30px;
top: -4px;
}
h1 {
margin: 0;
font-size: 20px;
margin: 0;
color: @gray-darkest;
}
h2 {
&.status {
margin: 8px 0px 0px 16px;
text-transform: uppercase;
font-weight: bold;
font-size: 10px;
&.running {
color: @brand-positive;
}
}
&.image-label {
margin: 8px 0px 0px 30px;
font-size: 10px;
color: @gray-lighter;
}
&.image {
margin: 5px 0px 0px 16px;
font-size: 14px;
color: @gray-normal;
}
} }
} }
} }
@ -266,12 +332,17 @@
.details-logs { .details-logs {
flex: 1; flex: 1;
overflow: auto; overflow: auto;
h4 {
font-size: 14px;
margin-top: 16px;
margin-left: 40px;
}
.logs { .logs {
user-select: text; -webkit-user-select: text;
font-family: Menlo; font-family: Menlo;
font-size: 12px; font-size: 12px;
padding: 44px 45px; padding: 18px 45px;
color: #595D5E; color: lighten(@gray-normal, 6%);
white-space: pre-wrap; white-space: pre-wrap;
p { p {
margin: 0 6px; margin: 0 6px;

View File

@ -3,9 +3,10 @@
.header { .header {
min-width: 100%; min-width: 100%;
flex: 0; flex: 0;
min-height: 48px; min-height: 50px;
-webkit-app-region: drag; -webkit-app-region: drag;
-webkit-user-select: none; -webkit-user-select: none;
// border-bottom: 1px solid #efefef;
&.no-drag { &.no-drag {
-webkit-app-region: no-drag; -webkit-app-region: no-drag;

View File

@ -13,8 +13,13 @@ html, body {
width: 100%; width: 100%;
overflow: hidden; overflow: hidden;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
user-select: none; -webkit-user-select: none;
font-family: 'Clear Sans', sans-serif; font-family: 'Clear Sans', sans-serif;
cursor: default;
img {
pointer-events: none;
}
} }
::-webkit-scrollbar { ::-webkit-scrollbar {
@ -49,18 +54,14 @@ html, body {
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.10); box-shadow: 0 2px 5px rgba(0, 0, 0, 0.10);
border: none; //1px solid #ccc; border: none; //1px solid #ccc;
height: 610px; height: 610px;
display: flex;
} }
.modal-body { .modal-body {
flex: 1 auto;
display: flex; display: flex;
flex-direction: row; flex-direction: row;
padding: 32px 32px; padding: 32px 32px;
.title {
color: #CCD3D5;
font-weight: 400;
font-size: 13px;
}
aside.custom { aside.custom {
flex: 0 auto; flex: 0 auto;
padding-left: 32px; padding-left: 32px;
@ -68,57 +69,100 @@ html, body {
} }
section.search { section.search {
flex: 0 auto;
min-width: 404px; min-width: 404px;
padding-right: 32px; padding-right: 32px;
border-right: 1px solid #eee; border-right: 1px solid #eee;
.question { .question {
a { a {
color: #CCD3D5; color: @gray-lightest;
&:hover {
color: darken(@gray-lightest, 10%);
}
} }
font-size: 10px; font-size: 10px;
text-align: right; text-align: right;
} }
input { .search-bar {
border-radius: 20px; position: relative;
font-size: 13px; .loading {
height: 38px; position: absolute;
padding: 8px 16px; left: 13px;
font-weight: 400; top: 10px;
color: #666; width: 20px;
height: 20px;
&:focus { -webkit-animation-name: spin;
box-shadow: none; -webkit-animation-duration: 1.8s;
border-color: #bbb; -webkit-animation-iteration-count: infinite;
-webkit-animation-timing-function: linear;
} }
.search-icon {
font-size: 20px;
color: @gray-lighter;
position: absolute;
top: 9px;
left: 14px;
}
input {
border-radius: 20px;
font-size: 13px;
height: 38px;
padding: 8px 16px 8px 40px;
color: @gray-darkest;
margin-bottom: 3px;
border-color: @gray-lightest;
box-shadow: none;
&::-webkit-input-placeholder { &:focus {
color: #ddd; box-shadow: none;
font-weight: 300; border-color: @gray-lighter;
}
&::-webkit-input-placeholder {
color: #ddd;
font-weight: 300;
}
} }
} }
.results { .results {
overflow: auto; overflow: auto;
.no-results {
text-align: center;
h3 {
color: #ABC0C0;
font-size: 16px;
margin-top: 160px;
}
}
.title { .title {
flex: 0 auto;
margin-top: 16px; margin-top: 16px;
} }
ul { ul {
margin-top: 10px;
list-style: none; list-style: none;
color: #555;
padding: 0; padding: 0;
li { li {
&:hover {
background-color: lighten(@gray-lightest, 17.5%);
}
display: flex; display: flex;
flex-direction: row; flex-direction: row;
margin: 12px; padding: 8px 14px 5px 14px;
//margin: 12px;
border-bottom: 1px solid #eee; border-bottom: 1px solid #eee;
&:last-child {
border-bottom: 0;
}
.info { .info {
.name { .name {
color: @gray-darkest;
max-width: 278px; max-width: 278px;
img { img {
margin-right: 6px; margin-right: 6px;
@ -129,8 +173,8 @@ html, body {
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.stars { .properties {
color: #A7A7A7; color: @gray-lighter;
margin-top: 2px; margin-top: 2px;
.star-count { .star-count {
@ -152,6 +196,8 @@ html, body {
flex: 0 auto; flex: 0 auto;
} }
.action { .action {
position: relative;
top: 5px;
text-align: right; text-align: right;
flex: 1 auto; flex: 1 auto;
} }
@ -168,7 +214,7 @@ html, body {
height: 100%; height: 100%;
} }
@-webkit-keyframes translatedownload { @-webkit-keyframes spin {
from { from {
-webkit-transform: rotate(0deg); -webkit-transform: rotate(0deg);
} }

View File

@ -6,6 +6,12 @@
@import "bootstrap/mixins.less"; @import "bootstrap/mixins.less";
h4 {
font-size: 13px;
color: @gray-normal;
font-weight: 400;
}
// //
// Buttons // Buttons
// -------------------------------------------------- // --------------------------------------------------
@ -21,32 +27,83 @@
} }
// Mixin for generating new styles // Mixin for generating new styles
.btn-styles(@btn-color: #555) { .btn-styles(@btn-color: @gray-normal) {
transition: all 0.1s;
.reset-filter(); // Disable gradients for IE9 because filter bleeds through rounded corners .reset-filter(); // Disable gradients for IE9 because filter bleeds through rounded corners
background-repeat: repeat-x; border-color: @btn-color;
// border-color: darken(@btn-color, 14%); color: @btn-color;
&:hover, &:hover,
&:focus { &:focus {
background-color: darken(@btn-color, 12%); border-color: darken(@btn-color, 10%);
color: darken(@btn-color, 10%);
cursor: default;
box-shadow: none;
}
&:active {
background-color: lighten(@btn-color, 45%);
border-color: darken(@btn-color, 10%);
color: darken(@btn-color, 10%);
box-shadow: none;
}
&.active {
background-color: @btn-color;
color: white;
box-shadow: none;
box-shadow: none;
} }
&:disabled, &:disabled,
&[disabled] { &[disabled] {
background-color: darken(@btn-color, 12%); opacity: 0.5;
}
}
.btn-group {
.btn {
.icon-dropdown {
&.icon:before {
top: 7px;
margin-left: 0px;
margin-right: 4px;
}
}
} }
} }
// Common styles // Common styles
.btn { .btn {
font-size: 12px;
background-color: transparent;
color: @gray-normal;
border: 1px solid @gray-normal;
border-radius: 25px; border-radius: 25px;
box-shadow: none; box-shadow: none;
font-weight: 400; font-weight: 400;
text-shadow: none; text-shadow: none;
padding: 6px 14px 6px 14px;
height: 32px;
cursor: default;
.content {
position: relative;
top: -4px;
margin-left: 5px;
margin-right: 5px;
}
.icon {
position: relative;
font-size: 16px;
}
// Remove the gradient for the pressed/active state // Remove the gradient for the pressed/active state
&:active, &:active,
&.active { &.active {
background-image: none; background-image: none;
box-shadow: none;
} }
&:focus, &:focus,
@ -57,6 +114,9 @@
} }
// Apply the mixin to the buttons // Apply the mixin to the buttons
.btn-action {
.btn-styles(@brand-action);
}
.btn-default { .btn-styles(@btn-default-bg); } .btn-default { .btn-styles(@btn-default-bg); }
.btn-primary { .btn-styles(@btn-primary-bg); } .btn-primary { .btn-styles(@btn-primary-bg); }
.btn-success { .btn-styles(@btn-success-bg); } .btn-success { .btn-styles(@btn-success-bg); }

View File

@ -1,4 +1,10 @@
@brand-primary: #24B8EB; @brand-primary: #24B8EB;
@brand-action: #49CEF2; @brand-action: #24B8EB;
@brand-positive: #3AD86D; @brand-positive: #65E100;
@brand-negative: #F74B1F; @brand-negative: #F47A45;
@gray-darkest: #253237;
@gray-darker: #394C51;
@gray-normal: #546C70;
@gray-lighter: #7A9999;
@gray-lightest: #C7D7D7;

View File

@ -16,6 +16,8 @@ if (argv.test) {
console.log('Running tests'); console.log('Running tests');
} }
process.env.NODE_PATH = __dirname + '/../node_modules';
app.on('activate-with-no-open-windows', function () { app.on('activate-with-no-open-windows', function () {
if (mainWindow) { if (mainWindow) {
mainWindow.show(); mainWindow.show();
@ -25,13 +27,14 @@ app.on('activate-with-no-open-windows', function () {
app.on('ready', function() { app.on('ready', function() {
var windowOptions = { var windowOptions = {
width: 1200, width: 1000,
height: 800, height: 700,
'min-width': 960, 'min-width': 1000,
'min-height': 700, 'min-height': 700,
resizable: true, resizable: true,
frame: false frame: false
}; };
mainWindow = new BrowserWindow(windowOptions); mainWindow = new BrowserWindow(windowOptions);
mainWindow.hide(); mainWindow.hide();

18
deps Executable file
View File

@ -0,0 +1,18 @@
#!/bin/bash
BASE="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
export BOOT2DOCKER_CLI_VERSION=$(node -pe "JSON.parse(process.argv[1])['boot2docker-version']" "$(cat $BASE/package.json)")
export BOOT2DOCKER_CLI_VERSION_FILE=boot2docker-$BOOT2DOCKER_CLI_VERSION
mkdir -p $BASE/cache
pushd $BASE/resources > /dev/null
if [ ! -f $BOOT2DOCKER_CLI_VERSION_FILE ]; then
echo "-----> Downloading Boot2docker CLI..."
rm -rf boot2docker-*
curl -L -o $BOOT2DOCKER_CLI_VERSION_FILE https://github.com/boot2docker/boot2docker-cli/releases/download/v${BOOT2DOCKER_CLI_VERSION}/boot2docker-v${BOOT2DOCKER_CLI_VERSION}-darwin-amd64
chmod +x $BOOT2DOCKER_CLI_VERSION_FILE
fi
popd > /dev/null

View File

@ -4,8 +4,7 @@ var browserify = require('browserify');
var watchify = require('watchify'); var watchify = require('watchify');
var reactify = require('reactify'); var reactify = require('reactify');
var gulpif = require('gulp-if'); var gulpif = require('gulp-if');
var uglify = require('gulp-uglify'); var uglify = require('gulp-uglifyjs');
var streamify = require('gulp-streamify');
var notify = require('gulp-notify'); var notify = require('gulp-notify');
var concat = require('gulp-concat'); var concat = require('gulp-concat');
var less = require('gulp-less'); var less = require('gulp-less');
@ -22,56 +21,29 @@ var ecstatic = require('ecstatic');
var downloadatomshell = require('gulp-download-atom-shell'); var downloadatomshell = require('gulp-download-atom-shell');
var packagejson = require('./package.json'); var packagejson = require('./package.json');
var http = require('http'); var http = require('http');
var react = require('gulp-react');
var fs = require('fs');
var dependencies = Object.keys(packagejson.dependencies); var dependencies = Object.keys(packagejson.dependencies);
var devDependencies = Object.keys(packagejson.devDependencies); var devDependencies = Object.keys(packagejson.devDependencies);
var options = { var options = {
dev: process.argv.indexOf('release') === -1, dev: process.argv.indexOf('release') === -1 && process.argv.indexOf('test') === -1,
test: process.argv.indexOf('test') !== -1, test: process.argv.indexOf('test') !== -1,
filename: 'Kitematic.app', filename: 'Kitematic.app',
name: 'Kitematic', name: 'Kitematic',
signing_identity: process.env.XCODE_SIGNING_IDENTITY signing_identity: fs.readFileSync('./identity')
}; };
gulp.task('js', function () { gulp.task('js', function () {
var bundler = browserify({ gulp.src('./app/**/*.js')
entries: ['./app/main.js'], // Only need initial file, browserify finds the rest .pipe(plumber(function(error) {
transform: [reactify], // We want to convert JSX to normal javascript gutil.log(gutil.colors.red('Error (' + error.plugin + '): ' + error.message));
debug: options.dev, // Gives us sourcemapping // emit the end event, to properly end the task
builtins: false, this.emit('end');
commondir: false, }))
insertGlobals: false, .pipe(react())
detectGlobals: false, .pipe(gulp.dest(options.dev ? './build' : './dist/osx/' + options.filename + '/Contents/Resources/app/build'))
bundleExternal: false, .pipe(gulpif(options.dev, livereload()));
cache: {}, packageCache: {}, fullPaths: options.dev // Requirement of watchify
});
// We set our dependencies as externals on our app bundler when developing
dependencies.forEach(function (dep) {
bundler.external(dep);
});
devDependencies.forEach(function (dep) {
bundler.external(dep);
});
bundler.external('./app');
var bundle = function () {
return bundler.bundle()
.on('error', gutil.log)
.pipe(source('main.js'))
.pipe(gulpif(!options.dev, streamify(uglify())))
.pipe(gulp.dest(options.dev ? './build' : './dist/osx/' + options.filename + '/Contents/Resources/app/build'))
.pipe(gulpif(options.dev && !options.test, livereload()));
};
if (options.dev) {
bundler = watchify(bundler);
bundler.on('update', bundle);
}
return bundle();
}); });
gulp.task('specs', function () { gulp.task('specs', function () {
@ -109,7 +81,7 @@ gulp.task('images', function() {
svgoPlugins: [{removeViewBox: false}] svgoPlugins: [{removeViewBox: false}]
})) }))
.pipe(gulp.dest(options.dev ? './build' : './dist/osx/' + options.filename + '/Contents/Resources/app/build')) .pipe(gulp.dest(options.dev ? './build' : './dist/osx/' + options.filename + '/Contents/Resources/app/build'))
.pipe(gulpif(options.dev && !options.test, livereload())); .pipe(gulpif(options.dev, livereload()));
}); });
gulp.task('styles', function () { gulp.task('styles', function () {
@ -138,16 +110,16 @@ gulp.task('download', function (cb) {
gulp.task('copy', function () { gulp.task('copy', function () {
gulp.src('./app/index.html') gulp.src('./app/index.html')
.pipe(gulp.dest(options.dev ? './build' : './dist/osx/' + options.filename + '/Contents/Resources/app/build')) .pipe(gulp.dest(options.dev ? './build' : './dist/osx/' + options.filename + '/Contents/Resources/app/build'))
.pipe(gulpif(options.dev && !options.test, livereload())); .pipe(gulpif(options.dev, livereload()));
gulp.src('./app/fonts/**') gulp.src('./app/fonts/**')
.pipe(gulp.dest(options.dev ? './build' : './dist/osx/' + options.filename + '/Contents/Resources/app/build')) .pipe(gulp.dest(options.dev ? './build' : './dist/osx/' + options.filename + '/Contents/Resources/app/build'))
.pipe(gulpif(options.dev && !options.test, livereload())); .pipe(gulpif(options.dev, livereload()));
}); });
gulp.task('dist', function (cb) { gulp.task('dist', function (cb) {
var stream = gulp.src('').pipe(shell([ var stream = gulp.src('').pipe(shell([
'rm -rf ./dist/osx', 'rm -Rf ./dist',
'mkdir -p ./dist/osx', 'mkdir -p ./dist/osx',
'cp -R ./cache/Atom.app ./dist/osx/<%= filename %>', 'cp -R ./cache/Atom.app ./dist/osx/<%= filename %>',
'mv ./dist/osx/<%= filename %>/Contents/MacOS/Atom ./dist/osx/<%= filename %>/Contents/MacOS/<%= name %>', 'mv ./dist/osx/<%= filename %>/Contents/MacOS/Atom ./dist/osx/<%= filename %>/Contents/MacOS/<%= name %>',
@ -213,6 +185,7 @@ gulp.task('test', ['download', 'copy', 'js', 'images', 'styles', 'specs'], funct
}); });
gulp.task('default', ['download', 'copy', 'js', 'images', 'styles'], function () { gulp.task('default', ['download', 'copy', 'js', 'images', 'styles'], function () {
gulp.watch('./app/**/*.js', ['js']);
gulp.watch('./app/**/*.html', ['copy']); gulp.watch('./app/**/*.html', ['copy']);
gulp.watch('./app/styles/**/*.less', ['styles']); gulp.watch('./app/styles/**/*.less', ['styles']);
gulp.watch('./app/images/**', ['images']); gulp.watch('./app/images/**', ['images']);

View File

@ -11,7 +11,10 @@
}, },
"bugs": "https://github.com/kitematic/kitematic/issues", "bugs": "https://github.com/kitematic/kitematic/issues",
"scripts": { "scripts": {
"start": "./script/run" "start": "gulp",
"preinstall": "./deps",
"test": "gulp test",
"release": ". ./script/identity && gulp release"
}, },
"licenses": [ "licenses": [
{ {
@ -24,26 +27,22 @@
"dependencies": { "dependencies": {
"ansi-to-html": "0.2.0", "ansi-to-html": "0.2.0",
"async": "^0.9.0", "async": "^0.9.0",
"bugsnag-js": "git+https://git@github.com/bugsnag/bugsnag-js",
"dockerode": "2.0.4", "dockerode": "2.0.4",
"exec": "0.1.2", "exec": "0.1.2",
"flux-react": "^2.6.1", "gulp-react": "^2.0.0",
"jquery": "^2.1.3", "jquery": "^2.1.3",
"leveldown": "^1.0.0",
"levelup": "git+https://github.com/kitematic/node-levelup.git",
"minimist": "^1.1.0", "minimist": "^1.1.0",
"moment": "2.8.1", "moment": "2.8.1",
"ncp": "0.6.0",
"node-uuid": "1.4.1", "node-uuid": "1.4.1",
"open": "0.0.5", "open": "0.0.5",
"raven": "^0.7.2", "react": "^0.12.2",
"react": "^0.12.1",
"react-bootstrap": "^0.13.2", "react-bootstrap": "^0.13.2",
"react-retina-image": "^1.1.2", "react-retina-image": "^1.1.2",
"react-router": "^0.11.6", "react-router": "^0.11.6",
"request": "2.42.0", "request": "2.42.0",
"request-progress": "0.3.1", "request-progress": "0.3.1",
"retina.js": "^1.1.0", "retina.js": "^1.1.0",
"tar": "0.1.20",
"underscore": "^1.7.0" "underscore": "^1.7.0"
}, },
"devDependencies": { "devDependencies": {
@ -65,9 +64,9 @@
"gulp-sourcemaps": "^1.2.8", "gulp-sourcemaps": "^1.2.8",
"gulp-streamify": "0.0.5", "gulp-streamify": "0.0.5",
"gulp-uglify": "^0.3.1", "gulp-uglify": "^0.3.1",
"gulp-uglifyjs": "^0.5.0",
"gulp-util": "^3.0.0", "gulp-util": "^3.0.0",
"jasmine-tagged": "^1.1.2", "jasmine-tagged": "^1.1.2",
"object-assign": "^2.0.0",
"reactify": "^0.15.2", "reactify": "^0.15.2",
"run-sequence": "^1.0.2", "run-sequence": "^1.0.2",
"vinyl-source-stream": "^0.1.1", "vinyl-source-stream": "^0.1.1",

View File

@ -1,35 +0,0 @@
#!/bin/bash
DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
BASE=$DIR/..
export NODE_VERSION="0.11.14"
export NPM="$BASE/cache/node-v$NODE_VERSION/bin/npm"
export NODE="$BASE/cache/node-v$NODE_VERSION/bin/node"
export PATH="$BASE/cache/node-v$NODE_VERSION/bin/:$BASE/node_modules/.bin:$PATH"
export NODE_PATH="$BASE/node_modules"
export BOOT2DOCKER_CLI_VERSION=$($NODE -pe "JSON.parse(process.argv[1])['boot2docker-version']" "$(cat $BASE/package.json)")
export BOOT2DOCKER_CLI_VERSION_FILE=boot2docker-$BOOT2DOCKER_CLI_VERSION
mkdir -p $BASE/cache
pushd $BASE/cache > /dev/null
if [ ! -f "$NODE" ]; then
curl -L -o node-v$NODE_VERSION-darwin-x64.tar.gz http://nodejs.org/dist/v$NODE_VERSION/node-v$NODE_VERSION-darwin-x64.tar.gz
mkdir -p node-v$NODE_VERSION
tar -xzf node-v$NODE_VERSION-darwin-x64.tar.gz --strip-components 1 -C node-v$NODE_VERSION
rm -rf node-v$NODE_VERSION-darwin-x64.tar.gz
fi
popd > /dev/null
pushd $BASE/resources > /dev/null
if [ ! -f $BOOT2DOCKER_CLI_VERSION_FILE ]; then
cecho "-----> Downloading Boot2docker CLI..." $purple
rm -rf boot2docker-*
curl -L -o $BOOT2DOCKER_CLI_VERSION_FILE https://github.com/boot2docker/boot2docker-cli/releases/download/v${BOOT2DOCKER_CLI_VERSION}/boot2docker-v${BOOT2DOCKER_CLI_VERSION}-darwin-amd64
chmod +x $BOOT2DOCKER_CLI_VERSION_FILE
fi
popd > /dev/null

View File

@ -1,5 +0,0 @@
#!/bin/bash
DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
source $DIR/env
gulp $*

View File

@ -1,11 +0,0 @@
#!/bin/bash
DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
source $DIR/env
ATOM_SHELL_VERSION=$($NODE -pe "JSON.parse(process.argv[1])['atom-shell-version']" "$(cat package.json)")
export npm_config_disturl=https://gh-contractor-zcbenz.s3.amazonaws.com/atom-shell/dist
export npm_config_target=$ATOM_SHELL_VERSION
export npm_config_arch=ia64
HOME=~/.atom-shell-gyp $NPM $*

View File

@ -1,10 +0,0 @@
#!/bin/bash
DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
source $DIR/env
if [ -f $DIR/identity ]; then
source $DIR/identity
fi
gulp release

View File

@ -1,5 +0,0 @@
#!/bin/bash
DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
source $DIR/env
gulp test