diff --git a/images/connect-art.png b/images/connect-art.png new file mode 100644 index 0000000000..49328bdbd8 Binary files /dev/null and b/images/connect-art.png differ diff --git a/images/connect-art@2x.png b/images/connect-art@2x.png new file mode 100644 index 0000000000..4132e5725e Binary files /dev/null and b/images/connect-art@2x.png differ diff --git a/images/inspection.png b/images/inspection.png new file mode 100644 index 0000000000..68f66aef01 Binary files /dev/null and b/images/inspection.png differ diff --git a/images/inspection@2x.png b/images/inspection@2x.png new file mode 100644 index 0000000000..ecebe23041 Binary files /dev/null and b/images/inspection@2x.png differ diff --git a/images/private.png b/images/private.png new file mode 100644 index 0000000000..639f54f60a Binary files /dev/null and b/images/private.png differ diff --git a/images/private@2x.png b/images/private@2x.png new file mode 100644 index 0000000000..3009a51614 Binary files /dev/null and b/images/private@2x.png differ diff --git a/src/actions/AccountActions.js b/src/actions/AccountActions.js index 059f956a17..938aa52e91 100644 --- a/src/actions/AccountActions.js +++ b/src/actions/AccountActions.js @@ -21,6 +21,11 @@ class AccountActions { this.dispatch({}); hub.prompted(true); } + + verify () { + this.dispatch({}); + hub.verify(); + } } export default alt.createActions(AccountActions); diff --git a/src/actions/AccountServerActions.js b/src/actions/AccountServerActions.js index 758a6adaae..e88dc56a1a 100644 --- a/src/actions/AccountServerActions.js +++ b/src/actions/AccountServerActions.js @@ -1,13 +1,13 @@ import alt from '../alt'; import router from '../router'; - class AccountServerActions { constructor () { this.generateActions( 'loggedout', 'prompted', - 'errors' + 'errors', + 'verified' ); } @@ -19,7 +19,9 @@ class AccountServerActions { } signedup ({username}) { - router.get().transitionTo('search'); + if (router.get()) { + router.get().goBack(); + } this.dispatch({username}); } } diff --git a/src/actions/ContainerActions.js b/src/actions/ContainerActions.js index e5fd4e1dae..fbb33a966d 100644 --- a/src/actions/ContainerActions.js +++ b/src/actions/ContainerActions.js @@ -1,5 +1,6 @@ import alt from '../alt'; import dockerUtil from '../utils/DockerUtil'; +import hubUtil from '../utils/HubUtil'; class ContainerActions { start (name) { @@ -32,7 +33,7 @@ class ContainerActions { } run (name, repo, tag) { - dockerUtil.run(name, repo, tag); + dockerUtil.run(hubUtil.config(), name, repo, tag); } } diff --git a/src/actions/RepositoryActions.js b/src/actions/RepositoryActions.js index 092430ca24..a2a71f9074 100644 --- a/src/actions/RepositoryActions.js +++ b/src/actions/RepositoryActions.js @@ -1,8 +1,25 @@ import alt from '../alt'; +import regHubUtil from '../utils/RegHubUtil'; +import hubUtil from '../utils/HubUtil'; class RepositoryActions { - fetch () { + recommended () { this.dispatch({}); + regHubUtil.recommended(); + } + + search (query) { + this.dispatch({}); + regHubUtil.search(query); + } + + repos () { + this.dispatch({}); + regHubUtil.repos(hubUtil.jwt()); + } + + tags () { + } } diff --git a/src/actions/RepositoryServerActions.js b/src/actions/RepositoryServerActions.js index ffb2e2166c..b49e4d60d6 100644 --- a/src/actions/RepositoryServerActions.js +++ b/src/actions/RepositoryServerActions.js @@ -3,9 +3,10 @@ import alt from '../alt'; class RepositoryServerActions { constructor () { this.generateActions( - 'searched', - 'fetched', - 'error' + 'reposLoading', + 'resultsUpdated', + 'recommendedUpdated', + 'reposUpdated' ); } } diff --git a/src/actions/TagActions.js b/src/actions/TagActions.js new file mode 100644 index 0000000000..21da864666 --- /dev/null +++ b/src/actions/TagActions.js @@ -0,0 +1,12 @@ +import alt from '../alt'; +import regHubUtil from '../utils/RegHubUtil'; +import hubUtil from '../utils/HubUtil'; + +class TagActions { + tags (repo) { + this.dispatch({repo}); + regHubUtil.tags(hubUtil.jwt(), repo); + } +} + +export default alt.createActions(TagActions); diff --git a/src/actions/TagServerActions.js b/src/actions/TagServerActions.js new file mode 100644 index 0000000000..4149b81cb4 --- /dev/null +++ b/src/actions/TagServerActions.js @@ -0,0 +1,11 @@ +import alt from '../alt'; + +class TagServerActions { + constructor () { + this.generateActions( + 'tagsUpdated' + ); + } +} + +export default alt.createActions(TagServerActions); diff --git a/src/app.js b/src/app.js index 243070d656..8d3aee73b6 100644 --- a/src/app.js +++ b/src/app.js @@ -18,15 +18,17 @@ var hub = require('./utils/HubUtil'); var Router = require('react-router'); var routes = require('./routes'); var routerContainer = require('./router'); +var repositoryActions = require('./actions/RepositoryActions'); +hubUtil.init(); +repositoryActions.recommended(); +repositoryActions.repos(); webUtil.addWindowSizeSaving(); webUtil.addLiveReload(); webUtil.addBugReporting(); webUtil.disableGlobalBackspace(); -hubUtil.init(); - Menu.setApplicationMenu(Menu.buildFromTemplate(template())); metrics.track('Started App'); diff --git a/src/browser.js b/src/browser.js index e033f86f75..1e5a6f1367 100644 --- a/src/browser.js +++ b/src/browser.js @@ -18,7 +18,6 @@ try { settingsjson = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'settings.json'), 'utf8')); } catch (err) {} - var openURL = null; app.on('open-url', function (event, url) { event.preventDefault(); @@ -27,9 +26,9 @@ app.on('open-url', function (event, url) { app.on('ready', function () { var mainWindow = new BrowserWindow({ - width: size.width || 800, + width: size.width || 1000, height: size.height || 600, - 'min-width': 800, + 'min-width': 1000, 'min-height': 600, 'standard-window': false, resizable: true, diff --git a/src/components/Account.react.js b/src/components/Account.react.js index 1118d03948..ffb69fbece 100644 --- a/src/components/Account.react.js +++ b/src/components/Account.react.js @@ -40,13 +40,14 @@ module.exports = React.createClass({ render: function () { let close = this.state.prompted ? - Close : - Skip For Now; + Close : + Skip For Now; return (
+ {close}
@@ -55,7 +56,6 @@ module.exports = React.createClass({

Connect to Docker Hub

Pull and run private Docker Hub images by connecting your Docker Hub account to Kitematic.

- {close}
diff --git a/src/components/ContainerListNewItem.react.js b/src/components/ContainerListNewItem.react.js index 237e620c4a..55cebf975e 100644 --- a/src/components/ContainerListNewItem.react.js +++ b/src/components/ContainerListNewItem.react.js @@ -36,7 +36,7 @@ var ContainerListNewItem = React.createClass({ ); } return ( - +
  • diff --git a/src/components/Containers.react.js b/src/components/Containers.react.js index 41e6ff7523..3862259c23 100644 --- a/src/components/Containers.react.js +++ b/src/components/Containers.react.js @@ -5,7 +5,6 @@ var Router = require('react-router'); var containerStore = require('../stores/ContainerStore'); var ContainerList = require('./ContainerList.react'); var Header = require('./Header.react'); -var ipc = require('ipc'); var metrics = require('../utils/MetricsUtil'); var RetinaImage = require('react-retina-image'); var shell = require('shell'); diff --git a/src/components/Header.react.js b/src/components/Header.react.js index d6b043d88d..39b6ee4881 100644 --- a/src/components/Header.react.js +++ b/src/components/Header.react.js @@ -72,7 +72,12 @@ var Header = React.createClass({ }, handleUserClick: function (e) { let menu = new Menu(); - menu.append(new MenuItem({ label: 'Sign Out', click: this.handleLogoutClick.bind(this)})); + + if (!this.state.verified) { + menu.append(new MenuItem({ label: 'I\'ve Verified My Email Address', click: this.handleVerifyClick})); + } + + menu.append(new MenuItem({ label: 'Sign Out', click: this.handleLogoutClick})); menu.popup(remote.getCurrentWindow(), e.currentTarget.offsetLeft, e.currentTarget.offsetTop + e.currentTarget.clientHeight + 10); }, handleLoginClick: function () { @@ -81,6 +86,9 @@ var Header = React.createClass({ handleLogoutClick: function () { accountActions.logout(); }, + handleVerifyClick: function () { + accountActions.verify(); + }, render: function () { let updateWidget = this.state.updateAvailable ? UPDATE NOW : null; let buttons; @@ -108,7 +116,7 @@ var Header = React.createClass({ } else if (this.state.username) { username = ( - {this.state.username} + {this.state.username} {this.state.verified ? null : '(Unverified)'} ); } else { diff --git a/src/components/ImageCard.react.js b/src/components/ImageCard.react.js index 67a0a0a229..129b8ac1e1 100644 --- a/src/components/ImageCard.react.js +++ b/src/components/ImageCard.react.js @@ -1,13 +1,15 @@ var $ = require('jquery'); var React = require('react/addons'); var Router = require('react-router'); +var shell = require('shell'); var RetinaImage = require('react-retina-image'); var metrics = require('../utils/MetricsUtil'); var OverlayTrigger = require('react-bootstrap').OverlayTrigger; var Tooltip = require('react-bootstrap').Tooltip; -var util = require('../utils/Util'); var containerActions = require('../actions/ContainerActions'); var containerStore = require('../stores/ContainerStore'); +var tagStore = require('../stores/TagStore'); +var tagActions = require('../actions/TagActions'); var ImageCard = React.createClass({ mixins: [Router.Navigation], @@ -17,6 +19,23 @@ var ImageCard = React.createClass({ chosenTag: 'latest' }; }, + componentDidMount: function () { + tagStore.listen(this.update); + }, + componentWillUnmount: function () { + tagStore.unlisten(this.update); + }, + update: function () { + let repo = this.props.image.namespace + '/' + this.props.image.name; + let state = tagStore.getState(); + if (this.state.tags.length && !state.tags[repo]) { + $(this.getDOMNode()).find('.tag-overlay').fadeOut(300); + } + this.setState({ + loading: tagStore.getState().loading[repo] || false, + tags: tagStore.getState().tags[repo] || [] + }); + }, handleTagClick: function (tag) { this.setState({ chosenTag: tag @@ -33,64 +52,42 @@ var ImageCard = React.createClass({ containerActions.run(name, repository, this.state.chosenTag); this.transitionTo('containerHome', {name}); }, - handleTagOverlayClick: function (name) { - var $tagOverlay = $(this.getDOMNode()).find('.tag-overlay'); + handleTagOverlayClick: function () { + let $tagOverlay = $(this.getDOMNode()).find('.tag-overlay'); $tagOverlay.fadeIn(300); - $.get('https://registry.hub.docker.com/v1/repositories/' + name + '/tags', result => { - this.setState({ - tags: result - }); - }); + tagActions.tags(this.props.image.namespace + '/' + this.props.image.name); }, handleCloseTagOverlay: function () { var $tagOverlay = $(this.getDOMNode()).find('.tag-overlay'); $tagOverlay.fadeOut(300); }, handleRepoClick: function () { - var $repoUri = 'https://registry.hub.docker.com/'; + var repoUri = 'https://registry.hub.docker.com/'; if (this.props.image.is_official) { - $repoUri = $repoUri + "_/"; + repoUri = repoUri + '_/' + this.props.image.name; } else { - $repoUri = $repoUri + "u/"; + repoUri = repoUri + 'u/' + this.props.image.namespace + '/' + this.props.image.name; } - util.exec(['open', $repoUri + this.props.image.name]); - }, - componentDidMount: function() { - $.get('https://registry.hub.docker.com/v1/repositories/' + this.props.image.name + '/tags', result => { - this.setState({ - tags: result, - chosenTag: result[0].name - }); - }); + shell.openExternal(repoUri); }, render: function () { var self = this; - var name; - var imageNameTokens = this.props.image.name.split('/'); - var namespace; - var repo; - if (imageNameTokens.length > 1) { - namespace = imageNameTokens[0]; - repo = imageNameTokens[1]; - } else { - namespace = "official"; - repo = imageNameTokens[0]; - } - if (this.props.image.is_official) { + let name; + if (this.props.image.namespace === 'library') { name = (
    -
    {namespace}
    +
    official
    View on Docker Hub}> - {repo} + {this.props.image.name}
    ); } else { name = (
    -
    {namespace}
    +
    {this.props.image.namespace}
    View on Docker Hub}> - {repo} + {this.props.image.name}
    ); @@ -111,12 +108,16 @@ var ImageCard = React.createClass({ imgsrc = 'http://kitematic.com/recommended/kitematic_html.png'; } var tags; - if (self.state.tags.length > 0) { + if (self.state.loading) { + tags = ; + } else if (self.state.tags.length === 0) { + tags = No Tags; + } else { var tagDisplay = self.state.tags.map(function (t) { - if (t.name === self.state.chosenTag) { - return
    {t.name}
    ; + if (t === self.state.chosenTag) { + return
    {t}
    ; } else { - return
    {t.name}
    ; + return
    {t}
    ; } }); tags = ( @@ -124,13 +125,15 @@ var ImageCard = React.createClass({ {tagDisplay}
    ); - } else { - tags = ; } - var officialBadge; - if (this.props.image.is_official) { - officialBadge = ( - + var badge = null; + if (this.props.image.namespace === 'library') { + badge = ( + + ); + } else if (this.props.image.is_private) { + badge = ( + ); } return ( @@ -143,7 +146,7 @@ var ImageCard = React.createClass({
  • - {officialBadge} + {badge}
    {name} @@ -152,17 +155,13 @@ var ImageCard = React.createClass({ {description}
    - Favorites}> -
    - - {this.props.image.star_count} -
    -
    +
    + + {this.props.image.star_count} +
    - Change Tag}> - {this.state.chosenTag} - + {this.state.chosenTag}
    Create diff --git a/src/components/NewContainerSearch.react.js b/src/components/NewContainerSearch.react.js index ab81643b28..c44953b1cc 100644 --- a/src/components/NewContainerSearch.react.js +++ b/src/components/NewContainerSearch.react.js @@ -1,35 +1,57 @@ +var _ = require('underscore'); var React = require('react/addons'); +var Router = require('react-router'); +var RetinaImage = require('react-retina-image'); var ImageCard = require('./ImageCard.react'); var Promise = require('bluebird'); var metrics = require('../utils/MetricsUtil'); var classNames = require('classnames'); +var repositoryActions = require('../actions/RepositoryActions'); +var repositoryStore = require('../stores/RepositoryStore'); +var accountStore = require('../stores/AccountStore'); +var accountActions = require('../actions/AccountActions'); -var _recommended = []; var _searchPromise = null; module.exports = React.createClass({ + mixins: [Router.Navigation, Router.State], getInitialState: function () { return { query: '', - loading: false, - category: 'recommended', - recommendedrepos: [], - publicrepos: [], - userrepos: [], - results: [], - tab: 'all' + loading: repositoryStore.loading(), + repos: repositoryStore.all(), + username: accountStore.getState().username, + verified: accountStore.getState().verified, + accountLoading: accountStore.getState().loading, + error: repositoryStore.getState().error }; }, componentDidMount: function () { - // fetch recommended - // fetch public repos - // if logged in: my repos this.refs.searchInput.getDOMNode().focus(); + repositoryStore.listen(this.update); + accountStore.listen(this.updateAccount); + repositoryActions.search(); }, componentWillUnmount: function () { if (_searchPromise) { _searchPromise.cancel(); } + + repositoryStore.unlisten(this.update); + accountStore.unlisten(this.updateAccount); + }, + update: function () { + this.setState({ + loading: repositoryStore.loading(), + repos: repositoryStore.all() + }); + }, + updateAccount: function () { + this.setState({ + username: accountStore.getState().username, + verified: accountStore.getState().verified, + accountLoading: accountStore.getState().loading + }); }, search: function (query) { if (_searchPromise) { @@ -37,15 +59,6 @@ module.exports = React.createClass({ _searchPromise = null; } - if (!query.length) { - this.setState({ - query: query, - results: _recommended, - loading: false - }); - return; - } - this.setState({ query: query, loading: true @@ -54,7 +67,7 @@ module.exports = React.createClass({ _searchPromise = Promise.delay(200).cancellable().then(() => { metrics.track('Searched for Images'); _searchPromise = null; - // TODO: call search action + repositoryActions.search(query); }).catch(Promise.CancellationError, () => {}); }, handleChange: function (e) { @@ -64,38 +77,107 @@ module.exports = React.createClass({ } this.search(query); }, + handleFilter: function (filter) { + + // If we're clicking on the filter again - refresh + if (filter === 'userrepos' && this.getQuery().filter === 'userrepos') { + repositoryActions.repos(); + } + + if (filter === 'recommended' && this.getQuery().filter === 'recommended') { + repositoryActions.recommended(); + } + + this.transitionTo('search', {}, {filter: filter}); + }, + handleCheckVerification: function () { + accountActions.verify(); + }, render: function () { - var data = this.state.recommendedrepos; - var results; - if (data.length) { - var items = data.map(function (image) { - return ( - - ); - }); + let filter = this.getQuery().filter || 'all'; + let repos = _.values(this.state.repos) + .filter(repo => repo.name.indexOf(this.state.query) !== -1 || repo.namespace.indexOf(this.state.query) !== -1) + .filter(repo => filter === 'all' || (filter === 'recommended' && repo.is_recommended) || (filter === 'userrepos' && repo.is_user_repo)); + + let results; + if (this.state.error) { + results = ( +
    +

    There was an error contacting Docker Hub.

    +
    + ); + } else if (filter === 'userrepos' && !accountStore.getState().username) { + results = ( +
    +

    Log In or Sign Up to access your Docker Hub repositories.

    + +
    + ); + } else if (filter === 'userrepos' && !accountStore.getState().verified) { + let spinner = this.state.accountLoading ?
    : null; + results = ( +
    +

    Please verify your Docker Hub account email address

    +
    + {spinner} +
    + +
    + ); + } else if (this.state.loading) { + results = ( +
    +
    +

    Loading Images

    +
    +
    +
    + ); + } else if (repos.length) { + let recommendedItems = repos.filter(repo => repo.is_recommended).map(image => ); + let otherItems = repos.filter(repo => !repo.is_recommended && !repo.is_user_repo).map(image => ); + + let recommendedResults = recommendedItems.length ? ( +
    +

    Recommended

    +
    + {recommendedItems} +
    +
    + ) : null; + + let userRepoItems = repos.filter(repo => repo.is_user_repo).map(image => ); + let userRepoResults = userRepoItems.length ? ( +
    +

    My Repositories

    +
    + {userRepoItems} +
    +
    + ) : null; + + let otherResults = otherItems.length ? ( +
    +

    Other Repositories

    +
    + {otherItems} +
    +
    + ) : null; results = ( -
    - {items} +
    + {userRepoResults} + {recommendedResults} + {otherResults}
    ); } else { - if (this.state.results.length === 0 && this.state.query === '') { - results = ( -
    -
    -

    Loading Images

    -
    -
    -
    - ); - } else { - results = ( -
    -

    Cannot find a matching image.

    -
    - ); - } + results = ( +
    +

    Cannot find a matching image.

    +
    + ); } let loadingClasses = classNames({ @@ -114,16 +196,12 @@ module.exports = React.createClass({ 'search-icon': true }); - let allTabClasses = classNames({ - 'results-filter': - }); - return (
    - Select a Docker image to create a new container. + Select a Docker image to create a container.
    @@ -136,9 +214,9 @@ module.exports = React.createClass({
    FILTER BY - All - Recommended - My Repositories + All + Recommended + My Repositories
    {results}
    diff --git a/src/routes.js b/src/routes.js index d946cdbbda..8fcd2e3c8e 100644 --- a/src/routes.js +++ b/src/routes.js @@ -45,7 +45,7 @@ var routes = ( - + diff --git a/src/stores/AccountStore.js b/src/stores/AccountStore.js index fc2ce6c89d..7fd1e01044 100644 --- a/src/stores/AccountStore.js +++ b/src/stores/AccountStore.js @@ -44,16 +44,20 @@ class AccountStore { }); } - loggedin ({username}) { - this.setState({username, errors: {}, loading: false}); + loggedin ({username, verified}) { + this.setState({username, verified, errors: {}, loading: false}); } signedup ({username}) { this.setState({username, errors: {}, loading: false}); } + verify () { + this.setState({loading: true}); + } + verified ({verified}) { - this.setState({verified}); + this.setState({verified, loading: false}); } prompted ({prompted}) { diff --git a/src/stores/RepositoryStore.js b/src/stores/RepositoryStore.js index 4263b02671..dcaec5e1ba 100644 --- a/src/stores/RepositoryStore.js +++ b/src/stores/RepositoryStore.js @@ -1,30 +1,75 @@ +import _ from 'underscore'; import alt from '../alt'; import repositoryServerActions from '../actions/RepositoryServerActions'; +import repositoryActions from '../actions/RepositoryActions'; +import accountServerActions from '../actions/AccountServerActions'; +import accountStore from './AccountStore'; class RepositoryStore { constructor () { + this.bindActions(repositoryActions); this.bindActions(repositoryServerActions); - this.repos = []; + this.bindActions(accountServerActions); + this.results = []; this.recommended = []; - this.userrepos = []; - this.loading = false; + this.repos = []; + this.reposLoading = false; + this.recommendedLoading = false; + this.resultsLoading = false; this.error = null; } - fetch () { - this.setState({ - repos: [], - error: null, - loading: true - }); - } - - fetched ({repos}) { - this.setState({repos, loading: false}); - } - error ({error}) { - this.setState({error, loading: false}); + this.setState({error: error, reposLoading: false, recommendedLoading: false, resultsLoading: false}); + } + + repos () { + this.setState({reposError: null, reposLoading: true}); + } + + reposLoading () { + this.setState({reposLoading: true}); + } + + reposUpdated ({repos}) { + let accountState = accountStore.getState(); + + if (accountState.username && accountState.verified) { + this.setState({repos, reposLoading: false}); + } else { + this.setState({repos: [], reposLoading: false}); + } + } + + search () { + this.setState({error: null, resultsLoading: true}); + } + + resultsUpdated ({repos}) { + this.setState({results: repos, resultsLoading: false}); + } + + recommended () { + this.setState({error: null, recommendedLoading: true}); + } + + recommendedUpdated ({repos}) { + this.setState({recommended: repos, recommendedLoading: false}); + } + + loggedout () { + this.setState({repos: []}); + } + + static all () { + let state = this.getState(); + let all = state.recommended.concat(state.repos).concat(state.results); + return _.uniq(all, false, repo => repo.namespace + '/' + repo.name); + } + + static loading () { + let state = this.getState(); + return state.recommendedLoading || state.resultsLoading || state.reposLoading; } } diff --git a/src/stores/TagStore.js b/src/stores/TagStore.js new file mode 100644 index 0000000000..9293bb16c6 --- /dev/null +++ b/src/stores/TagStore.js @@ -0,0 +1,43 @@ +import alt from '../alt'; +import tagActions from '../actions/TagActions'; +import tagServerActions from '../actions/TagServerActions'; +import accountServerActions from '../actions/AccountServerActions'; + +class TagStore { + constructor () { + this.bindActions(tagActions); + this.bindActions(tagServerActions); + this.bindActions(accountServerActions); + + // maps 'namespace/name' => [list of tags] + this.tags = {}; + + // maps 'namespace/name' => true / false + this.loading = {}; + } + + tags ({repo}) { + this.loading[repo] = true; + this.emitChange(); + } + + tagsUpdated ({repo, tags}) { + this.tags[repo] = tags; + this.loading[repo] = false; + this.emitChange(); + } + + remove ({repo}) { + delete this.tags[repo]; + delete this.loading[repo]; + this.emitChange(); + } + + loggedout () { + this.loading = {}; + this.tags = {}; + this.emitChange(); + } +} + +export default alt.createStore(TagStore); diff --git a/src/utils/DockerUtil.js b/src/utils/DockerUtil.js index ccc6cb8644..66a1499471 100644 --- a/src/utils/DockerUtil.js +++ b/src/utils/DockerUtil.js @@ -4,7 +4,6 @@ import path from 'path'; import dockerode from 'dockerode'; import _ from 'underscore'; import util from './Util'; -import registry from '../utils/RegistryUtil'; import metrics from '../utils/MetricsUtil'; import containerServerActions from '../actions/ContainerServerActions'; import Promise from 'bluebird'; @@ -145,7 +144,7 @@ export default { }); }, - run (name, repository, tag) { + run (auth, name, repository, tag) { tag = tag || 'latest'; let imageName = repository + ':' + tag; @@ -165,7 +164,7 @@ export default { this.placeholders[name] = placeholderData; localStorage.setItem('placeholders', JSON.stringify(this.placeholders)); - this.pullImage(repository, tag, error => { + this.pullImage(auth, repository, tag, error => { if (error) { containerServerActions.error({name, error}); return; @@ -327,81 +326,66 @@ export default { }); }, - pullImage (repository, tag, callback, progressCallback, blockedCallback) { - registry.layers(repository, tag, (err, layerSizes) => { + pullImage (auth, repository, tag, callback, progressCallback, blockedCallback) { + // 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. + this.client.listImages({all: 1}, (err, images) => { + images = images || []; - // 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. - this.client.listImages({all: 1}, (err, images) => { - images = images || []; + this.client.pull(repository + ':' + tag, {authconfig: {key: auth}}, (err, stream) => { + if (err) { + callback(err); + return; + } + stream.setEncoding('utf8'); - let existingIds = new Set(images.map(function (image) { - return image.Id.slice(0, 12); - })); + let timeout = null; + let layerProgress = {}; + stream.on('data', str => { + var data = JSON.parse(str); - let layersToDownload = layerSizes.filter(function (layerSize) { - return !existingIds.has(layerSize.Id); - }); - - this.client.pull(repository + ':' + tag, (err, stream) => { - if (err) { - callback(err); + if (data.error) { return; } - stream.setEncoding('utf8'); - let layerProgress = layersToDownload.reduce(function (r, layer) { - if (_.findWhere(images, {Id: layer.Id})) { - r[layer.Id] = 1; + if (data.status && (data.status === 'Pulling dependent layers' || data.status.indexOf('already being pulled by another client') !== -1)) { + blockedCallback(); + return; + } + + if (!layerProgress[data.id]) { + layerProgress[data.id] = 0; + } + + if (data.status === 'Already exists') { + layerProgress[data.id] = 1; + } else if (data.status === 'Downloading') { + let current = data.progressDetail.current; + let total = data.progressDetail.total; + + if (total <= 0) { + progressCallback(0); + return; } else { - r[layer.Id] = 0; - } - return r; - }, {}); - - let timeout = null; - stream.on('data', str => { - var data = JSON.parse(str); - - if (data.error) { - return; + layerProgress[data.id] = current / total; } - if (data.status && (data.status === 'Pulling dependent layers' || data.status.indexOf('already being pulled by another client') !== -1)) { - blockedCallback(); - return; + let sum = _.values(layerProgress).reduce((pv, sv) => pv + sv, 0); + let numlayers = _.keys(layerProgress).length; + + var totalProgress = sum / numlayers * 100; + + if (!timeout) { + progressCallback(totalProgress); + timeout = setTimeout(() => { + timeout = null; + }, 100); } - - if (data.status === 'Already exists') { - layerProgress[data.id] = 1; - } else if (data.status === 'Downloading') { - let current = data.progressDetail.current; - let total = data.progressDetail.total; - - if (total <= 0) { - progressCallback(0); - return; - } else { - layerProgress[data.id] = current / total; - } - - let sum = _.values(layerProgress).reduce((pv, sv) => pv + sv, 0); - let numlayers = _.keys(layerProgress).length; - - var totalProgress = sum / numlayers * 100; - - if (!timeout) { - progressCallback(totalProgress); - timeout = setTimeout(() => { - timeout = null; - }, 100); - } - } - }); - stream.on('end', function () { - callback(); - }); + } + }); + stream.on('end', function () { + callback(); }); }); }); diff --git a/src/utils/HubUtil.js b/src/utils/HubUtil.js index 99092da4f9..b803a7cccf 100644 --- a/src/utils/HubUtil.js +++ b/src/utils/HubUtil.js @@ -1,13 +1,13 @@ var request = require('request'); var accountServerActions = require('../actions/AccountServerActions'); +var regHubUtil = require('./RegHubUtil'); module.exports = { - init: function () { accountServerActions.prompted({prompted: localStorage.getItem('auth.prompted')}); - if (this.jwt()) { // TODO: check for config too - let username = localStorage.getItem('auth.username'); - let verified = localStorage.getItem('auth.verified'); + let username = localStorage.getItem('auth.username'); + let verified = localStorage.getItem('auth.verified') === 'true'; + if (username) { // TODO: check for config too accountServerActions.loggedin({username, verified}); } }, @@ -36,19 +36,23 @@ module.exports = { }, refresh: function () { - // TODO: implement me + // TODO: implement me and wrap all jwt calls + }, + + loggedin: function () { + return this.jwt() && this.config(); }, logout: function () { + accountServerActions.loggedout(); localStorage.removeItem('auth.jwt'); localStorage.removeItem('auth.username'); localStorage.removeItem('auth.verified'); localStorage.removeItem('auth.config'); - accountServerActions.loggedout(); }, // Places a token under ~/.dockercfg and saves a jwt to localstore - login: function (username, password) { + login: function (username, password, verifying) { request.post('https://hub.docker.com/v2/users/login/', {form: {username, password}}, (err, response, body) => { let data = JSON.parse(body); if (response.statusCode === 200) { @@ -57,8 +61,15 @@ module.exports = { if (data.token) { localStorage.setItem('auth.jwt', data.token); localStorage.setItem('auth.username', username); + localStorage.setItem('auth.verified', true); + localStorage.setItem('auth.config', new Buffer(username + ':' + password).toString('base64')); } - accountServerActions.loggedin({username, verified: true}); + if (verifying) { + accountServerActions.verified({username}); + } else { + accountServerActions.loggedin({username, verified: true}); + } + regHubUtil.repos(data.token); } else if (response.statusCode === 401) { if (data && data.detail && data.detail.indexOf('Account not active yet') !== -1) { accountServerActions.loggedin({username, verified: false}); @@ -69,6 +80,17 @@ module.exports = { }); }, + verify: function () { + let config = this.config(); + if (!config) { + this.logout(); + return; + } + + let [username, password] = new Buffer(config, 'base64').toString().split(/:(.+)?/).slice(0, 2); + this.login(username, password, true); + }, + // Signs up and places a token under ~/.dockercfg and saves a jwt to localstore signup: function (username, password, email, subscribe) { request.post('https://hub.docker.com/v2/users/signup/', { @@ -82,6 +104,9 @@ module.exports = { // TODO: save username to localstorage if (response.statusCode === 204) { accountServerActions.signedup({username, verified: false}); + localStorage.setItem('auth.username', username); + localStorage.setItem('auth.verified', false); + localStorage.setItem('auth.config', new Buffer(username + ':' + password).toString('base64')); } else { let data = JSON.parse(body); let errors = {}; diff --git a/src/utils/RegHubUtil.js b/src/utils/RegHubUtil.js index 72aacf99dc..662f090fd2 100644 --- a/src/utils/RegHubUtil.js +++ b/src/utils/RegHubUtil.js @@ -1,24 +1,44 @@ +var _ = require('underscore'); var request = require('request'); var async = require('async'); +var util = require('../utils/Util'); var repositoryServerActions = require('../actions/RepositoryServerActions'); +var tagServerActions = require('../actions/TagServerActions'); module.exports = { - search: function (query) { + // Normalizes results from search to v2 repository results + normalize: function (repo) { + let obj = _.clone(repo); + if (obj.is_official) { + obj.namespace = 'library'; + } else { + let [namespace, name] = repo.name.split('/'); + obj.namespace = namespace; + obj.name = name; + } + + return obj; + }, + + search: function (query, page) { if (!query) { - return; + repositoryServerActions.resultsUpdated({repos: []}); } request.get({ url: 'https://registry.hub.docker.com/v1/search?', - qs: {q: query} + qs: {q: query, page} }, (error, response, body) => { if (error) { - // TODO: report search error + repositoryServerActions.searchError({error}); } let data = JSON.parse(body); + let repos = _.map(data.results, result => { + return this.normalize(result); + }); if (response.statusCode === 200) { - repositoryServerActions.searched({}); + repositoryServerActions.resultsUpdated({repos}); } }); }, @@ -26,19 +46,63 @@ module.exports = { recommended: function () { request.get('https://kitematic.com/recommended.json', (error, response, body) => { if (error) { - // TODO: report search error + repositoryServerActions.recommendedError({error}); } let data = JSON.parse(body); - console.log(data); + let repos = data.repos; + async.map(repos, (repo, cb) => { + let name = repo.repo; + if (util.isOfficialRepo(name)) { + name = 'library/' + name; + } + request.get({ + url: `https://registry.hub.docker.com/v2/repositories/${name}`, + }, (error, response, body) => { + if (error) { + repositoryServerActions.error({error}); + return; + } + + if (response.statusCode === 200) { + let data = JSON.parse(body); + data.is_recommended = true; + _.extend(data, repo); + cb(null, data); + } + }); + }, (error, repos) => { + repositoryServerActions.recommendedUpdated({repos}); + }); + }); + }, + + tags: function (jwt, repo) { + let headers = jwt ? { + Authorization: `JWT ${jwt}` + } : null; + + request.get({ + url: `https://registry.hub.docker.com/v2/repositories/${repo}/tags`, + headers + }, (error, response, body) => { if (response.statusCode === 200) { - repositoryServerActions.recommended({}); + let data = JSON.parse(body); + tagServerActions.tagsUpdated({repo, tags: data.tags}); + } else if (response.statusCude === 401) { + return; } }); }, // Returns the base64 encoded index token or null if no token exists repos: function (jwt) { + if (!jwt) { + repositoryServerActions.reposUpdated({repos: []}); + return; + } + + repositoryServerActions.reposLoading({repos: []}); // TODO: provide jwt request.get({ @@ -48,7 +112,8 @@ module.exports = { } }, (error, response, body) => { if (error) { - repositoryServerActions.error({error}); + repositoryServerActions.reposError({error}); + return; } let data = JSON.parse(body); @@ -61,7 +126,7 @@ module.exports = { } }, (error, response, body) => { if (error) { - repositoryServerActions.error({error}); + repositoryServerActions.reposError({error}); return; } @@ -73,7 +138,12 @@ module.exports = { for (let list of lists) { repos = repos.concat(list); } - repositoryServerActions.fetched({repos}); + + _.each(repos, repo => { + repo.is_user_repo = true; + }); + + repositoryServerActions.reposUpdated({repos}); }); }); } diff --git a/src/utils/RegistryUtil.js b/src/utils/RegistryUtil.js deleted file mode 100644 index 8b4352d9f5..0000000000 --- a/src/utils/RegistryUtil.js +++ /dev/null @@ -1,84 +0,0 @@ -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) { - 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) { - 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/styles/new-container.less b/styles/new-container.less index e9f7407858..eac8d36df1 100644 --- a/styles/new-container.less +++ b/styles/new-container.less @@ -32,6 +32,11 @@ flex: 1 auto; flex-direction: column; padding: 25px 20px 0 25px; + + .spinner { + display: inline-block; + } + .results { display: flex; flex-direction: column; @@ -39,10 +44,13 @@ color: @gray-normal; .results-filters { + flex: 0 auto; + flex-shrink: 0; display: flex; flex-direction: row; justify-content: flex-end; font-size: 13px; + margin-bottom: 10px; .results-filter { text-align: center; @@ -60,27 +68,38 @@ .no-results { flex: 1 auto; display: flex; + flex-direction: column; align-items: center; + justify-content: center; + flex-shrink: 0; + + .verify { + margin: 15px 0; + position: relative; + + .spinner { + position: absolute; + top: 0; + right: -50px; + } + } + .loader { - flex: 1 auto; display: flex; flex-direction: column; align-items: center; - - margin: 0 auto; - margin-top: -20%; text-align: center; width: 300px; + margin-top: -10%; h2 { color: @gray-normal; margin-bottom: 20px; } } h1 { - color: @gray-lightest; + color: @gray-lighter; font-size: 24px; margin: 0 auto; - margin-top: -20%; } } } @@ -88,6 +107,7 @@ margin-bottom: 8px; display: flex; flex: 0 auto; + flex-shrink: 0; .text { flex: 1 auto; width: 50%; @@ -134,183 +154,186 @@ } } -.result-grid { - display: flex; - flex-flow: row wrap; - justify-content: flex-start; - margin-top: 10px; +.result-grids { + overflow: auto; - .image-item { + .result-grid { display: flex; - position: relative; - width: 320px; - height: 166px; - border-radius: 4px; - background-color: white; - margin-right: 20px; - margin-bottom: 20px; - .tag-overlay { - z-index: 999; - background-color: rgba(0,0,0,0.8); - border-radius: 4px; + flex-flow: row wrap; + justify-content: flex-start; + margin-top: 10px; + .image-item { + display: flex; + position: relative; width: 320px; height: 166px; - position: absolute; - color: white; - font-size: 13px; - display: none; - padding: 10px; - .tag-list { - display: flex; - flex-direction: row; - align-items: flex-start; - align-content: flex-start; - flex-flow: row wrap; - height: 140px; - overflow: auto; - .tag { - display: inline-block; - flex: 0 auto; - margin-right: 2px; - padding: 3px 5px; - &.active { - background-color: rgba(255,255,255,0.2); - border-radius: 20px; - } - &:hover { - background-color: rgba(255,255,255,0.2); - border-radius: 20px; - } - } - } - .tags-loading { - position: relative; - left: 42%; - top: 20%; - text-align: center; - margin: 14px auto; - -webkit-animation-name: spin; - -webkit-animation-duration: 1.8s; - -webkit-animation-iteration-count: infinite; - -webkit-animation-timing-function: linear; - } - } - .logo { - flex: 1 auto; - min-width: 90px; - background-color: @brand-action; - border-top-left-radius: 4px; - border-bottom-left-radius: 4px; - justify-content: center; - text-align: center; - box-shadow: inset 0px 0px 0px 1px rgba(0,0,0,0.2); - img { - margin-top: 15px; - } - } - .card { - padding: 10px 20px 10px 20px; - position: relative; - border: 1px solid @gray-lighter; - border-left: 0; - border-top-right-radius: 4px; - border-bottom-right-radius: 4px; - .badges { + border-radius: 4px; + background-color: white; + margin-right: 20px; + margin-bottom: 20px; + .tag-overlay { + z-index: 999; + background-color: rgba(0,0,0,0.8); + border-radius: 4px; + width: 320px; + height: 166px; position: absolute; - right: 15px; - top: 8px; - } - .name { - font-size: 18px; - color: @gray-darkest; - margin-bottom: 0px; - position: relative; - width: 190px; - .namespace { - font-size: 11px; - color: @gray-lighter; - margin-bottom: -3px; - &.official { - color: @brand-action; - } - } - .repo { - display: inline-block; - max-width: 190px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } - } - .description { - font-size: 12px; - color: @gray-normal; - height: 50px; - width: 190px; - text-overflow: ellipsis; - overflow: hidden; - -webkit-box-orient: vertical; - -webkit-line-clamp: 3; - display: -webkit-box; - word-wrap: break-word; - } - .actions { - width: 190px; - position: absolute; - bottom: 8px; - .stars { - height: 15px; - font-size: 10px; - color: @gray-darker; - border-right: 1px solid @gray-lightest; - padding-right: 10px; - .icon { - position: relative; - font-size: 16px; - margin-right: 3px; - top: -1px; - color: @gray-darkest; - } - .text { - position: relative; - top: -6px; - } - } - .tags { - height: 15px; - font-size: 10px; - color: @gray-darker; - padding-left: 10px; - .icon { - position: relative; - font-size: 12px; + color: white; + font-size: 13px; + display: none; + padding: 10px; + .tag-list { + display: flex; + flex-direction: row; + align-items: flex-start; + align-content: flex-start; + flex-flow: row wrap; + height: 140px; + overflow: auto; + .tag { + display: inline-block; + flex: 0 auto; margin-right: 2px; - top: 2px; - color: @gray-darkest; - } - .text { - position: relative; - top: -2px; padding: 3px 5px; - text-decoration: underline; + &.active { + background-color: rgba(255,255,255,0.2); + border-radius: 20px; + } &:hover { - background-color: @brand-action; - color: white; + background-color: rgba(255,255,255,0.2); border-radius: 20px; } } } - .action { - flex: 1 auto; - .btn { - text-align: right; - position: relative; - float: right; - top: -7px; + .tags-loading { + position: relative; + left: 42%; + top: 20%; + text-align: center; + margin: 14px auto; + -webkit-animation-name: spin; + -webkit-animation-duration: 1.8s; + -webkit-animation-iteration-count: infinite; + -webkit-animation-timing-function: linear; + } + } + .logo { + flex: 1 auto; + min-width: 90px; + background-color: @brand-action; + border-top-left-radius: 4px; + border-bottom-left-radius: 4px; + justify-content: center; + text-align: center; + box-shadow: inset 0px 0px 0px 1px rgba(0,0,0,0.2); + img { + margin-top: 15px; + } + } + .card { + padding: 10px 20px 10px 20px; + position: relative; + border: 1px solid @gray-lighter; + border-left: 0; + border-top-right-radius: 4px; + border-bottom-right-radius: 4px; + .badges { + position: absolute; + right: 15px; + top: 8px; + } + .name { + font-size: 18px; + color: @gray-darkest; + margin-bottom: 0px; + position: relative; + width: 190px; + .namespace { + font-size: 11px; + color: @gray-lighter; + margin-bottom: -3px; + &.official { + color: @brand-action; + } + } + .repo { + display: inline-block; + max-width: 190px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } } - display: flex; - flex-direaction: row; + .description { + font-size: 12px; + color: @gray-normal; + height: 50px; + width: 190px; + text-overflow: ellipsis; + overflow: hidden; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; + display: -webkit-box; + word-wrap: break-word; + } + .actions { + width: 190px; + position: absolute; + bottom: 8px; + .stars { + height: 15px; + font-size: 10px; + color: @gray-darker; + border-right: 1px solid @gray-lightest; + padding-right: 10px; + .icon { + position: relative; + font-size: 16px; + margin-right: 3px; + top: -1px; + color: @gray-darkest; + } + .text { + position: relative; + top: -6px; + } + } + .tags { + height: 15px; + font-size: 10px; + color: @gray-darker; + padding-left: 10px; + .icon { + position: relative; + font-size: 12px; + margin-right: 2px; + top: 2px; + color: @gray-darkest; + } + .text { + position: relative; + top: -2px; + padding: 3px 5px; + text-decoration: underline; + &:hover { + background-color: @brand-action; + color: white; + border-radius: 20px; + } + } + } + .action { + flex: 1 auto; + .btn { + text-align: right; + position: relative; + float: right; + top: -7px; + } + } + display: flex; + flex-direaction: row; + } } } }