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 ( +
+
+

{name}

View +
+ {progress} +
+
+ {logs} +
+
+
+ ); + } +}); + +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 ( +
  • +
    +
    + {name} +
    +
    +
    +
    {r.star_count}
    +
    +
    +
    + +
    +
  • + ); + }); + + var title; + if (this.state.query) { + title =
    Results
    ; + } else { + title =
    Recommended
    ; + } + + return ( + +
    +
    + +
    + What's an image? +
    +
    + {title} +
      + {results} +
    +
    +
    + +
    +
    + ); + } +}); + +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 ( +
    +
    +
    +
    +
    +

    containers

    +
    + }> +
    + +
    +
    +
    +
    +
    + +
    +
    + +
    +
    + ); + } +}); + +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 ( +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + {percentage} +
    +
    + ); + } +}); + +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 @@ + + + +Generated by Fontastic.me + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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