diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md new file mode 100644 index 0000000000..9bff3b7cce --- /dev/null +++ b/.github/ISSUE_TEMPLATE.md @@ -0,0 +1,11 @@ +### Expected behavior + +### Actual behavior + +### Information about the Issue + + +### Steps to reproduce the behavior + + 1. ... + 2. ... diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 21febb49bd..59c8d9f32b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -17,18 +17,18 @@ Before you file an issue or a pull request, read the following tips on how to ke - [License](#license) -### Prerequisites for developing Kitematic on Mac -You will need to install: +### Prerequisites for developing Kitematic on Mac +You will need to install: - The [Docker Toolbox](https://docker.com/toolbox) - [Node.js](https://nodejs.org/) - Wine `brew install wine` (only if you want to generate a Windows release on OS X) -- The latest Xcode from the Apple App Store. +- The latest Xcode from the Apple App Store. -### Prerequisites for developing Kitematic on Windows -You will need to install: +### Prerequisites for developing Kitematic on Windows +You will need to install: - The [Docker Toolbox](https://docker.com/toolbox) - [Node.js](https://nodejs.org/) -- Open a command prompt (`cmd`) and run the command `mkdir ~/AppData/Roaming/npm` +- Open a command prompt (`cmd`) and run the command `mkdir ~/AppData/Roaming/npm` - [Visual Studio 2013 Community](https://www.visualstudio.com/en-us/products/visual-studio-community-vs.aspx) (or similar) - You do not need to install any optional packages during install. - [Python](https://www.python.org/downloads/release/python-2710/) diff --git a/Gruntfile.js b/Gruntfile.js index 735dc33004..e03a31ecb3 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -91,7 +91,7 @@ module.exports = function (grunt) { 'FileDescription': WINDOWS_APPNAME, 'InternalName': BASENAME + '.exe', 'OriginalFilename': BASENAME + '.exe', - 'LegalCopyright': 'Copyright 2015 Docker Inc. All rights reserved.' + 'LegalCopyright': 'Copyright 2015-2016 Docker Inc. All rights reserved.' } } } diff --git a/LICENSE b/LICENSE index dfed3fbe9e..498341cd9a 100644 --- a/LICENSE +++ b/LICENSE @@ -187,7 +187,7 @@ same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright 2014-2015 Docker, Inc. + Copyright 2014-2016 Docker, Inc. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/Makefile b/Makefile index 01e2c56e4c..2ec5ecf027 100644 --- a/Makefile +++ b/Makefile @@ -5,6 +5,11 @@ run: npm install npm run +# Get the IP ADDRESS +DOCKER_IP=$(shell python -c "import urlparse ; print urlparse.urlparse('$(DOCKER_HOST)').hostname or ''") +HUGO_BASE_URL=$(shell test -z "$(DOCKER_IP)" && echo localhost || echo "$(DOCKER_IP)") +HUGO_BIND_IP=0.0.0.0 + # import the existing docs build cmds from docker/docker DOCS_MOUNT := $(if $(DOCSDIR),-v $(CURDIR)/$(DOCSDIR):/$(DOCSDIR)) DOCSPORT := 8000 @@ -13,7 +18,9 @@ DOCKER_DOCS_IMAGE := kitematic-docs$(if $(GIT_BRANCH),:$(GIT_BRANCH)) DOCKER_RUN_DOCS := docker run --rm -it $(DOCS_MOUNT) docs: docs-build - $(DOCKER_RUN_DOCS) -p $(if $(DOCSPORT),$(DOCSPORT):)8000 "$(DOCKER_DOCS_IMAGE)" mkdocs serve + $(DOCKER_RUN_DOCS) -p $(if $(DOCSPORT),$(DOCSPORT):)8000 "$(DOCKER_DOCS_IMAGE)" \ + hugo server \ + --port=$(DOCSPORT) --baseUrl=$(HUGO_BASE_URL) --bind=$(HUGO_BIND_IP) docs-shell: docs-build $(DOCKER_RUN_DOCS) -p $(if $(DOCSPORT),$(DOCSPORT):)8000 "$(DOCKER_DOCS_IMAGE)" bash diff --git a/docs/Dockerfile b/docs/Dockerfile index b42ddbd82d..da11b2de13 100644 --- a/docs/Dockerfile +++ b/docs/Dockerfile @@ -5,9 +5,10 @@ RUN svn checkout https://github.com/docker/compose/trunk/docs /docs/content/comp RUN svn checkout https://github.com/docker/docker/trunk/docs /docs/content/docker RUN svn checkout https://github.com/docker/swarm/trunk/docs /docs/content/swarm RUN svn checkout https://github.com/docker/distribution/trunk/docs /docs/content/registry -RUN svn checkout https://github.com/docker/tutorials/trunk/docs /docs/content +RUN svn checkout https://github.com/docker/notary/trunk/docs /docs/content/notary RUN svn checkout https://github.com/docker/opensource/trunk/docs /docs/content/opensource RUN svn checkout https://github.com/docker/machine/trunk/docs /docs/content/machine +RUN svn checkout https://github.com/docker/toolbox/trunk/docs /docs/content/toolbox ENV PROJECT=kitematic # To get the git info for this repo diff --git a/docs/faq.md b/docs/faq.md index 3b9cd4cfc5..8a0a840e2f 100755 --- a/docs/faq.md +++ b/docs/faq.md @@ -4,7 +4,7 @@ title = "Frequently Asked Questions" description = "Documentation covering common questions users have about Kitematic" keywords = ["docker, documentation, about, technology, kitematic, gui"] [menu.main] -parent="smn_workw_kitematic" +parent="toolbox_kitematic" weight=5 +++ diff --git a/docs/index.md b/docs/index.md index 9190f56478..7cf9ec44ac 100755 --- a/docs/index.md +++ b/docs/index.md @@ -4,7 +4,8 @@ title = "Kitematic" description = "Documentation that provides an overview of Kitematic and installation instructions" keywords = ["docker, documentation, about, technology, kitematic, gui"] [menu.main] -parent="mn_install" +identifier="toolbox_kitematic" +parent="workw_toolbox" weight=2 +++ diff --git a/docs/known-issues.md b/docs/known-issues.md index a12c9bbe1b..b69b1c5ac9 100755 --- a/docs/known-issues.md +++ b/docs/known-issues.md @@ -4,7 +4,7 @@ title = "Known Issues" description = "Information about known issues in Kitematic" keywords = ["docker, documentation, about, technology, kitematic, gui"] [menu.main] -parent="smn_workw_kitematic" +parent="toolbox_kitematic" weight=5 +++ diff --git a/docs/minecraft-server.md b/docs/minecraft-server.md index 2c4d089bae..433b0f0dbe 100755 --- a/docs/minecraft-server.md +++ b/docs/minecraft-server.md @@ -4,7 +4,7 @@ title = "Set up a Minecraft Server" description = "Tutorial demonstrating the setup of a Minecraft server using Docker and Kitematic" keywords = ["docker, documentation, about, technology, kitematic, gui, minecraft, tutorial"] [menu.main] -parent="smn_workw_kitematic" +parent="toolbox_kitematic" weight=2 +++ diff --git a/docs/nginx-web-server.md b/docs/nginx-web-server.md index 7121c4fd6f..7c1199a579 100755 --- a/docs/nginx-web-server.md +++ b/docs/nginx-web-server.md @@ -4,7 +4,7 @@ title = "Set up an Nginx web server" description = "Tutorial demonstrating the setup of an Nginx web server using Docker and Kitematic" keywords = ["docker, documentation, about, technology, kitematic, gui, nginx, tutorial"] [menu.main] -parent="smn_workw_kitematic" +parent="toolbox_kitematic" weight=1 +++ diff --git a/docs/rethinkdb-dev-database.md b/docs/rethinkdb-dev-database.md index 620cb8f189..d571a121bb 100755 --- a/docs/rethinkdb-dev-database.md +++ b/docs/rethinkdb-dev-database.md @@ -4,7 +4,7 @@ title = "Creating a Local RethinkDB Database for Development" description = "Tutorial demonstrating the setup of an RethinkDB database for development" keywords = ["docker, documentation, about, technology, kitematic, gui, rethink, tutorial"] [menu.main] -parent="smn_workw_kitematic" +parent="toolbox_kitematic" weight=3 +++ diff --git a/docs/userguide.md b/docs/userguide.md index 4943e45562..01792cc58c 100755 --- a/docs/userguide.md +++ b/docs/userguide.md @@ -4,7 +4,7 @@ title = "Kitematic User Guide: Intro & Overview" description = "Documentation that provides an overview of Kitematic and installation instructions" keywords = ["docker, documentation, about, technology, kitematic, gui"] [menu.main] -parent="smn_workw_kitematic" +parent="toolbox_kitematic" +++ diff --git a/jest-unit.json b/jest-unit.json index bc91f0f679..19685d46ae 100644 --- a/jest-unit.json +++ b/jest-unit.json @@ -13,6 +13,7 @@ "object-assign", "underscore", "source-map-support", - "/node_modules/.*JSONStream" + "/node_modules/.*JSONStream", + "/node_modules/core-js" ] } diff --git a/package.json b/package.json index 6536e0e8bf..c4890e6ad9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "Kitematic", - "version": "0.9.6", + "version": "0.10.0", "author": "Kitematic", "description": "Simple Docker Container management for Mac OS X.", "homepage": "https://kitematic.com/", @@ -16,13 +16,11 @@ "test": "jest -c jest-unit.json", "integration": "jest -c jest-integration.json", "release": "grunt release", - "release-mac": "grunt release-mac", "lint": "jsxhint src" }, "license": "Apache-2.0", "electron-version": "0.35.4", "dependencies": { - "JSONStream": "^1.0.7", "alt": "^0.16.2", "ansi-to-html": "0.3.0", "any-promise": "^0.1.0", @@ -35,6 +33,7 @@ "dockerode": "^2.2.7", "install": "^0.1.8", "jquery": "^2.1.3", + "JSONStream": "^1.0.7", "mixpanel": "kitematic/mixpanel-node", "mkdirp": "^0.5.0", "node-uuid": "^1.4.3", diff --git a/resources/terminal b/resources/terminal index ea55c18810..38e1d9896d 100755 --- a/resources/terminal +++ b/resources/terminal @@ -15,22 +15,49 @@ EOF` function open_iterm () { osascript > /dev/null < /dev/null < { throw err; }); + ipcRenderer.on('application:quitting', () => { + docker.detachEvent(); if (localStorage.getItem('settings.closeVMOnQuit') === 'true') { machine.stop(); } }); + +window.onbeforeunload = function () { + docker.detachEvent(); +}; diff --git a/src/browser.js b/src/browser.js index 0ffca63efc..50beae7e69 100644 --- a/src/browser.js +++ b/src/browser.js @@ -4,18 +4,20 @@ const BrowserWindow = electron.BrowserWindow; import fs from 'fs'; import os from 'os'; - import path from 'path'; import child_process from 'child_process'; process.env.NODE_PATH = path.join(__dirname, 'node_modules'); process.env.RESOURCES_PATH = path.join(__dirname, '/../resources'); -process.env.PATH = '/usr/local/bin:' + process.env.PATH; +if (process.platform !== 'win32') { + process.env.PATH = '/usr/local/bin:' + process.env.PATH; +} var size = {}, settingsjson = {}; try { - size = JSON.parse(fs.readFileSync(path.join(process.env[(process.platform === 'win32') ? 'USERPROFILE' : 'HOME'], 'Library', 'Application\ Support', 'Kitematic', 'size'))); + size = JSON.parse(fs.readFileSync(path.join(app.getPath('userData'), 'size'))); } catch (err) {} + try { settingsjson = JSON.parse(fs.readFileSync(path.join(__dirname, 'settings.json'), 'utf8')); } catch (err) {} @@ -45,6 +47,7 @@ app.on('ready', function () { return false; }); + if (os.platform() === 'win32') { mainWindow.on('close', function () { mainWindow.webContents.send('application:quitting'); diff --git a/src/components/ContainerHome.react.js b/src/components/ContainerHome.react.js index dad139c8a8..0399c0da93 100644 --- a/src/components/ContainerHome.react.js +++ b/src/components/ContainerHome.react.js @@ -32,6 +32,7 @@ var ContainerHome = React.createClass({ }, handleErrorClick: function () { + // Display wiki for proxy: https://github.com/docker/kitematic/wiki/Common-Proxy-Issues-&-Fixes shell.openExternal('https://github.com/kitematic/kitematic/issues/new'); }, @@ -52,7 +53,7 @@ var ContainerHome = React.createClass({ if (this.props.container.Error) { body = (
-

We're sorry. There seem to be an error:

+

We're sorry. There seems to be an error:

{this.props.container.Error}

If this error is invalid, please file a ticket on our Github repo.

File Ticket diff --git a/src/components/ContainerHomeFolders.react.js b/src/components/ContainerHomeFolders.react.js index cab35902df..fbbc704bf0 100644 --- a/src/components/ContainerHomeFolders.react.js +++ b/src/components/ContainerHomeFolders.react.js @@ -32,6 +32,7 @@ var ContainerHomeFolder = React.createClass({ mounts.forEach(m => { if (m.Destination === destination) { m.Source = util.windowsToLinuxPath(newSource); + m.Driver = null; } }); diff --git a/src/components/ContainerListItem.react.js b/src/components/ContainerListItem.react.js index 2791ca9d22..1e6af7a9a7 100644 --- a/src/components/ContainerListItem.react.js +++ b/src/components/ContainerListItem.react.js @@ -97,7 +97,7 @@ var ContainerListItem = React.createClass({ return ( -
  • +
  • {state}
    diff --git a/src/components/ContainerSettingsVolumes.react.js b/src/components/ContainerSettingsVolumes.react.js index 914fa57494..7462db8c04 100644 --- a/src/components/ContainerSettingsVolumes.react.js +++ b/src/components/ContainerSettingsVolumes.react.js @@ -28,7 +28,7 @@ var ContainerSettingsVolumes = React.createClass({ metrics.track('Choose Directory for Volume'); - var mounts = _.clone(this.props.container.Mounts); + let mounts = _.clone(this.props.container.Mounts); _.each(mounts, m => { if (m.Destination === dockerVol) { m.Source = util.windowsToLinuxPath(directory); @@ -36,7 +36,7 @@ var ContainerSettingsVolumes = React.createClass({ } }); - var binds = mounts.map(m => { + let binds = mounts.map(m => { return m.Source + ':' + m.Destination; }); @@ -50,7 +50,7 @@ var ContainerSettingsVolumes = React.createClass({ from: 'settings' }); - var mounts = _.clone(this.props.container.Mounts); + let mounts = _.clone(this.props.container.Mounts); _.each(mounts, m => { if (m.Destination === dockerVol) { m.Source = null; diff --git a/src/components/Containers.react.js b/src/components/Containers.react.js index d7b611168f..130557f15f 100644 --- a/src/components/Containers.react.js +++ b/src/components/Containers.react.js @@ -26,7 +26,7 @@ var Containers = React.createClass({ containerStore.listen(this.update); }, - componentDidUnmount: function () { + componentWillUnmount: function () { containerStore.unlisten(this.update); }, @@ -106,43 +106,7 @@ var Containers = React.createClass({ metrics.track('Opened Issue Reporter', { from: 'app' }); - shell.openExternal('https://github.com/kitematic/kitematic/issues/new'); - }, - - handleMouseEnterDockerTerminal: function () { - this.setState({ - currentButtonLabel: 'Open terminal to use Docker command line.' - }); - }, - - handleMouseLeaveDockerTerminal: function () { - this.setState({ - currentButtonLabel: '' - }); - }, - - handleMouseEnterReportIssue: function () { - this.setState({ - currentButtonLabel: 'Report an issue or suggest feedback.' - }); - }, - - handleMouseLeaveReportIssue: function () { - this.setState({ - currentButtonLabel: '' - }); - }, - - handleMouseEnterPreferences: function () { - this.setState({ - currentButtonLabel: 'Change app preferences.' - }); - }, - - handleMouseLeavePreferences: function () { - this.setState({ - currentButtonLabel: '' - }); + shell.openExternal('https://github.com/docker/kitematic/issues/new'); }, render: function () { @@ -169,9 +133,9 @@ var Containers = React.createClass({
    - DOCKER CLI - - + DOCKER CLI + +
    diff --git a/src/components/ImageCard.react.js b/src/components/ImageCard.react.js index a18420b16c..2816757695 100644 --- a/src/components/ImageCard.react.js +++ b/src/components/ImageCard.react.js @@ -5,6 +5,7 @@ import shell from 'shell'; import RetinaImage from 'react-retina-image'; import metrics from '../utils/MetricsUtil'; import containerActions from '../actions/ContainerActions'; +import imageActions from '../actions/ImageActions'; import containerStore from '../stores/ContainerStore'; import tagStore from '../stores/TagStore'; import tagActions from '../actions/TagActions'; @@ -14,8 +15,8 @@ var ImageCard = React.createClass({ mixins: [Router.Navigation], getInitialState: function () { return { - tags: [], - chosenTag: 'latest' + tags: this.props.tags || [], + chosenTag: this.props.chosenTag || 'latest' }; }, componentDidMount: function () { @@ -49,11 +50,14 @@ var ImageCard = React.createClass({ private: this.props.image.is_private, official: this.props.image.namespace === 'library', userowned: this.props.image.is_user_repo, - recommended: this.props.image.is_recommended + recommended: this.props.image.is_recommended, + local: this.props.image.is_local || false }); let name = containerStore.generateName(this.props.image.name); - let repo = this.props.image.namespace === 'library' ? this.props.image.name : this.props.image.namespace + '/' + this.props.image.name; - containerActions.run(name, repo, this.state.chosenTag); + let localImage = this.props.image.is_local || false; + let repo = (this.props.image.namespace === 'library' || this.props.image.namespace === 'local') ? this.props.image.name : this.props.image.namespace + '/' + this.props.image.name; + + containerActions.run(name, repo, this.state.chosenTag, localImage); this.transitionTo('containerHome', {name}); }, handleMenuOverlayClick: function () { @@ -67,7 +71,12 @@ var ImageCard = React.createClass({ handleTagOverlayClick: function () { let $tagOverlay = $(this.getDOMNode()).find('.tag-overlay'); $tagOverlay.fadeIn(300); - tagActions.tags(this.props.image.namespace + '/' + this.props.image.name); + let localImage = this.props.image.is_local || false; + if (localImage) { + tagActions.localTags(this.props.image.namespace + '/' + this.props.image.name, this.props.tags); + } else { + tagActions.tags(this.props.image.namespace + '/' + this.props.image.name); + } }, handleCloseTagOverlay: function () { let $menuOverlay = $(this.getDOMNode()).find('.menu-overlay'); @@ -75,12 +84,17 @@ var ImageCard = React.createClass({ var $tagOverlay = $(this.getDOMNode()).find('.tag-overlay'); $tagOverlay.fadeOut(300); }, + handleDeleteImgClick: function (image) { + if (this.state.chosenTag && !this.props.image.inUse) { + imageActions.destroy(image.RepoTags[0].split(':')[0] + ':' + this.state.chosenTag); + } + }, handleRepoClick: function () { - var repoUri = 'https://registry.hub.docker.com/'; + var repoUri = 'https://hub.docker.com/'; if (this.props.image.namespace === 'library') { repoUri = repoUri + '_/' + this.props.image.name; } else { - repoUri = repoUri + 'u/' + this.props.image.namespace + '/' + this.props.image.name; + repoUri = repoUri + 'r/' + this.props.image.namespace + '/' + this.props.image.name; } shell.openExternal(repoUri); }, @@ -105,11 +119,12 @@ var ImageCard = React.createClass({ var description; if (this.props.image.description) { description = this.props.image.description; + } else if(this.props.image.short_description){ + description = this.props.image.short_description; } else { - description = "No description."; + description = 'No description.'; } var logoStyle = { - //backgroundImage: `linear-gradient(-180deg, ${this.props.image.gradient_start} 4%, ${this.props.image.gradient_end} 100%)` backgroundColor: this.props.image.gradient_start }; var imgsrc; @@ -148,21 +163,74 @@ var ImageCard = React.createClass({ ); } - let favCount = (this.props.image.star_count < 1000) ? numeral(this.props.image.star_count).value() : numeral(this.props.image.star_count).format('0.0a').toUpperCase(); - let pullCount = (this.props.image.pull_count < 1000) ? numeral(this.props.image.pull_count).value() : numeral(this.props.image.pull_count).format('0a').toUpperCase(); - return ( -
    + + let create, overlay; + if (this.props.image.is_local) { + create = ( +
    +
    + {this.state.chosenTag} + +
    +
    + +
    +
    + CREATE +
    +
    + ); + overlay = (
    SELECTED TAG: {this.state.chosenTag}
    -
    - VIEW ON DOCKER HUB +
    + Delete Tag
    + {this.props.image.inUse ?

    To delete, remove all containers
    using the above image

    : null }
    + ); + } else { + let favCount = (this.props.image.star_count < 1000) ? numeral(this.props.image.star_count).value() : numeral(this.props.image.star_count).format('0.0a').toUpperCase(); + let pullCount = (this.props.image.pull_count < 1000) ? numeral(this.props.image.pull_count).value() : numeral(this.props.image.pull_count).format('0a').toUpperCase(); + create = ( +
    +
    + + {favCount} + + {pullCount} +
    +
    + +
    +
    + CREATE +
    +
    + ); + + overlay = ( +
    +
    + SELECTED TAG: {this.state.chosenTag} +
    +
    + VIEW ON DOCKER HUB +
    +
    + +
    +
    + ); + } + return ( +
    + {overlay}

    Please select an image tag.

    {tags} @@ -185,20 +253,7 @@ var ImageCard = React.createClass({ {description}
    -
    -
    - - {favCount} - - {pullCount} -
    -
    - -
    -
    - CREATE -
    -
    + {create}
    ); diff --git a/src/components/NewContainerSearch.react.js b/src/components/NewContainerSearch.react.js index 06b72dc946..886ad17817 100644 --- a/src/components/NewContainerSearch.react.js +++ b/src/components/NewContainerSearch.react.js @@ -10,6 +10,8 @@ import repositoryActions from '../actions/RepositoryActions'; import repositoryStore from '../stores/RepositoryStore'; import accountStore from '../stores/AccountStore'; import accountActions from '../actions/AccountActions'; +import imageActions from '../actions/ImageActions'; +import imageStore from '../stores/ImageStore'; var _searchPromise = null; @@ -20,6 +22,8 @@ module.exports = React.createClass({ query: '', loading: repositoryStore.loading(), repos: repositoryStore.all(), + images: imageStore.all(), + imagesErr: imageStore.error, username: accountStore.getState().username, verified: accountStore.getState().verified, accountLoading: accountStore.getState().loading, @@ -34,6 +38,7 @@ module.exports = React.createClass({ this.refs.searchInput.getDOMNode().focus(); repositoryStore.listen(this.update); accountStore.listen(this.updateAccount); + imageStore.listen(this.updateImage); repositoryActions.search(); }, componentWillUnmount: function () { @@ -51,7 +56,14 @@ module.exports = React.createClass({ currentPage: repositoryStore.getState().currentPage, totalPage: repositoryStore.getState().totalPage, previousPage: repositoryStore.getState().previousPage, - nextPage: repositoryStore.getState().nextPage + nextPage: repositoryStore.getState().nextPage, + error: repositoryStore.getState().error + }); + }, + updateImage: function (imgStore) { + this.setState({ + images: imgStore.images, + error: imgStore.error }); }, updateAccount: function () { @@ -73,14 +85,14 @@ module.exports = React.createClass({ nextPage = (page + 1 > this.state.totalPage) ? this.state.totalPage : page + 1; totalPage = this.state.totalPage; } - this.setState({ query: query, loading: true, currentPage: page, previousPage: previousPage, nextPage: nextPage, - totalPage: totalPage + totalPage: totalPage, + error: null }); _searchPromise = Promise.delay(200).cancellable().then(() => { @@ -102,11 +114,17 @@ module.exports = React.createClass({ }, handleFilter: function (filter) { + this.setState({error: null}); + // If we're clicking on the filter again - refresh if (filter === 'userrepos' && this.getQuery().filter === 'userrepos') { repositoryActions.repos(); } + if (filter === 'userimages' && this.getQuery().filter === 'userimages') { + imageActions.all(); + } + if (filter === 'recommended' && this.getQuery().filter === 'recommended') { repositoryActions.recommended(); } @@ -188,10 +206,16 @@ module.exports = React.createClass({ ) : null; + let errorMsg = null; + if (this.state.error === null || this.state.error.message.indexOf('getaddrinfo ENOTFOUND') !== -1) { + errorMsg = 'There was an error contacting Docker Hub.'; + } else { + errorMsg = this.state.error.message.replace('HTTP code is 409 which indicates error: conflict - ', ''); + } if (this.state.error) { results = (
    -

    There was an error contacting Docker Hub.

    +

    {errorMsg}

    ); paginateResults = null; @@ -215,6 +239,34 @@ module.exports = React.createClass({
    ); paginateResults = null; + } else if (filter === 'userimages') { + let userImageItems = this.state.images.map(image => { + let repo = image.RepoTags[0].split(':')[0]; + if (repo.indexOf('/') === -1) { + repo = 'local/' + repo; + } + [image.namespace, image.name] = repo.split('/'); + image.description = null; + let tags = image.tags.join('-'); + image.star_count = 0; + image.is_local = true; + return (); + }); + let userImageResults = userImageItems.length ? ( +
    +
    +

    My Images

    +
    + {userImageItems} +
    +
    +
    + ) :
    +

    Cannot find any local image.

    +
    ; + results = ( + {userImageResults} + ); } else if (this.state.loading) { results = (
    @@ -300,23 +352,30 @@ module.exports = React.createClass({ 'icon-search': true, 'search-icon': true }); + let searchClasses = classNames('search-bar'); + if (filter === 'userimages') { + searchClasses = classNames('search-bar', { + hidden: true + }); + } return (
    -
    - -
    -
    -
    +
    + +
    +
    +
    FILTER BY All Recommended My Repos + My Images
    diff --git a/src/components/Setup.react.js b/src/components/Setup.react.js index 741322e174..3635092cee 100644 --- a/src/components/Setup.react.js +++ b/src/components/Setup.react.js @@ -3,12 +3,13 @@ import Router from 'react-router'; import Radial from './Radial.react.js'; import RetinaImage from 'react-retina-image'; import Header from './Header.react'; -import Util from '../utils/Util'; +import util from '../utils/Util'; import metrics from '../utils/MetricsUtil'; import setupStore from '../stores/SetupStore'; import setupActions from '../actions/SetupActions'; import shell from 'shell'; + var Setup = React.createClass({ mixins: [Router.Navigation], @@ -20,7 +21,7 @@ var Setup = React.createClass({ setupStore.listen(this.update); }, - componentDidUnmount: function () { + componentWillUnmount: function () { setupStore.unlisten(this.update); }, @@ -32,6 +33,10 @@ var Setup = React.createClass({ setupActions.retry(false); }, + handleUseVbox: function () { + setupActions.useVbox(); + }, + handleErrorRemoveRetry: function () { console.log('Deleting VM and trying again.' ); setupActions.retry(true); @@ -63,6 +68,12 @@ var Setup = React.createClass({ }, renderProgress: function () { + let title = 'Starting Docker VM'; + let descr = 'To run Docker containers on your computer, Kitematic is starting a Linux virtual machine. This may take a minute...'; + if (util.isNative()) { + title = 'Checking Docker'; + descr = 'To run Docker containers on your computer, Kitematic is checking the Docker connection.'; + } return (
    @@ -72,8 +83,8 @@ var Setup = React.createClass({
    -

    Starting Docker VM

    -

    To run Docker containers on your computer, Kitematic is starting a Linux virtual machine. This may take a minute...

    +

    {title}

    +

    {descr}

    @@ -84,22 +95,24 @@ var Setup = React.createClass({ renderError: function () { let deleteVmAndRetry; - if (Util.isLinux()) { + if (util.isLinux()) { if (!this.state.started) { deleteVmAndRetry = ( ); } + } else if (util.isNative()) { + deleteVmAndRetry = ( + + ); + } else if (this.state.started) { + deleteVmAndRetry = ( + + ); } else { - if (this.state.started) { - deleteVmAndRetry = ( - - ); - } else { - deleteVmAndRetry = ( - - ); - } + deleteVmAndRetry = ( + + ); } return (
    diff --git a/src/stores/ImageStore.js b/src/stores/ImageStore.js new file mode 100644 index 0000000000..397b3c3c7e --- /dev/null +++ b/src/stores/ImageStore.js @@ -0,0 +1,59 @@ +import alt from '../alt'; +import imageActions from '../actions/ImageActions'; +import imageServerActions from '../actions/ImageServerActions'; + +class ImageStore { + constructor () { + this.bindActions(imageActions); + this.bindActions(imageServerActions); + this.results = []; + this.images = []; + this.imagesLoading = false; + this.resultsLoading = false; + this.error = null; + } + + error (error) { + this.setState({error: error, imagesLoading: false, resultsLoading: false}); + } + + clearError () { + this.setState({error: null}); + } + + destroyed (data) { + let images = this.images; + if ((data && data[1] && data[1].Deleted)) { + delete images[data[1].Deleted]; + } + this.setState({error: null}); + } + + updated (images) { + let tags = {}; + let finalImages = []; + images.map((image) => { + image.RepoTags.map(repoTags => { + let [name, tag] = repoTags.split(':'); + if (typeof tags[name] !== 'undefined') { + finalImages[tags[name]].tags.push(tag); + if (image.inUse) { + finalImages[tags[name]].inUse = image.inUse; + } + } else { + image.tags = [tag]; + tags[name] = finalImages.length; + finalImages.push(image); + } + }); + }); + this.setState({error: null, images: finalImages, imagesLoading: false}); + } + + static all () { + let state = this.getState(); + return state.images; + } +} + +export default alt.createStore(ImageStore); diff --git a/src/stores/TagStore.js b/src/stores/TagStore.js index 13a2b980cf..4ddb4c5a05 100644 --- a/src/stores/TagStore.js +++ b/src/stores/TagStore.js @@ -21,6 +21,15 @@ class TagStore { this.emitChange(); } + localTags ({repo, tags}) { + let data = []; + tags.map((value) => { + data.push({'name': value}); + }); + this.loading[repo] = true; + this.tagsUpdated({repo, tags: data || []}); + } + tagsUpdated ({repo, tags}) { this.tags[repo] = tags; this.loading[repo] = false; diff --git a/src/utils/ContainerUtil.js b/src/utils/ContainerUtil.js index 2a9496a6e0..a19576970f 100644 --- a/src/utils/ContainerUtil.js +++ b/src/utils/ContainerUtil.js @@ -33,7 +33,6 @@ var ContainerUtil = { var [dockerPort, portType] = key.split('/'); var localUrl = null; var port = null; - if (value && value.length) { port = value[0].HostPort; } diff --git a/src/utils/DockerMachineUtil.js b/src/utils/DockerMachineUtil.js index 49886372c4..634c8059ac 100644 --- a/src/utils/DockerMachineUtil.js +++ b/src/utils/DockerMachineUtil.js @@ -158,11 +158,13 @@ var DockerMachine = { } }); }); - } else if (util.isLinux()) { + } else if (util.isNative()) { cmd = cmd || process.env.SHELL; - var terminal = util.linuxTerminal(); - if (terminal) - util.execFile(terminal.concat([cmd])).then(() => {}); + var terminal = util.isLinux() ? util.linuxTerminal() : [path.join(process.env.RESOURCES_PATH, 'terminal')]; + terminal.push(cmd); + if (terminal) { + util.execFile(terminal).then(() => {}); + } } else { cmd = cmd || process.env.SHELL; this.url(machineName).then(machineUrl => { diff --git a/src/utils/DockerUtil.js b/src/utils/DockerUtil.js index 9d22c5ac98..0f6c4cca41 100644 --- a/src/utils/DockerUtil.js +++ b/src/utils/DockerUtil.js @@ -8,32 +8,42 @@ import util from './Util'; import hubUtil from './HubUtil'; import metrics from '../utils/MetricsUtil'; import containerServerActions from '../actions/ContainerServerActions'; +import imageServerActions from '../actions/ImageServerActions'; +import Promise from 'bluebird'; import rimraf from 'rimraf'; import stream from 'stream'; import JSONStream from 'JSONStream'; -export default { + + +var DockerUtil = { host: null, client: null, placeholders: {}, - streams: {}, + stream: null, + eventStream: null, activeContainerName: null, + localImages: null, + imagesUsed: [], setup (ip, name) { - if (!ip || !name) { + if (!ip && !name) { throw new Error('Falsy ip or name passed to docker client setup'); } + this.host = ip; - if (util.isLinux()) { - this.host = 'localhost'; - this.client = new dockerode({socketPath: '/var/run/docker.sock'}); + if (ip.indexOf('local') !== -1) { + try { + this.client = new dockerode({socketPath: '/var/run/docker.sock'}); + } catch (error) { + throw new Error('Cannot connect to the Docker daemon. Is the daemon running?'); + } } else { let certDir = path.join(util.home(), '.docker/machine/machines/', name); if (!fs.existsSync(certDir)) { throw new Error('Certificate directory does not exist'); } - this.host = ip; this.client = new dockerode({ protocol: 'https', host: ip, @@ -45,9 +55,31 @@ export default { } }, + async version () { + let version = null; + let maxRetries = 10; + let retries = 0; + let error_message = ""; + while (version == null && retries < maxRetries) { + this.client.version((error,data) => { + if (!error) { + version = data.Version; + } else { + error_message = error; + } + retries++; + }); + await Promise.delay(1000); + } + if (version == null) { + throw new Error(error_message); + } + return version; + }, + init () { this.placeholders = JSON.parse(localStorage.getItem('placeholders')) || {}; - this.fetchAllContainers(); + this.refresh(); this.listen(); // Resume pulling containers that were previously being pulled @@ -88,6 +120,7 @@ export default { container.start((error) => { if (error) { containerServerActions.error({name, error}); + console.log('error starting: %o - %o', name, error); return; } containerServerActions.started({name, error}); @@ -121,7 +154,7 @@ export default { if (image.Config.Cmd) { containerData.Cmd = image.Config.Cmd; } else if (!image.Config.Entrypoint) { - containerData.Cmd = 'bash'; + containerData.Cmd = 'sh'; } let existing = this.client.getContainer(name); @@ -136,6 +169,7 @@ export default { this.startContainer(name); delete this.placeholders[name]; localStorage.setItem('placeholders', JSON.stringify(this.placeholders)); + this.refresh(); }); }); }); @@ -156,14 +190,20 @@ export default { fetchAllContainers () { this.client.listContainers({all: true}, (err, containers) => { if (err) { + console.error(err); return; } + this.imagesUsed = []; async.map(containers, (container, callback) => { this.client.getContainer(container.Id).inspect((error, container) => { if (error) { callback(null, null); return; } + let imgSha = container.Image.replace('sha256:', ''); + if (_.indexOf(this.imagesUsed, imgSha) === -1) { + this.imagesUsed.push(imgSha); + } container.Name = container.Name.replace('/', ''); callback(null, container); }); @@ -171,15 +211,55 @@ export default { containers = containers.filter(c => c !== null); if (err) { // TODO: add a global error handler for this + console.error(err); return; } containerServerActions.allUpdated({containers: _.indexBy(containers.concat(_.values(this.placeholders)), 'Name')}); this.logs(); + this.fetchAllImages(); }); }); }, - run (name, repository, tag) { + fetchAllImages () { + this.client.listImages((err, list) => { + if (err) { + imageServerActions.error(err); + } else { + list.map((image, idx) => { + let imgSha = image.Id.replace('sha256:', ''); + if (_.indexOf(this.imagesUsed, imgSha) !== -1) { + list[idx].inUse = true; + } else { + list[idx].inUse = false; + } + }); + this.localImages = list; + imageServerActions.updated(list); + } + }); + }, + + removeImage (selectedRepoTag) { + this.localImages.some((image) => { + image.RepoTags.map(repoTag => { + if (repoTag === selectedRepoTag) { + this.client.getImage(selectedRepoTag).remove({'force': true}, (err, data) => { + if (err) { + console.error(err); + imageServerActions.error(err); + } else { + imageServerActions.destroyed(data); + this.refresh(); + } + }); + return true; + } + }); + }); + }, + + run (name, repository, tag, local = false) { tag = tag || 'latest'; let imageName = repository + ':' + tag; @@ -200,30 +280,34 @@ export default { this.placeholders[name] = placeholderData; localStorage.setItem('placeholders', JSON.stringify(this.placeholders)); - - this.pullImage(repository, tag, error => { - if (error) { - containerServerActions.error({name, error}); - return; - } - - if (!this.placeholders[name]) { - return; - } - + if (local) { this.createContainer(name, {Image: imageName, Tty: true, OpenStdin: true}); - }, + } else { + this.pullImage(repository, tag, error => { + if (error) { + containerServerActions.error({name, error}); + this.refresh(); + return; + } - // progress is actually the progression PER LAYER (combined in columns) - // not total because it's not accurate enough - progress => { - containerServerActions.progress({name, progress}); - }, + if (!this.placeholders[name]) { + return; + } + + this.createContainer(name, {Image: imageName, Tty: true, OpenStdin: true}); + }, + + // progress is actually the progression PER LAYER (combined in columns) + // not total because it's not accurate enough + progress => { + containerServerActions.progress({name, progress}); + }, - () => { - containerServerActions.waiting({name, waiting: true}); - }); + () => { + containerServerActions.waiting({name, waiting: true}); + }); + } }, updateContainer (name, data) { @@ -231,6 +315,7 @@ export default { existing.inspect((error, existingData) => { if (error) { containerServerActions.error({name, error}); + this.refresh(); return; } @@ -267,6 +352,7 @@ export default { if (error) { // TODO: handle error containerServerActions.error({newName, error}); + this.refresh(); } rimraf(newPath, () => { if (fs.existsSync(oldPath)) { @@ -288,11 +374,13 @@ export default { this.client.getContainer(name).stop({t: 5}, stopError => { if (stopError && stopError.statusCode !== 304) { containerServerActions.error({name, stopError}); + this.refresh(); return; } this.client.getContainer(name).start(startError => { if (startError && startError.statusCode !== 304) { containerServerActions.error({name, startError}); + this.refresh(); return; } this.fetchContainer(name); @@ -304,6 +392,7 @@ export default { this.client.getContainer(name).stop({t: 5}, error => { if (error && error.statusCode !== 304) { containerServerActions.error({name, error}); + this.refresh(); return; } this.fetchContainer(name); @@ -314,6 +403,7 @@ export default { this.client.getContainer(name).start(error => { if (error && error.statusCode !== 304) { containerServerActions.error({name, error}); + this.refresh(); return; } this.fetchContainer(name); @@ -325,15 +415,17 @@ export default { containerServerActions.destroyed({id: name}); delete this.placeholders[name]; localStorage.setItem('placeholders', JSON.stringify(this.placeholders)); + this.refresh(); return; } let container = this.client.getContainer(name); - container.unpause(function () { - container.kill(function () { - container.remove(function (error) { + container.unpause( () => { + container.kill( () => { + container.remove( (error) => { if (error) { containerServerActions.error({name, error}); + this.refresh(); return; } containerServerActions.destroyed({id: name}); @@ -341,13 +433,14 @@ export default { if (fs.existsSync(volumePath)) { rimraf(volumePath, () => {}); } + this.refresh(); }); }); }); }, active (name) { - this.detach(); + this.detachLog(); this.activeContainerName = name; if (name) { @@ -368,6 +461,8 @@ export default { timestamps: 1 }, (err, logStream) => { if (err) { + // socket hang up can be captured + console.error(err); return; } @@ -394,12 +489,12 @@ export default { timestamps: 1 }, (err, logStream) => { if (err) { + // Socket hang up also can be found here + console.error(err); return; } - if (this.stream) { - this.detach(); - } + this.detachLog() this.stream = logStream; let timeout = null; @@ -418,14 +513,22 @@ export default { }); }, - detach () { + detachLog() { if (this.stream) { this.stream.destroy(); this.stream = null; } }, + detachEvent() { + if (this.eventStream) { + this.eventStream.destroy(); + this.eventStream = null; + } + }, + listen () { + this.detachEvent() this.client.getEvents((error, stream) => { if (error || !stream) { // TODO: Add app-wide error handler @@ -433,20 +536,22 @@ export default { } stream.setEncoding('utf8'); - stream.pipe(JSONStream.parse()).on('data', data => { - if (data.status === 'pull' || data.status === 'untag' || data.status === 'delete' || data.status === 'attach') { - return; + stream.on('data', json => { + let data = JSON.parse(json); + + if (data.status === 'pull' || data.status === 'untag' || data.status === 'delete' || data.status === 'attach') { + this.refresh(); } if (data.status === 'destroy') { containerServerActions.destroyed({id: data.id}); - this.detach(data.id); + this.detachLog() } else if (data.status === 'kill') { containerServerActions.kill({id: data.id}); - this.detach(data.id); + this.detachLog() } else if (data.status === 'stop') { containerServerActions.stopped({id: data.id}); - this.detach(data.id); + this.detachLog() } else if (data.status === 'create') { this.logs(); this.fetchContainer(data.id); @@ -457,6 +562,7 @@ export default { this.fetchContainer(data.id); } }); + this.eventStream = stream; }); }, @@ -477,6 +583,7 @@ export default { this.client.pull(repository + ':' + tag, opts, (err, stream) => { if (err) { + console.log('Err: %o', err); callback(err); return; } @@ -521,7 +628,7 @@ export default { if (i < leftOverLayers) { layerAmount += 1; } - columns.progress[i] = {layerIDs: [], nbLayers:0 , maxLayers: layerAmount, value: 0.0}; + columns.progress[i] = {layerIDs: [], nbLayers: 0, maxLayers: layerAmount, value: 0.0}; } } @@ -573,5 +680,11 @@ export default { callback(error); }); }); + }, + + refresh () { + this.fetchAllContainers(); } }; + +module.exports = DockerUtil; diff --git a/src/utils/SetupUtil.js b/src/utils/SetupUtil.js index 02faddafb8..ed624589ea 100644 --- a/src/utils/SetupUtil.js +++ b/src/utils/SetupUtil.js @@ -11,8 +11,14 @@ import machine from './DockerMachineUtil'; import docker from './DockerUtil'; import router from '../router'; +// Docker Machine exits with 3 to differentiate pre-create check failures (e.g. +// virtualization isn't enabled) from normal errors during create (exit code +// 1). +const precreateCheckExitCode = 3; + let _retryPromise = null; let _timers = []; +let useNative = util.isNative() ? util.isNative() : true; export default { simulateProgress (estimateSeconds) { @@ -31,12 +37,20 @@ export default { _timers = []; }, + async useVbox () { + metrics.track('Retried Setup with VBox'); + router.get().transitionTo('loading'); + setupServerActions.error({ error: { message: null }}); + _retryPromise.resolve(); + }, + retry (removeVM) { metrics.track('Retried Setup', { removeVM }); router.get().transitionTo('loading'); + setupServerActions.error({ error: { message: null }}); if (removeVM) { machine.rm().finally(() => { _retryPromise.resolve(); @@ -51,30 +65,43 @@ export default { return _retryPromise.promise; }, - setup() { - return util.isLinux() ? this.nativeSetup() : this.nonNativeSetup(); + async setup () { + while (true) { + try { + if (util.isNative()) { + let stats = fs.statSync('/var/run/docker.sock'); + if (stats.isSocket()) { + await this.nativeSetup(); + } else { + throw new Error('File found is not a socket'); + } + } else { + await this.nonNativeSetup(); + } + return; + } catch (error) { + metrics.track('Native Setup Failed'); + setupServerActions.error({error}); + + bugsnag.notify('Native Setup Failed', error.message, { + 'Docker Error': error.message + }, 'info'); + this.clearTimers(); + await this.pause(); + } + } }, async nativeSetup () { while (true) { try { - docker.setup('localhost', machine.name()); - docker.isDockerRunning(); - - break; - } catch (error) { router.get().transitionTo('setup'); - metrics.track('Native Setup Failed'); - setupServerActions.error({error}); - - let message = error.message.split('\n'); - let lastLine = message.length > 1 ? message[message.length - 2] : 'Docker Machine encountered an error.'; - bugsnag.notify('Native Setup Failed', lastLine, { - 'Docker Machine Logs': error.message - }, 'info'); - - this.clearTimers(); - await this.pause(); + docker.setup(util.isLinux() ? 'localhost':'docker.local'); + setupServerActions.started({started: true}); + this.simulateProgress(20); + return docker.version(); + } catch (error) { + throw new Error(error); } } }, @@ -86,7 +113,7 @@ export default { try { setupServerActions.started({started: false}); - // Make sure virtulBox and docker-machine are installed + // Make sure virtualBox and docker-machine are installed let virtualBoxInstalled = virtualBox.installed(); let machineInstalled = machine.installed(); if (!virtualBoxInstalled || !machineInstalled) { @@ -122,13 +149,16 @@ export default { } else { let state = await machine.status(); if (state !== 'Running') { + router.get().transitionTo('setup'); + setupServerActions.started({started: true}); if (state === 'Saved') { - router.get().transitionTo('setup'); this.simulateProgress(10); } else if (state === 'Stopped') { - router.get().transitionTo('setup'); this.simulateProgress(25); + } else { + this.simulateProgress(40); } + await machine.start(); } } @@ -146,6 +176,7 @@ export default { if (ip) { docker.setup(ip, machine.name()); + await docker.version(); } else { throw new Error('Could not determine IP from docker-machine.'); } @@ -154,11 +185,17 @@ export default { } catch (error) { router.get().transitionTo('setup'); - let novtx = error.message.indexOf('This computer doesn\'t have VT-X/AMD-v enabled') !== -1; - metrics.track(novtx ? 'Setup Halted' : 'Setup Failed', { - virtualBoxVersion, - machineVersion - }); + if (error.code === precreateCheckExitCode) { + metrics.track('Setup Halted', { + virtualBoxVersion, + machineVersion + }); + } else { + metrics.track('Setup Failed', { + virtualBoxVersion, + machineVersion + }); + } let message = error.message.split('\n'); let lastLine = message.length > 1 ? message[message.length - 2] : 'Docker Machine encountered an error.'; diff --git a/src/utils/Util.js b/src/utils/Util.js index c8e376021d..6e15f07a44 100644 --- a/src/utils/Util.js +++ b/src/utils/Util.js @@ -13,7 +13,7 @@ module.exports = { return new Promise((resolve, reject) => { child_process.execFile(args[0], args.slice(1), options, (error, stdout) => { if (error) { - reject(new Error('Encountered an error: ' + error)); + reject(error); } else { resolve(stdout); } @@ -37,6 +37,23 @@ module.exports = { isLinux: function () { return process.platform === 'linux'; }, + isNative: function () { + let native = null; + if (native === null) { + try { + // Check if file exists + fs.statSync('/var/run/docker.sock'); + native = true; + } catch (e) { + if (this.isLinux()) { + native = true; + } else { + native = false; + } + } + } + return native; + }, binsPath: function () { return this.isWindows() ? path.join(this.home(), 'Kitematic-bins') : path.join('/usr/local/bin'); }, @@ -62,9 +79,6 @@ module.exports = { // TODO: fix me for windows 7 return 'Documents'; }, - supportDir: function () { - return app.getPath('userData'); - }, CommandOrCtrl: function () { return this.isWindows() ? 'Ctrl' : 'Command'; }, @@ -95,7 +109,7 @@ module.exports = { // An official repo is alphanumeric characters separated by dashes or // underscores. // Examples: myrepo, my-docker-repo, my_docker_repo - // Non-exapmles: mynamespace/myrepo, my%!repo + // Non-examples: mynamespace/myrepo, my%!repo var repoRegexp = /^[a-z0-9]+(?:[._-][a-z0-9]+)*$/; return repoRegexp.test(name); }, @@ -166,9 +180,9 @@ module.exports = { dialog.showMessageBox({ type: 'warning', buttons: ['OK'], - message: 'The terminal emulator symbolic link doesn\'t exists. Please read the Wiki at https://github.com/kitematic/kitematic/wiki/Common-Issues-and-Fixes#early-linux-support-from-zedtux.' + message: 'The terminal emulator symbolic link doesn\'t exists. Please read the Wiki at https://github.com/docker/kitematic/wiki/Early-Linux-Support.' }); - return; + return false; } }, webPorts: ['80', '8000', '8080', '8888', '3000', '5000', '2368', '9200', '8983'] diff --git a/src/utils/WebUtil.js b/src/utils/WebUtil.js index 2d774fe013..f726d57ca1 100644 --- a/src/utils/WebUtil.js +++ b/src/utils/WebUtil.js @@ -10,7 +10,7 @@ import metrics from './MetricsUtil'; var WebUtil = { addWindowSizeSaving: function () { window.addEventListener('resize', function () { - fs.writeFileSync(path.join(util.supportDir(), 'size'), JSON.stringify({ + fs.writeFileSync(path.join(app.getPath('userData'), 'size'), JSON.stringify({ width: window.outerWidth, height: window.outerHeight })); diff --git a/styles/new-container.less b/styles/new-container.less index 7ca9247a3d..8885123910 100644 --- a/styles/new-container.less +++ b/styles/new-container.less @@ -67,6 +67,10 @@ justify-content: center; flex-shrink: 0; + .error { + color: red; + } + img { width: 380px; } @@ -160,6 +164,11 @@ font-weight: 500; margin-right: 0.7rem; } + .results-userimages { + border-left: 1px solid @gray-lighter; + padding-left: 1.2rem; + padding-right: 1.2rem; + } } } } @@ -227,6 +236,31 @@ bottom: 1rem; right: 1rem; } + .remove { + display: flex; + flex: 1 auto; + justify-content: center; + margin: 0.8rem 0 0 0; + a { + display: block; + text-decoration: none; + cursor: default; + &:focus { + outline: 0; + } + &.active { + .btn-delete { + opacity: 0.3; + } + } + } + } + .small { + color: red; + text-align: center; + padding-top: 5px; + font-size: 75%; + } } .tag-overlay { z-index: 1000;