diff --git a/.gitignore b/.gitignore
index aed401fe57..3cd3b8edff 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,10 +1,11 @@
.DS_Store
-.demeteorized
+build
dist
node_modules
-package
-cache
-bin
+npm-debug.log
+
+# Signing Identity
+script/identity
# Resources
resources/virtualbox-*.pkg
@@ -17,5 +18,3 @@ resources/settings.json
# Cache
cache
-
-script/sign.sh
diff --git a/README.md b/README.md
index c87de4d0c4..cae55f027e 100755
--- a/README.md
+++ b/README.md
@@ -10,21 +10,18 @@ Kitematic is a simple application for managing Docker containers on Mac OS X.
Kitematic's documentation and other information can be found at [http://kitematic.com/docs](http://kitematic.com/docs).
-## Development
+### Development
-- Install any version of Node.js
-- Install meteor.js `curl https://install.meteor.com/ | sh`.
-- Install meteorite `npm install meteorite -g`
-- Install demeteorizer `npm install demeteorizer -g`
-- Run `./script/setup.sh` to download the binary requirements (things like virtualbox).
+- `sudo npm install -g less`
+- `./script/npm install`
-### Running the development Server
+To run the app in development:
-- `./script/run.sh`
+- `./script/gulp`
### Building the Mac OS X Package
-- `./script/dist.sh`
+- `./script/release`
## Uninstalling
diff --git a/app/ContainerDetails.react.js b/app/ContainerDetails.react.js
new file mode 100644
index 0000000000..cd8c140d6f
--- /dev/null
+++ b/app/ContainerDetails.react.js
@@ -0,0 +1,180 @@
+var _ = require('underscore');
+var React = require('react');
+var Router = require('react-router');
+var Convert = require('ansi-to-html');
+var convert = new Convert();
+var ContainerStore = require('./ContainerStore');
+var docker = require('./docker');
+var exec = require('exec');
+var boot2docker = require('./boot2docker');
+var ProgressBar = require('react-bootstrap/ProgressBar');
+
+var Route = Router.Route;
+var NotFoundRoute = Router.NotFoundRoute;
+var DefaultRoute = Router.DefaultRoute;
+var Link = Router.Link;
+var RouteHandler = Router.RouteHandler;
+
+var ContainerDetails = React.createClass({
+ mixins: [Router.State],
+ getInitialState: function () {
+ return {
+ logs: []
+ };
+ },
+ componentWillReceiveProps: function () {
+ this.update();
+ this.setState({
+ logs: []
+ });
+ var self = this;
+ var logs = [];
+ var index = 0;
+ 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 () {
+ ContainerStore.addChangeListener(ContainerStore.CONTAINERS, this.update);
+ ContainerStore.addChangeListener(ContainerStore.PROGRESS, this.update);
+ },
+ componentWillUnmount: function () {
+ ContainerStore.removeChangeListener(ContainerStore.CONTAINERS, this.update);
+ ContainerStore.removeChangeListener(ContainerStore.PROGRESS, this.update);
+ },
+ update: function () {
+ var name = this.getParams().name;
+ this.setState({
+ container: ContainerStore.container(name),
+ progress: ContainerStore.progress(name)
+ });
+ },
+ _escapeHTML: function (html) {
+ var text = document.createTextNode(html);
+ var div = document.createElement('div');
+ div.appendChild(text);
+ return div.innerHTML;
+ },
+ handleClick: function (name) {
+ var container = this.state.container;
+ boot2docker.ip(function (err, ip) {
+ var ports = _.map(container.NetworkSettings.Ports, function (value, key) {
+ var portProtocolPair = key.split('/');
+ var res = {
+ 'port': portProtocolPair[0],
+ 'protocol': portProtocolPair[1]
+ };
+ if (value && value.length) {
+ var port = value[0].HostPort;
+ res.host = ip;
+ res.port = port;
+ res.url = 'http://' + ip + ':' + port;
+ } else {
+ return null;
+ }
+ return res;
+ });
+ console.log(ports);
+ exec(['open', ports[0].url], function (err) {
+ if (err) { throw err; }
+ });
+ });
+ },
+ render: function () {
+ var self = this;
+
+ if (!this.state) {
+ return
;
+ }
+
+ var logs = this.state.logs.map(function (l, i) {
+ return ;
+ });
+
+ if (!this.state.container) {
+ return false;
+ }
+
+ var state;
+ if (this.state.container.State.Running) {
+ state = running
;
+ } else if (this.state.container.State.Restarting) {
+ state = restarting
;
+ }
+
+ var progress;
+ if (this.state.progress > 0 && this.state.progress != 1) {
+ progress = (
+
+ );
+ } else {
+ progress = ;
+ }
+
+ var button;
+ if (this.state.progress === 1) {
+ button = View;
+ } else {
+ button = View;
+ }
+
+ var name = this.state.container.Name.replace('/', '');
+
+ return (
+
+ );
+ }
+});
+
+module.exports = ContainerDetails;
diff --git a/app/ContainerList.react.js b/app/ContainerList.react.js
new file mode 100644
index 0000000000..81ade25892
--- /dev/null
+++ b/app/ContainerList.react.js
@@ -0,0 +1,105 @@
+var async = require('async');
+var _ = require('underscore');
+var $ = require('jquery');
+var React = require('react/addons');
+var Router = require('react-router');
+var Modal = require('react-bootstrap/Modal');
+var RetinaImage = require('react-retina-image');
+var ModalTrigger = require('react-bootstrap/ModalTrigger');
+var ContainerModal = require('./ContainerModal.react');
+var ContainerStore = require('./ContainerStore');
+var Header = require('./Header.react');
+var docker = require('./docker');
+
+var Link = Router.Link;
+var RouteHandler = Router.RouteHandler;
+var Navigation= Router.Navigation;
+
+var ContainerList = React.createClass({
+ getInitialState: function () {
+ return {
+ containers: []
+ };
+ },
+ componentDidMount: function () {
+ this.updateContainers();
+ ContainerStore.addChangeListener(ContainerStore.CONTAINERS, this.updateContainers);
+ },
+ componentWillMount: function () {
+ 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 () {
+ var self = this;
+ var containers = this.state.containers.map(function (container) {
+ var downloadingImage = null, downloading = false;
+ var env = container.Config.Env;
+ if (env.length) {
+ var obj = _.object(env.map(function (e) {
+ return e.split('=');
+ }));
+ if (obj.KITEMATIC_DOWNLOADING) {
+ downloading = true;
+ }
+ downloadingImage = obj.KITEMATIC_DOWNLOADING_IMAGE || null;
+ }
+
+ var imageName = downloadingImage || container.Config.Image;
+
+ // Synchronize all animations
+ var style = {
+ WebkitAnimationDelay: (self._start - Date.now()) + 'ms'
+ };
+
+ var state;
+ if (downloading) {
+ state = ;
+ } else if (container.State.Running && !container.State.Paused) {
+ state = ;
+ } else if (container.State.Restarting) {
+ state = ;
+ } else if (container.State.Paused) {
+ state = ;
+ } else if (container.State.ExitCode) {
+ // state = ;
+ state = ;
+ } else {
+ state = ;
+ }
+
+ var name = container.Name.replace('/', '');
+
+ return (
+
+
+ {state}
+
+
+ {name}
+
+
+ {imageName}
+
+
+
+
+ );
+ });
+ return (
+
+ );
+ }
+});
+
+module.exports = ContainerList;
diff --git a/app/ContainerModal.react.js b/app/ContainerModal.react.js
new file mode 100644
index 0000000000..2568d4f103
--- /dev/null
+++ b/app/ContainerModal.react.js
@@ -0,0 +1,126 @@
+var async = require('async');
+var $ = require('jquery');
+var React = require('react');
+var Router = require('react-router');
+var Modal = require('react-bootstrap/Modal');
+var RetinaImage = require('react-retina-image');
+var ContainerStore = require('./ContainerStore');
+
+var Navigation = Router.Navigation;
+
+var ContainerModal = React.createClass({
+ mixins: [Navigation],
+ _searchRequest: null,
+ getInitialState: function () {
+ return {
+ query: '',
+ results: [],
+ recommended: ContainerStore.recommended()
+ };
+ },
+ componentDidMount: function () {
+ this.refs.searchInput.getDOMNode().focus();
+ },
+ search: function (query) {
+ var self = this;
+ this._searchRequest = $.get('https://registry.hub.docker.com/v1/search?q=' + query, function (result) {
+ self._searchRequest.abort();
+ self._searchRequest = null;
+ if (self.isMounted()) {
+ self.setState(result);
+ console.log(result);
+ }
+ });
+ },
+ handleChange: function (e) {
+ var query = e.target.value;
+
+ if (query === this.state.query) {
+ return;
+ }
+
+ if (this._searchRequest) {
+ console.log('Cancel');
+ this._searchRequest.abort();
+ this._searchRequest = null;
+ }
+ clearTimeout(this.timeout);
+ var self = this;
+ this.timeout = setTimeout(function () {
+ self.search(query);
+ }, 250);
+ },
+ handleClick: function (event) {
+ var name = event.target.getAttribute('name');
+ var self = this;
+ ContainerStore.create(name, 'latest', function (err, containerName) {
+ // this.transitionTo('containers', {container: containerName});
+ self.props.onRequestHide();
+ }.bind(this));
+ },
+ render: function () {
+ var self = this;
+
+ var data;
+ if (this.state.query) {
+ data = this.state.results.splice(0, 7);
+ } else {
+ data = this.state.recommended;
+ }
+ var results = data.map(function (r) {
+ var name;
+ if (r.is_official) {
+ name = {r.name};
+ } else {
+ name = {r.name};
+ }
+ return (
+
+
+
+
+
+
+ );
+ });
+
+ var title;
+ if (this.state.query) {
+ title = Results
;
+ } else {
+ title = Recommended
;
+ }
+
+ return (
+
+
+
+ );
+ }
+});
+
+module.exports = ContainerModal;
diff --git a/app/ContainerStore.js b/app/ContainerStore.js
new file mode 100644
index 0000000000..ff54d4351a
--- /dev/null
+++ b/app/ContainerStore.js
@@ -0,0 +1,308 @@
+var EventEmitter = require('events').EventEmitter;
+var async = require('async');
+var assign = require('react/lib/Object.assign');
+var docker = require('./docker');
+var registry = require('./registry');
+var $ = require('jquery');
+var _ = require('underscore');
+
+// Merge our store with Node's Event Emitter
+var ContainerStore = assign(EventEmitter.prototype, {
+ CONTAINERS: 'containers',
+ PROGRESS: 'progress',
+ LOGS: 'logs',
+ RECOMMENDED: 'recommended',
+ _recommended: [],
+ _containers: {},
+ _progress: {},
+ _logs: {},
+ _pullScratchImage: function (callback) {
+ var image = docker.client().getImage('scratch:latest');
+ image.inspect(function (err, data) {
+ if (!data) {
+ docker.client().pull('scratch:latest', function (err, stream) {
+ if (err) {
+ callback(err);
+ return;
+ }
+ stream.setEncoding('utf8');
+ stream.on('data', function (data) {});
+ stream.on('end', function () {
+ callback();
+ });
+ });
+ } else {
+ callback();
+ }
+ });
+ },
+ _createContainer: function (image, name, callback) {
+ var existing = docker.client().getContainer(name);
+ existing.remove(function (err, data) {
+ console.log('Placeholder removed.');
+ docker.client().createContainer({
+ Image: image,
+ Tty: false,
+ name: name
+ }, function (err, container) {
+ if (err) {
+ callback(err, null);
+ return;
+ }
+ console.log('Created container: ' + container.id);
+ container.start({
+ PublishAllPorts: true
+ }, function (err) {
+ if (err) { callback(err, null); return; }
+ console.log('Started container: ' + container.id);
+ callback(null, container);
+ });
+ });
+ });
+ },
+ _createPlaceholderContainer: function (imageName, name, callback) {
+ console.log('_createPlaceholderContainer', imageName, name);
+ this._pullScratchImage(function (err) {
+ if (err) {
+ callback(err);
+ return;
+ }
+ docker.client().createContainer({
+ Image: 'scratch:latest',
+ Tty: false,
+ Env: [
+ 'KITEMATIC_DOWNLOADING=true',
+ 'KITEMATIC_DOWNLOADING_IMAGE=' + imageName
+ ],
+ Cmd: 'placeholder',
+ name: name
+ }, function (err, container) {
+ callback(err, container);
+ });
+ });
+ },
+ _generateName: function (repository) {
+ var base = _.last(repository.split('/'));
+ var count = 1;
+ var name = base;
+ while (true) {
+ var exists = _.findWhere(_.values(this._containers), {Name: '/' + name}) || _.findWhere(_.values(this._containers), {Name: name});
+ if (!exists) {
+ return name;
+ } else {
+ count++;
+ name = base + '-' + count;
+ }
+ }
+ },
+ init: function (callback) {
+ // TODO: Load cached data from db on loading
+
+ // Refresh with docker & hook into events
+ var self = this;
+ this.update(function (err) {
+ self.updateRecommended(function (err) {
+ 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.on('data', function (data) {
+ console.log(data);
+
+ // 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) {
+ var self = this;
+ docker.client().listContainers({all: true}, function (err, containers) {
+ if (err) {
+ callback(err);
+ return;
+ }
+ async.map(containers, function(container, callback) {
+ docker.client().getContainer(container.Id).inspect(function (err, data) {
+ callback(err, data);
+ });
+ }, function (err, results) {
+ if (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) {
+ var self = this;
+ $.ajax({
+ url: 'https://kitematic.com/recommended.json',
+ dataType: 'json',
+ success: function (res, status) {
+ var recommended = res.recommended;
+ async.map(recommended, function (repository, callback) {
+ $.get('https://registry.hub.docker.com/v1/search?q=' + repository, function (data) {
+ var results = data.results;
+ callback(null, _.find(results, function (r) {
+ return r.name === repository;
+ }));
+ });
+ }, function (err, results) {
+ self._recommended = results;
+ callback();
+ });
+ },
+ error: function (err) {
+ console.log(err);
+ }
+ });
+ },
+ create: function (repository, tag, callback) {
+ tag = tag || 'latest';
+ var self = this;
+ var imageName = repository + ':' + tag;
+ var containerName = this._generateName(repository);
+ var image = docker.client().getImage(imageName);
+
+ image.inspect(function (err, data) {
+ if (!data) {
+ // Pull image
+ self._createPlaceholderContainer(imageName, containerName, function (err, container) {
+ if (err) {
+ console.log(err);
+ }
+ registry.layers(repository, tag, function (err, layerSizes) {
+ if (err) {
+ callback(err);
+ }
+
+ // 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];
+ });
+ });
+ });
+ });
+ });
+ });
+ } else {
+ // If not then directly create the container
+ self._createContainer(imageName, containerName, function () {
+ callback(null, containerName);
+ });
+ }
+ });
+ },
+ containers: function() {
+ return this._containers;
+ },
+ container: function (name) {
+ return this._containers[name];
+ },
+ recommended: function () {
+ return this._recommended;
+ },
+ progress: function (name) {
+ return this._progress[name];
+ },
+ logs: function (name) {
+ return logs[name];
+ },
+ addChangeListener: function(eventType, callback) {
+ this.on(eventType, callback);
+ },
+ removeChangeListener: function(eventType, callback) {
+ this.removeListener(eventType, callback);
+ },
+});
+
+module.exports = ContainerStore;
diff --git a/app/Containers.react.js b/app/Containers.react.js
new file mode 100644
index 0000000000..7b118559fc
--- /dev/null
+++ b/app/Containers.react.js
@@ -0,0 +1,66 @@
+var React = require('react/addons');
+var Router = require('react-router');
+var Modal = require('react-bootstrap/Modal');
+var RetinaImage = require('react-retina-image');
+var ModalTrigger = require('react-bootstrap/ModalTrigger');
+var ContainerModal = require('./ContainerModal.react');
+var ContainerStore = require('./ContainerStore');
+var ContainerList = require('./ContainerList.react');
+var Header = require('./Header.react');
+var async = require('async');
+var _ = require('underscore');
+var docker = require('./docker');
+var $ = require('jquery');
+
+var Link = Router.Link;
+var RouteHandler = Router.RouteHandler;
+
+var Containers = React.createClass({
+ getInitialState: function () {
+ return {
+ sidebarOffset: 0
+ };
+ },
+ handleScroll: function (e) {
+ if (e.target.scrollTop > 0 && !this.state.sidebarOffset) {
+ this.setState({
+ sidebarOffset: e.target.scrollTop
+ });
+ } else if (e.target.scrollTop === 0 && this.state.sidebarOffset) {
+ this.setState({
+ sidebarOffset: 0
+ });
+ }
+ },
+ render: function () {
+ var sidebarHeaderClass = 'sidebar-header';
+ if (this.state.sidebarOffset) {
+ sidebarHeaderClass += ' sep';
+ }
+ return (
+
+ );
+ }
+});
+
+module.exports = Containers;
diff --git a/app/Header.react.js b/app/Header.react.js
new file mode 100644
index 0000000000..b69eb6f18c
--- /dev/null
+++ b/app/Header.react.js
@@ -0,0 +1,56 @@
+var React = require('react/addons');
+var remote = require('remote');
+
+var Header = React.createClass({
+ componentDidMount: function () {
+ document.addEventListener('keyup', this.handleDocumentKeyUp, false);
+ },
+ componentWillUnmount: function () {
+ document.removeEventListener('keyup', this.handleDocumentKeyUp, false);
+ },
+ handleDocumentKeyUp: function (e) {
+ if (e.keyCode === 27 && remote.getCurrentWindow().isFullScreen()) {
+ remote.getCurrentWindow().setFullScreen(false);
+ this.forceUpdate();
+ }
+ },
+ handleClose: function () {
+ remote.getCurrentWindow().hide();
+ },
+ handleMinimize: function () {
+ remote.getCurrentWindow().minimize();
+ },
+ handleFullscreen: function () {
+ remote.getCurrentWindow().setFullScreen(!remote.getCurrentWindow().isFullScreen());
+ this.forceUpdate();
+ },
+ handleFullscreenHover: function () {
+ this.update();
+ },
+ render: function () {
+ var buttons;
+ if (remote.getCurrentWindow().isFullScreen()) {
+ return (
+
+ );
+ } else {
+ return (
+
+ );
+ }
+ }
+});
+
+module.exports = Header;
diff --git a/app/Radial.react.js b/app/Radial.react.js
new file mode 100644
index 0000000000..c91c898445
--- /dev/null
+++ b/app/Radial.react.js
@@ -0,0 +1,37 @@
+var React = require('react/addons');
+
+var Radial = React.createClass({
+ render: function () {
+ var percentage;
+ if (this.props.progress && !this.props.spin) {
+ percentage = (
+
+ );
+ } else {
+ percentage = ;
+ }
+ var classes = React.addons.classSet({
+ 'radial-progress': true,
+ 'radial-spinner': this.props.spin
+ });
+ return (
+
+ );
+ }
+});
+
+module.exports = Radial;
diff --git a/app/Setup.react.js b/app/Setup.react.js
new file mode 100644
index 0000000000..f04f58a969
--- /dev/null
+++ b/app/Setup.react.js
@@ -0,0 +1,196 @@
+var React = require('react');
+var Router = require('react-router');
+var Radial = require('./Radial.react.js');
+var async = require('async');
+var assign = require('object-assign');
+var fs = require('fs');
+var path = require('path');
+var boot2docker = require('./boot2docker.js');
+var virtualbox = require('./virtualbox.js');
+var util = require('./util.js');
+var docker = require('./docker.js');
+var ContainerStore = require('./ContainerStore.js');
+
+var setupSteps = [
+ {
+ run: function (callback, progressCallback) {
+ var installed = virtualbox.installed();
+ if (!installed) {
+ util.download('https://s3.amazonaws.com/kite-installer/' + virtualbox.INSTALLER_FILENAME, path.join(process.cwd(), 'resources', virtualbox.INSTALLER_FILENAME), virtualbox.INSTALLER_CHECKSUM, function (err) {
+ if (err) {callback(err); return;}
+ virtualbox.install(function (err) {
+ if (!virtualbox.installed()) {
+ callback('VirtualBox could not be installed. The installation either failed or was cancelled. Please try closing all VirtualBox instances and try again.');
+ } else {
+ callback(err);
+ }
+ });
+ }, function (progress) {
+ progressCallback(progress);
+ });
+ } else {
+ virtualbox.version(function (err, installedVersion) {
+ if (err) {callback(err); return;}
+ if (util.compareVersions(installedVersion, virtualbox.REQUIRED_VERSION) < 0) {
+ // Download a newer version of Virtualbox
+ util.downloadFile(Setup.BASE_URL + virtualbox.INSTALLER_FILENAME, path.join(util.getResourceDir(), virtualbox.INSTALLER_FILENAME), virtualbox.INSTALLER_CHECKSUM, function (err) {
+ if (err) {callback(err); return;}
+ virtualbox.kill(function (err) {
+ if (err) {callback(err); return;}
+ virtualbox.install(function (err) {
+ if (err) {callback(err); return;}
+ virtualbox.version(function (err, installedVersion) {
+ if (err) {callback(err); return;}
+ if (util.compareVersions(installedVersion, virtualbox.REQUIRED_VERSION) < 0) {
+ callback('VirtualBox could not be installed. The installation either failed or was cancelled. Please try closing all VirtualBox instances and try again.');
+ } else {
+ callback(err);
+ }
+ });
+ });
+ });
+ }, function (progress) {
+ progressCallback(progress);
+ });
+ } else {
+ callback();
+ }
+ });
+ }
+ },
+ message: 'Downloading VirtualBox...'
+ },
+ {
+ run: function (callback) {
+ virtualbox.deleteVM('kitematic-vm', function (err, removed) {
+ if (err) {
+ console.log(err);
+ }
+ callback();
+ });
+ },
+ message: 'Cleaning up existing Docker VM...'
+ },
+
+ // Initialize Boot2Docker if necessary.
+ {
+ run: function (callback) {
+ boot2docker.exists(function (err, exists) {
+ if (err) { callback(err); return; }
+ if (!exists) {
+ boot2docker.init(function (err) {
+ callback(err);
+ });
+ } else {
+ if (!boot2docker.sshKeyExists()) {
+ callback('Boot2Docker SSH key doesn\'t exist. Fix by removing the existing Boot2Docker VM and re-run the installer. This usually occurs because an old version of Boot2Docker is installed.');
+ } else {
+ boot2docker.isoVersion(function (err, version) {
+ if (err || util.compareVersions(version, boot2docker.version()) < 0) {
+ boot2docker.stop(function(err) {
+ boot2docker.upgrade(function (err) {
+ callback(err);
+ });
+ });
+ } else {
+ callback();
+ }
+ });
+ }
+ }
+ });
+ },
+ message: 'Setting up the Docker VM...'
+ },
+ {
+ run: function (callback) {
+ boot2docker.waitWhileStatus('saving', function (err) {
+ boot2docker.status(function (err, status) {
+ if (err) {callback(err); return;}
+ if (status !== 'running') {
+ boot2docker.start(function (err) {
+ callback(err);
+ });
+ } else {
+ callback();
+ }
+ });
+ });
+ },
+ message: 'Starting the Docker VM...'
+ },
+ {
+ run: function (callback) {
+ boot2docker.ip(function (err, ip) {
+ if (err) {callback(err); return;}
+ console.log('Setting host IP to: ' + ip);
+ // Docker.setHost(ip);
+ callback(err);
+ });
+ },
+ message: 'Detecting Docker VM...'
+ }
+];
+
+var Setup = React.createClass({
+ mixins: [ Router.Navigation ],
+ render: function () {
+ var radial;
+ if (this.state.progress) {
+ radial = ;
+ } else {
+ radial = ;
+ }
+ return (
+
+ {radial}
+
{this.state.message}
+
+ );
+ },
+ componentWillMount: function () {
+ this.setState({});
+ },
+ componentDidMount: function () {
+ var self = this;
+ this.setup(function (err) {
+ boot2docker.ip(function (err, ip) {
+ docker.setHost(ip);
+ self.transitionTo('containers');
+ });
+ });
+ },
+ setup: function (callback) {
+ var self = this;
+ var currentStep = 0;
+ async.eachSeries(setupSteps, function (step, callback) {
+ console.log('Performing step ' + currentStep);
+ self.setState({progress: 0});
+ self.setState({message: step.message});
+ step.run(function (err) {
+ if (err) {
+ callback(err);
+ } else {
+ currentStep += 1;
+ callback();
+ }
+ }, function (progress) {
+ self.setState({progress: progress});
+ });
+ }, function (err) {
+ if (err) {
+ // if any of the steps fail
+ console.log('Kitematic setup failed at step ' + currentStep);
+ console.log(err);
+ self.setState({error: err});
+ callback(err);
+ } else {
+ // Setup Finished
+ console.log('Setup finished.');
+ callback();
+ }
+ });
+ }
+});
+
+module.exports = Setup;
diff --git a/app/boot2docker.js b/app/boot2docker.js
new file mode 100644
index 0000000000..1c90bfe105
--- /dev/null
+++ b/app/boot2docker.js
@@ -0,0 +1,218 @@
+var exec = require('exec');
+var path = require('path');
+var fs = require('fs');
+var path = require('path');
+var async = require('async');
+
+var cmdExec = function (cmd, callback) {
+ exec(cmd, function (stderr, stdout, code) {
+ if (code !== 0) {
+ callback('Exit code ' + code + ': ' + stderr);
+ } else {
+ callback(null, stdout);
+ }
+ });
+};
+
+var homeDir = function () {
+ return process.env[(process.platform === 'win32') ? 'USERPROFILE' : 'HOME'];
+};
+
+var Boot2Docker = {
+ version: function () {
+ return JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8'))['boot2docker-version'];
+ },
+ cliVersion: function (callback) {
+ cmdExec([Boot2Docker.command(), 'version'], function (err, out) {
+ if (err) {
+ callback(err);
+ return;
+ }
+ var match = out.match(/version: v(\d+\.\d+\.\d+)/);
+ if (!match || match.length < 2) {
+ callback('Could not parse the boot2docker cli version.');
+ } else {
+ callback(null, match[1]);
+ }
+ });
+ },
+ isoVersion: function (callback) {
+ fs.readFile(path.join(homeDir(), '.boot2docker', 'boot2docker.iso'), 'utf8', function (err, data) {
+ if (err) {
+ callback(err);
+ return;
+ }
+ var match = data.match(/Boot2Docker-v(\d+\.\d+\.\d+)/);
+ if (!match) {
+ callback('Could not parse boot2docker iso version');
+ return;
+ }
+ callback (null, match[1]);
+ });
+ },
+ command: function () {
+ return path.join(process.cwd(), 'resources', 'boot2docker-' + this.version());
+ },
+ exists: function (callback) {
+ cmdExec([Boot2Docker.command(), 'info'], callback);
+ },
+ status: function (callback) {
+ cmdExec([Boot2Docker.command(), 'status'], function (err, out) {
+ if (err) {
+ callback(err);
+ return;
+ }
+ callback(null, out.trim());
+ });
+ },
+ init: function (callback) {
+ cmdExec([Boot2Docker.command(), 'init'], callback);
+ },
+ start: function (callback) {
+ cmdExec([Boot2Docker.command(), 'start'], callback);
+ },
+ stop: function (callback) {
+ cmdExec([Boot2Docker.command(), 'stop'], callback);
+ },
+ upgrade: function (callback) {
+ cmdExec([Boot2Docker.command(), 'upgrade'], callback);
+ },
+ ip: function (callback) {
+ cmdExec([Boot2Docker.command(), 'ip'], callback);
+ },
+ erase: function (callback) {
+ var VMFileLocation = path.join(homeDir(), 'VirtualBox\\ VMs/boot2docker-vm');
+ cmdExec(['rm', '-rf', VMFileLocation], callback);
+ },
+ state: function (callback) {
+ cmdExec([Boot2Docker.command(), 'info'], function (err, out) {
+ if (err) {
+ callback(err);
+ return;
+ }
+ try {
+ var info = JSON.parse(out);
+ callback(null, info.State);
+ } catch (e) {
+ callback(e, null);
+ }
+ });
+ },
+ disk: function (callback) {
+ cmdExec([Boot2Docker.command(), 'ssh', 'df'], function (err, out) {
+ if (err) {
+ callback(err);
+ return;
+ }
+ try {
+ var lines = out.split('\n');
+ var dataline = _.find(lines, function (line) {
+ return line.indexOf('/dev/sda1') !== -1;
+ });
+ var tokens = dataline.split(' ');
+ tokens = tokens.filter(function (token) {
+ return token !== '';
+ });
+ var usedGb = parseInt(tokens[2], 10) / 1000000;
+ var totalGb = parseInt(tokens[3], 10) / 1000000;
+ var percent = parseInt(tokens[4].replace('%', ''), 10);
+ callback(null, {
+ used_gb: usedGb.toFixed(2),
+ total_gb: totalGb.toFixed(2),
+ percent: percent
+ });
+ } catch (error) {
+ callback(error, null);
+ }
+ });
+ },
+ memory: function (callback) {
+ cmdExec([Boot2Docker.command(), 'ssh', 'free -m'], function (err, out) {
+ if (err) {
+ callback(err);
+ return;
+ }
+ try {
+ var lines = out.split('\n');
+ var dataline = _.find(lines, function (line) {
+ return line.indexOf('-/+ buffers') !== -1;
+ });
+ var tokens = dataline.split(' ');
+ tokens = tokens.filter(function(token) {
+ return token !== '';
+ });
+ var usedGb = parseInt(tokens[2], 10) / 1000;
+ var freeGb = parseInt(tokens[3], 10) / 1000;
+ var totalGb = usedGb + freeGb;
+ var percent = Math.round(usedGb / totalGb * 100);
+ callback(null, {
+ used_gb: usedGb.toFixed(2),
+ total_gb: totalGb.toFixed(2),
+ free_gb: freeGb.toFixed(2),
+ percent: percent
+ });
+ } catch (error) {
+ callback(error);
+ }
+ });
+ },
+ createScratchImage: function (callback) {
+ cmdExec([Boot2Docker.command(), 'ssh', 'tar cv --files-from /dev/null | docker import - scratch'], function (err, out) {
+ callback(err);
+ });
+ },
+ stats: function (callback) {
+ var self = this;
+ self.state(function (err, state) {
+ if (err) {
+ callback(err);
+ return;
+ }
+ if (state === 'poweroff') {
+ callback(null, {state: state});
+ return;
+ }
+ self.memoryUsage(function (err, mem) {
+ if (err) {
+ callback(null, {state: state});
+ return;
+ }
+ self.diskUsage(function (err, disk) {
+ if (err) {
+ callback(null, {state: state, memory: mem});
+ return;
+ }
+ callback(null, {
+ state: state,
+ memory: mem,
+ disk: disk
+ });
+ });
+ });
+ });
+ },
+ sshKeyExists: function () {
+ return fs.existsSync(path.join(homeDir(), '.ssh', 'id_boot2docker'));
+ },
+
+ // Todo: move me to setup
+ waitWhileStatus: function (status, callback) {
+ var current = status;
+ async.whilst(function () {
+ return current === status;
+ }, function (callback) {
+ Boot2Docker.status(function (err, vmStatus) {
+ if (err) {
+ callback(err);
+ } else {
+ current = vmStatus.trim();
+ callback();
+ }
+ });
+ }, function (err) {
+ callback(err);
+ });
+ }
+};
+
+module.exports = Boot2Docker;
diff --git a/app/docker.js b/app/docker.js
new file mode 100644
index 0000000000..ac9701546a
--- /dev/null
+++ b/app/docker.js
@@ -0,0 +1,28 @@
+var fs = require('fs');
+var path = require('path');
+var dockerode = require('dockerode');
+
+var Docker = {
+ host: null,
+ _client: null,
+ setHost: function(host) {
+ this.host = host;
+ var certDir = path.join(process.env[(process.platform === 'win32') ? 'USERPROFILE' : 'HOME'], '.boot2docker/certs/boot2docker-vm');
+ if (!fs.existsSync(certDir)) {
+ return;
+ }
+ this._client = new dockerode({
+ protocol: 'https',
+ host: this.host,
+ port: 2376,
+ ca: fs.readFileSync(path.join(certDir, 'ca.pem')),
+ cert: fs.readFileSync(path.join(certDir, 'cert.pem')),
+ key: fs.readFileSync(path.join(certDir, 'key.pem'))
+ });
+ },
+ client: function () {
+ return this._client;
+ }
+};
+
+module.exports = Docker;
diff --git a/app/fonts/clearsans-bold-webfont.ttf b/app/fonts/clearsans-bold-webfont.ttf
new file mode 100755
index 0000000000..710166c79c
Binary files /dev/null and b/app/fonts/clearsans-bold-webfont.ttf differ
diff --git a/app/fonts/clearsans-bolditalic-webfont.ttf b/app/fonts/clearsans-bolditalic-webfont.ttf
new file mode 100755
index 0000000000..1cc7838060
Binary files /dev/null and b/app/fonts/clearsans-bolditalic-webfont.ttf differ
diff --git a/app/fonts/clearsans-italic-webfont.ttf b/app/fonts/clearsans-italic-webfont.ttf
new file mode 100755
index 0000000000..c67d772cb9
Binary files /dev/null and b/app/fonts/clearsans-italic-webfont.ttf differ
diff --git a/app/fonts/clearsans-light-webfont.ttf b/app/fonts/clearsans-light-webfont.ttf
new file mode 100755
index 0000000000..295bf8ef94
Binary files /dev/null and b/app/fonts/clearsans-light-webfont.ttf differ
diff --git a/app/fonts/clearsans-medium-webfont.ttf b/app/fonts/clearsans-medium-webfont.ttf
new file mode 100755
index 0000000000..a1cc3c54d5
Binary files /dev/null and b/app/fonts/clearsans-medium-webfont.ttf differ
diff --git a/app/fonts/clearsans-mediumitalic-webfont.ttf b/app/fonts/clearsans-mediumitalic-webfont.ttf
new file mode 100755
index 0000000000..17f338c341
Binary files /dev/null and b/app/fonts/clearsans-mediumitalic-webfont.ttf differ
diff --git a/app/fonts/clearsans-regular-webfont.ttf b/app/fonts/clearsans-regular-webfont.ttf
new file mode 100755
index 0000000000..248778b300
Binary files /dev/null and b/app/fonts/clearsans-regular-webfont.ttf differ
diff --git a/app/fonts/clearsans-thin-webfont.ttf b/app/fonts/clearsans-thin-webfont.ttf
new file mode 100755
index 0000000000..69a6219cbc
Binary files /dev/null and b/app/fonts/clearsans-thin-webfont.ttf differ
diff --git a/app/fonts/streamline-24px.eot b/app/fonts/streamline-24px.eot
new file mode 100644
index 0000000000..9d4b7388f2
Binary files /dev/null and b/app/fonts/streamline-24px.eot differ
diff --git a/app/fonts/streamline-24px.svg b/app/fonts/streamline-24px.svg
new file mode 100644
index 0000000000..851c7def48
--- /dev/null
+++ b/app/fonts/streamline-24px.svg
@@ -0,0 +1,1652 @@
+
+
+
diff --git a/app/fonts/streamline-24px.ttf b/app/fonts/streamline-24px.ttf
new file mode 100644
index 0000000000..7c68a974b3
Binary files /dev/null and b/app/fonts/streamline-24px.ttf differ
diff --git a/app/fonts/streamline-24px.woff b/app/fonts/streamline-24px.woff
new file mode 100644
index 0000000000..f816ee643b
Binary files /dev/null and b/app/fonts/streamline-24px.woff differ
diff --git a/app/images/close.png b/app/images/close.png
new file mode 100644
index 0000000000..4551a82a0e
Binary files /dev/null and b/app/images/close.png differ
diff --git a/app/images/close@2x.png b/app/images/close@2x.png
new file mode 100644
index 0000000000..30793ab48f
Binary files /dev/null and b/app/images/close@2x.png differ
diff --git a/app/images/downloading-arrow.png b/app/images/downloading-arrow.png
new file mode 100644
index 0000000000..dfca72b6ae
Binary files /dev/null and b/app/images/downloading-arrow.png differ
diff --git a/app/images/downloading-arrow@2x.png b/app/images/downloading-arrow@2x.png
new file mode 100644
index 0000000000..7f589b2596
Binary files /dev/null and b/app/images/downloading-arrow@2x.png differ
diff --git a/app/images/downloading.png b/app/images/downloading.png
new file mode 100644
index 0000000000..2dd4acfa27
Binary files /dev/null and b/app/images/downloading.png differ
diff --git a/app/images/downloading@2x.png b/app/images/downloading@2x.png
new file mode 100644
index 0000000000..b3d8b3a16d
Binary files /dev/null and b/app/images/downloading@2x.png differ
diff --git a/app/images/error.png b/app/images/error.png
new file mode 100644
index 0000000000..886c466340
Binary files /dev/null and b/app/images/error.png differ
diff --git a/app/images/error@2x.png b/app/images/error@2x.png
new file mode 100644
index 0000000000..e075bb3295
Binary files /dev/null and b/app/images/error@2x.png differ
diff --git a/app/images/fullscreen.png b/app/images/fullscreen.png
new file mode 100644
index 0000000000..284cb69a72
Binary files /dev/null and b/app/images/fullscreen.png differ
diff --git a/app/images/fullscreen@2x.png b/app/images/fullscreen@2x.png
new file mode 100644
index 0000000000..447372fdb0
Binary files /dev/null and b/app/images/fullscreen@2x.png differ
diff --git a/app/images/fullscreenclose.png b/app/images/fullscreenclose.png
new file mode 100644
index 0000000000..d3d6543be4
Binary files /dev/null and b/app/images/fullscreenclose.png differ
diff --git a/app/images/loading.png b/app/images/loading.png
new file mode 100644
index 0000000000..6d8e975938
Binary files /dev/null and b/app/images/loading.png differ
diff --git a/app/images/loading@2x.png b/app/images/loading@2x.png
new file mode 100644
index 0000000000..87a14647c4
Binary files /dev/null and b/app/images/loading@2x.png differ
diff --git a/app/images/minimize.png b/app/images/minimize.png
new file mode 100644
index 0000000000..731b123eb7
Binary files /dev/null and b/app/images/minimize.png differ
diff --git a/app/images/minimize@2x.png b/app/images/minimize@2x.png
new file mode 100644
index 0000000000..feb342d58c
Binary files /dev/null and b/app/images/minimize@2x.png differ
diff --git a/app/images/official.png b/app/images/official.png
new file mode 100644
index 0000000000..f96e0b73b4
Binary files /dev/null and b/app/images/official.png differ
diff --git a/app/images/official@2x.png b/app/images/official@2x.png
new file mode 100644
index 0000000000..1ba6879fc5
Binary files /dev/null and b/app/images/official@2x.png differ
diff --git a/app/images/paused.png b/app/images/paused.png
new file mode 100644
index 0000000000..221adf9f1b
Binary files /dev/null and b/app/images/paused.png differ
diff --git a/app/images/paused@2x.png b/app/images/paused@2x.png
new file mode 100644
index 0000000000..dea41b1881
Binary files /dev/null and b/app/images/paused@2x.png differ
diff --git a/app/images/restarting.png b/app/images/restarting.png
new file mode 100644
index 0000000000..64860c7b21
Binary files /dev/null and b/app/images/restarting.png differ
diff --git a/app/images/restarting@2x.png b/app/images/restarting@2x.png
new file mode 100644
index 0000000000..5e8a2f0953
Binary files /dev/null and b/app/images/restarting@2x.png differ
diff --git a/app/images/running.png b/app/images/running.png
new file mode 100644
index 0000000000..15ccd11b6b
Binary files /dev/null and b/app/images/running.png differ
diff --git a/app/images/running@2x.png b/app/images/running@2x.png
new file mode 100644
index 0000000000..406bfc05f4
Binary files /dev/null and b/app/images/running@2x.png differ
diff --git a/app/images/runningwave.png b/app/images/runningwave.png
new file mode 100644
index 0000000000..2934116bf3
Binary files /dev/null and b/app/images/runningwave.png differ
diff --git a/app/images/runningwave@2x.png b/app/images/runningwave@2x.png
new file mode 100644
index 0000000000..d20f942fce
Binary files /dev/null and b/app/images/runningwave@2x.png differ
diff --git a/app/images/stopped.png b/app/images/stopped.png
new file mode 100644
index 0000000000..2b64d4a3cd
Binary files /dev/null and b/app/images/stopped.png differ
diff --git a/app/images/stopped@2x.png b/app/images/stopped@2x.png
new file mode 100644
index 0000000000..776f0c8aea
Binary files /dev/null and b/app/images/stopped@2x.png differ
diff --git a/app/index.html b/app/index.html
new file mode 100644
index 0000000000..5b3d4ba12d
--- /dev/null
+++ b/app/index.html
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/main.js b/app/main.js
new file mode 100644
index 0000000000..aa765b88f4
--- /dev/null
+++ b/app/main.js
@@ -0,0 +1,69 @@
+var React = require('react');
+var Router = require('react-router');
+var RetinaImage = require('react-retina-image');
+var Raven = require('raven');
+var async = require('async');
+var docker = require('./docker.js');
+var boot2docker = require('./boot2docker.js');
+var Setup = require('./Setup.react');
+var Containers = require('./Containers.react');
+var ContainerDetails = require('./ContainerDetails.react');
+var ContainerStore = require('./ContainerStore');
+var Radial = require('./Radial.react');
+
+var Route = Router.Route;
+var NotFoundRoute = Router.NotFoundRoute;
+var DefaultRoute = Router.DefaultRoute;
+var Link = Router.Link;
+var RouteHandler = Router.RouteHandler;
+
+var App = React.createClass({
+ render: function () {
+ return (
+
+ );
+ }
+});
+
+var NoContainers = React.createClass({
+ render: function () {
+ return (
+
+ No Containers
+
+ );
+ }
+});
+
+var routes = (
+
+
+
+
+
+
+
+
+
+
+);
+
+boot2docker.ip(function (err, ip) {
+ if (window.location.hash !== '#/') {
+ docker.setHost(ip);
+ ContainerStore.init(function () {
+ Router.run(routes, function (Handler) {
+ React.render(, document.body);
+ });
+ });
+ } else {
+ Router.run(routes, function (Handler) {
+ React.render(, document.body);
+ });
+ }
+});
+
+if (process.env.NODE_ENV !== 'development') {
+ Raven.config('https://0a5f032d745d4acaae94ce46f762c586@app.getsentry.com/35057', {
+ }).install();
+}
diff --git a/app/registry.js b/app/registry.js
new file mode 100644
index 0000000000..2fc0dcf0be
--- /dev/null
+++ b/app/registry.js
@@ -0,0 +1,84 @@
+var async = require('async');
+var $ = require('jquery');
+
+var Registry = {
+ token: function(repository, callback) {
+ $.ajax({
+ url: 'https://registry.hub.docker.com/v1/repositories/' + repository + '/images',
+ headers: {
+ 'X-Docker-Token': true,
+ },
+ success: function (res, status, xhr) {
+ callback(null, xhr.getResponseHeader('X-Docker-Token'));
+ },
+ error: function (err) {
+ callback(err);
+ }
+ });
+ },
+ ancestry: function (imageId, token, callback) {
+ $.ajax({
+ url: 'https://registry-1.docker.io/v1/images/' + imageId + '/ancestry',
+ headers: {
+ Authorization: 'Token ' + token
+ },
+ success: function (layers, status, xhr) {
+ callback(null, layers);
+ },
+ error: function (err) {
+ callback(err);
+ }
+ });
+ },
+ imageId: function (repository, tag, token, callback) {
+ $.ajax({
+ url: 'https://registry-1.docker.io/v1/repositories/' + repository + '/tags/' + tag,
+ headers: {
+ Authorization: 'Token ' + token
+ },
+ success: function (res, status, xhr) {
+ callback(null, res);
+ },
+ error: function (err) {
+ callback(err);
+ }
+ });
+ },
+
+ // Returns an array [{Id: <12 character image ID, size: size of layer in bytes}]
+ layers: function (repository, tag, callback) {
+ var self = this;
+ this.token(repository, function (err, token) {
+ self.imageId(repository, tag, token, function (err, imageId) {
+ self.ancestry(imageId, token, function (err, layers) {
+ async.map(layers, function (layer, callback) {
+ $.ajax({
+ url: 'https://registry-1.docker.io/v1/images/' + layer + '/json',
+ headers: {
+ Authorization: 'Token ' + token
+ },
+ success: function (res, status, xhr) {
+ var size = xhr.getResponseHeader('X-Docker-Size');
+ callback(null, {
+ Id: layer.slice(0, 12),
+ size: parseInt(size, 10)
+ });
+ },
+ error: function (err) {
+ callback(err);
+ }
+ });
+ }, function (err, results) {
+ if (err) {
+ callback('Could not sum' + err);
+ return;
+ }
+ callback(null, results);
+ });
+ });
+ });
+ });
+ }
+};
+
+module.exports = Registry;
diff --git a/meteor/client/stylesheets/bootstrap/alerts.import.less b/app/styles/bootstrap/alerts.less
similarity index 100%
rename from meteor/client/stylesheets/bootstrap/alerts.import.less
rename to app/styles/bootstrap/alerts.less
diff --git a/meteor/client/stylesheets/bootstrap/badges.import.less b/app/styles/bootstrap/badges.less
similarity index 88%
rename from meteor/client/stylesheets/bootstrap/badges.import.less
rename to app/styles/bootstrap/badges.less
index 20624f30db..b27c405a30 100755
--- a/meteor/client/stylesheets/bootstrap/badges.import.less
+++ b/app/styles/bootstrap/badges.less
@@ -44,11 +44,17 @@
}
// Account for badges in navs
- a.list-group-item.active > &,
+ .list-group-item.active > &,
.nav-pills > .active > a > & {
color: @badge-active-color;
background-color: @badge-active-bg;
}
+ .list-group-item > & {
+ float: right;
+ }
+ .list-group-item > & + & {
+ margin-right: 5px;
+ }
.nav-pills > li > a > & {
margin-left: 3px;
}
diff --git a/app/styles/bootstrap/bootstrap.less b/app/styles/bootstrap/bootstrap.less
new file mode 100755
index 0000000000..61b77474f9
--- /dev/null
+++ b/app/styles/bootstrap/bootstrap.less
@@ -0,0 +1,50 @@
+// Core variables and mixins
+@import "variables.less";
+@import "mixins.less";
+
+// Reset and dependencies
+@import "normalize.less";
+@import "print.less";
+@import "glyphicons.less";
+
+// Core CSS
+@import "scaffolding.less";
+@import "type.less";
+@import "code.less";
+@import "grid.less";
+@import "tables.less";
+@import "forms.less";
+@import "buttons.less";
+
+// Components
+@import "component-animations.less";
+@import "dropdowns.less";
+@import "button-groups.less";
+@import "input-groups.less";
+@import "navs.less";
+@import "navbar.less";
+@import "breadcrumbs.less";
+@import "pagination.less";
+@import "pager.less";
+@import "labels.less";
+@import "badges.less";
+@import "jumbotron.less";
+@import "thumbnails.less";
+@import "alerts.less";
+@import "progress-bars.less";
+@import "media.less";
+@import "list-group.less";
+@import "panels.less";
+@import "responsive-embed.less";
+@import "wells.less";
+@import "close.less";
+
+// Components w/ JavaScript
+@import "modals.less";
+@import "tooltip.less";
+@import "popovers.less";
+@import "carousel.less";
+
+// Utility classes
+@import "utilities.less";
+@import "responsive-utilities.less";
diff --git a/meteor/client/stylesheets/bootstrap/breadcrumbs.import.less b/app/styles/bootstrap/breadcrumbs.less
similarity index 100%
rename from meteor/client/stylesheets/bootstrap/breadcrumbs.import.less
rename to app/styles/bootstrap/breadcrumbs.less
diff --git a/meteor/client/stylesheets/bootstrap/button-groups.import.less b/app/styles/bootstrap/button-groups.less
similarity index 88%
rename from meteor/client/stylesheets/bootstrap/button-groups.import.less
rename to app/styles/bootstrap/button-groups.less
index 7021ecd171..f84febbd56 100755
--- a/meteor/client/stylesheets/bootstrap/button-groups.import.less
+++ b/app/styles/bootstrap/button-groups.less
@@ -18,10 +18,6 @@
&.active {
z-index: 2;
}
- &:focus {
- // Remove focus outline when dropdown JS adds it after closing the menu
- outline: 0;
- }
}
}
@@ -198,7 +194,6 @@
}
-
// Justified button groups
// ----------------------
@@ -226,15 +221,23 @@
// Checkbox and radio options
//
// In order to support the browser's form validation feedback, powered by the
-// `required` attribute, we have to "hide" the inputs via `opacity`. We cannot
-// use `display: none;` or `visibility: hidden;` as that also hides the popover.
+// `required` attribute, we have to "hide" the inputs via `clip`. We cannot use
+// `display: none;` or `visibility: hidden;` as that also hides the popover.
+// Simply visually hiding the inputs via `opacity` would leave them clickable in
+// certain cases which is prevented by using `clip` and `pointer-events`.
// This way, we ensure a DOM element is visible to position the popover from.
//
-// See https://github.com/twbs/bootstrap/pull/12794 for more.
+// See https://github.com/twbs/bootstrap/pull/12794 and
+// https://github.com/twbs/bootstrap/pull/14559 for more information.
-[data-toggle="buttons"] > .btn > input[type="radio"],
-[data-toggle="buttons"] > .btn > input[type="checkbox"] {
- position: absolute;
- z-index: -1;
- .opacity(0);
+[data-toggle="buttons"] {
+ > .btn,
+ > .btn-group > .btn {
+ input[type="radio"],
+ input[type="checkbox"] {
+ position: absolute;
+ clip: rect(0,0,0,0);
+ pointer-events: none;
+ }
+ }
}
diff --git a/meteor/client/stylesheets/bootstrap/buttons.import.less b/app/styles/bootstrap/buttons.less
similarity index 96%
rename from meteor/client/stylesheets/bootstrap/buttons.import.less
rename to app/styles/bootstrap/buttons.less
index 492bdc65ae..40553c6386 100755
--- a/meteor/client/stylesheets/bootstrap/buttons.import.less
+++ b/app/styles/bootstrap/buttons.less
@@ -12,6 +12,7 @@
font-weight: @btn-font-weight;
text-align: center;
vertical-align: middle;
+ touch-action: manipulation;
cursor: pointer;
background-image: none; // Reset unusual Firefox-on-Android default style; see https://github.com/necolas/normalize.css/issues/214
border: 1px solid transparent;
@@ -22,13 +23,15 @@
&,
&:active,
&.active {
- &:focus {
+ &:focus,
+ &.focus {
.tab-focus();
}
}
&:hover,
- &:focus {
+ &:focus,
+ &.focus {
color: @btn-default-color;
text-decoration: none;
}
@@ -43,7 +46,7 @@
&.disabled,
&[disabled],
fieldset[disabled] & {
- cursor: not-allowed;
+ cursor: @cursor-disabled;
pointer-events: none; // Future-proof disabling of clicks
.opacity(.65);
.box-shadow(none);
@@ -85,11 +88,11 @@
.btn-link {
color: @link-color;
font-weight: normal;
- cursor: pointer;
border-radius: 0;
&,
&:active,
+ &.active,
&[disabled],
fieldset[disabled] & {
background-color: transparent;
diff --git a/meteor/client/stylesheets/bootstrap/carousel.import.less b/app/styles/bootstrap/carousel.less
similarity index 89%
rename from meteor/client/stylesheets/bootstrap/carousel.import.less
rename to app/styles/bootstrap/carousel.less
index 1644ddf7f5..5724d8a56e 100755
--- a/meteor/client/stylesheets/bootstrap/carousel.import.less
+++ b/app/styles/bootstrap/carousel.less
@@ -24,6 +24,30 @@
&:extend(.img-responsive);
line-height: 1;
}
+
+ // WebKit CSS3 transforms for supported devices
+ @media all and (transform-3d), (-webkit-transform-3d) {
+ transition: transform .6s ease-in-out;
+ backface-visibility: hidden;
+ perspective: 1000;
+
+ &.next,
+ &.active.right {
+ transform: translate3d(100%, 0, 0);
+ left: 0;
+ }
+ &.prev,
+ &.active.left {
+ transform: translate3d(-100%, 0, 0);
+ left: 0;
+ }
+ &.next.left,
+ &.prev.right,
+ &.active {
+ transform: translate3d(0, 0, 0);
+ left: 0;
+ }
+ }
}
> .active,
diff --git a/meteor/client/stylesheets/bootstrap/close.import.less b/app/styles/bootstrap/close.less
similarity index 100%
rename from meteor/client/stylesheets/bootstrap/close.import.less
rename to app/styles/bootstrap/close.less
diff --git a/meteor/client/stylesheets/bootstrap/code.import.less b/app/styles/bootstrap/code.less
similarity index 98%
rename from meteor/client/stylesheets/bootstrap/code.import.less
rename to app/styles/bootstrap/code.less
index baa13df613..a08b4d48c4 100755
--- a/meteor/client/stylesheets/bootstrap/code.import.less
+++ b/app/styles/bootstrap/code.less
@@ -32,6 +32,7 @@ kbd {
kbd {
padding: 0;
font-size: 100%;
+ font-weight: bold;
box-shadow: none;
}
}
diff --git a/meteor/client/stylesheets/bootstrap/component-animations.import.less b/app/styles/bootstrap/component-animations.less
similarity index 73%
rename from meteor/client/stylesheets/bootstrap/component-animations.import.less
rename to app/styles/bootstrap/component-animations.less
index 9400a0d32f..967715d98b 100755
--- a/meteor/client/stylesheets/bootstrap/component-animations.import.less
+++ b/app/styles/bootstrap/component-animations.less
@@ -17,8 +17,9 @@
.collapse {
display: none;
+ visibility: hidden;
- &.in { display: block; }
+ &.in { display: block; visibility: visible; }
tr&.in { display: table-row; }
tbody&.in { display: table-row-group; }
}
@@ -27,5 +28,7 @@
position: relative;
height: 0;
overflow: hidden;
- .transition(height .35s ease);
+ .transition-property(~"height, visibility");
+ .transition-duration(.35s);
+ .transition-timing-function(ease);
}
diff --git a/meteor/client/stylesheets/bootstrap/dropdowns.import.less b/app/styles/bootstrap/dropdowns.less
similarity index 96%
rename from meteor/client/stylesheets/bootstrap/dropdowns.import.less
rename to app/styles/bootstrap/dropdowns.less
index 0881908631..84a48c1413 100755
--- a/meteor/client/stylesheets/bootstrap/dropdowns.import.less
+++ b/app/styles/bootstrap/dropdowns.less
@@ -103,16 +103,15 @@
&:focus {
color: @dropdown-link-disabled-color;
}
-}
-// Nuke hover/focus effects
-.dropdown-menu > .disabled > a {
+
+ // Nuke hover/focus effects
&:hover,
&:focus {
text-decoration: none;
background-color: transparent;
background-image: none; // Remove CSS gradient
.reset-filter();
- cursor: not-allowed;
+ cursor: @cursor-disabled;
}
}
@@ -198,7 +197,7 @@
// Component alignment
//
-// Reiterate per navbar.import.less and the modified component alignment there.
+// Reiterate per navbar.less and the modified component alignment there.
@media (min-width: @grid-float-breakpoint) {
.navbar-right {
@@ -212,4 +211,3 @@
}
}
}
-
diff --git a/meteor/client/stylesheets/bootstrap/forms.import.less b/app/styles/bootstrap/forms.less
similarity index 88%
rename from meteor/client/stylesheets/bootstrap/forms.import.less
rename to app/styles/bootstrap/forms.less
index 9b7631e770..1bcc2b6b97 100755
--- a/meteor/client/stylesheets/bootstrap/forms.import.less
+++ b/app/styles/bootstrap/forms.less
@@ -141,7 +141,7 @@ output {
&[disabled],
&[readonly],
fieldset[disabled] & {
- cursor: not-allowed;
+ cursor: @cursor-disabled;
background-color: @input-bg-disabled;
opacity: 1; // iOS fix for unreadable disabled content
}
@@ -168,23 +168,27 @@ input[type="search"] {
// Special styles for iOS temporal inputs
//
// In Mobile Safari, setting `display: block` on temporal inputs causes the
-// text within the input to become vertically misaligned.
-// As a workaround, we set a pixel line-height that matches the
-// given height of the input. Since this fucks up everything else, we have to
-// appropriately reset it for Internet Explorer and the size variations.
+// text within the input to become vertically misaligned. As a workaround, we
+// set a pixel line-height that matches the given height of the input, but only
+// for Safari.
-input[type="date"],
-input[type="time"],
-input[type="datetime-local"],
-input[type="month"] {
- line-height: @input-height-base;
- // IE8+ misaligns the text within date inputs, so we reset
- line-height: @line-height-base ~"\0";
-
- &.input-sm {
+@media screen and (-webkit-min-device-pixel-ratio: 0) {
+ input[type="date"],
+ input[type="time"],
+ input[type="datetime-local"],
+ input[type="month"] {
+ line-height: @input-height-base;
+ }
+ input[type="date"].input-sm,
+ input[type="time"].input-sm,
+ input[type="datetime-local"].input-sm,
+ input[type="month"].input-sm {
line-height: @input-height-small;
}
- &.input-lg {
+ input[type="date"].input-lg,
+ input[type="time"].input-lg,
+ input[type="datetime-local"].input-lg,
+ input[type="month"].input-lg {
line-height: @input-height-large;
}
}
@@ -208,11 +212,11 @@ input[type="month"] {
.checkbox {
position: relative;
display: block;
- min-height: @line-height-computed; // clear the floating input if there is no label text
margin-top: 10px;
margin-bottom: 10px;
label {
+ min-height: @line-height-computed; // Ensure the input doesn't jump when there is no text
padding-left: 20px;
margin-bottom: 0;
font-weight: normal;
@@ -258,7 +262,7 @@ input[type="checkbox"] {
&[disabled],
&.disabled,
fieldset[disabled] & {
- cursor: not-allowed;
+ cursor: @cursor-disabled;
}
}
// These classes are used directly on