WIP login without refresh token

This commit is contained in:
Jeffrey Morgan 2015-05-22 16:59:07 -07:00
parent d80983fb4e
commit 2065063b14
30 changed files with 735 additions and 491 deletions

BIN
images/connect-art.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 206 KiB

BIN
images/connect-art@2x.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 568 KiB

BIN
images/inspection.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

BIN
images/inspection@2x.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 243 KiB

BIN
images/private.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 456 B

BIN
images/private@2x.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 824 B

View File

@ -21,6 +21,11 @@ class AccountActions {
this.dispatch({});
hub.prompted(true);
}
verify () {
this.dispatch({});
hub.verify();
}
}
export default alt.createActions(AccountActions);

View File

@ -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});
}
}

View File

@ -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);
}
}

View File

@ -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 () {
}
}

View File

@ -3,9 +3,10 @@ import alt from '../alt';
class RepositoryServerActions {
constructor () {
this.generateActions(
'searched',
'fetched',
'error'
'reposLoading',
'resultsUpdated',
'recommendedUpdated',
'reposUpdated'
);
}
}

12
src/actions/TagActions.js Normal file
View File

@ -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);

View File

@ -0,0 +1,11 @@
import alt from '../alt';
class TagServerActions {
constructor () {
this.generateActions(
'tagsUpdated'
);
}
}
export default alt.createActions(TagServerActions);

View File

@ -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');

View File

@ -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,

View File

@ -40,13 +40,14 @@ module.exports = React.createClass({
render: function () {
let close = this.state.prompted ?
<a className="btn btn-action btn-close" onClick={this.handleClose}>Close</a> :
<a className="btn btn-action btn-skip" onClick={this.handleSkip}>Skip For Now</a>;
<a className="btn btn-action btn-close" disabled={this.state.loading} onClick={this.handleClose}>Close</a> :
<a className="btn btn-action btn-skip" disabled={this.state.loading} onClick={this.handleSkip}>Skip For Now</a>;
return (
<div className="setup">
<Header hideLogin={true}/>
<div className="setup-content">
{close}
<div className="form-section">
<RetinaImage src={'connect-to-hub.png'} checkIfRetinaImgExists={false}/>
<Router.RouteHandler errors={this.state.errors} loading={this.state.loading} {...this.props}/>
@ -55,7 +56,6 @@ module.exports = React.createClass({
<div className="content">
<h1>Connect to Docker Hub</h1>
<p>Pull and run private Docker Hub images by connecting your Docker Hub account to Kitematic.</p>
{close}
</div>
</div>
</div>

View File

@ -36,7 +36,7 @@ var ContainerListNewItem = React.createClass({
);
}
return (
<Router.Link to="new">
<Router.Link to="search">
<li className="new-container-item" onMouseEnter={this.handleItemMouseEnter} onMouseLeave={this.handleItemMouseLeave}>
<div className="state state-new"></div>
<div className="info">

View File

@ -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');

View File

@ -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 ? <a className="btn btn-action small no-drag" onClick={this.handleAutoUpdateClick}>UPDATE NOW</a> : null;
let buttons;
@ -108,7 +116,7 @@ var Header = React.createClass({
} else if (this.state.username) {
username = (
<span className="no-drag" onClick={this.handleUserClick}>
<RetinaImage src="user.png"/> {this.state.username} <RetinaImage src="userdropdown.png"/>
<RetinaImage src="user.png"/> {this.state.username} {this.state.verified ? null : '(Unverified)'} <RetinaImage src="userdropdown.png"/>
</span>
);
} else {

View File

@ -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 = (
<div>
<div className="namespace official">{namespace}</div>
<div className="namespace official">official</div>
<OverlayTrigger placement="bottom" overlay={<Tooltip>View on Docker Hub</Tooltip>}>
<span className="repo" onClick={this.handleRepoClick}>{repo}</span>
<span className="repo" onClick={this.handleRepoClick}>{this.props.image.name}</span>
</OverlayTrigger>
</div>
);
} else {
name = (
<div>
<div className="namespace">{namespace}</div>
<div className="namespace">{this.props.image.namespace}</div>
<OverlayTrigger placement="bottom" overlay={<Tooltip>View on Docker Hub</Tooltip>}>
<span className="repo" onClick={this.handleRepoClick}>{repo}</span>
<span className="repo" onClick={this.handleRepoClick}>{this.props.image.name}</span>
</OverlayTrigger>
</div>
);
@ -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 = <RetinaImage className="tags-loading" src="loading-white.png"/>;
} else if (self.state.tags.length === 0) {
tags = <span>No Tags</span>;
} else {
var tagDisplay = self.state.tags.map(function (t) {
if (t.name === self.state.chosenTag) {
return <div className="tag active" key={t.name} onClick={self.handleTagClick.bind(self, t.name)}>{t.name}</div>;
if (t === self.state.chosenTag) {
return <div className="tag active" key={t} onClick={self.handleTagClick.bind(self, t)}>{t}</div>;
} else {
return <div className="tag" key={t.name} onClick={self.handleTagClick.bind(self, t.name)}>{t.name}</div>;
return <div className="tag" key={t} onClick={self.handleTagClick.bind(self, t)}>{t}</div>;
}
});
tags = (
@ -124,13 +125,15 @@ var ImageCard = React.createClass({
{tagDisplay}
</div>
);
} else {
tags = <RetinaImage className="tags-loading" src="loading-white.png"/>;
}
var officialBadge;
if (this.props.image.is_official) {
officialBadge = (
<RetinaImage src="official.png" />
var badge = null;
if (this.props.image.namespace === 'library') {
badge = (
<RetinaImage src="official.png"/>
);
} else if (this.props.image.is_private) {
badge = (
<RetinaImage src="private.png"/>
);
}
return (
@ -143,7 +146,7 @@ var ImageCard = React.createClass({
</div>
<div className="card">
<div className="badges">
{officialBadge}
{badge}
</div>
<div className="name">
{name}
@ -152,17 +155,13 @@ var ImageCard = React.createClass({
{description}
</div>
<div className="actions">
<OverlayTrigger placement="bottom" overlay={<Tooltip>Favorites</Tooltip>}>
<div className="stars">
<span className="icon icon-star-9"></span>
<span className="text">{this.props.image.star_count}</span>
</div>
</OverlayTrigger>
<div className="stars">
<span className="icon icon-star-9"></span>
<span className="text">{this.props.image.star_count}</span>
</div>
<div className="tags">
<span className="icon icon-bookmark-2"></span>
<OverlayTrigger placement="bottom" overlay={<Tooltip>Change Tag</Tooltip>}>
<span className="text" onClick={self.handleTagOverlayClick.bind(self, this.props.image.name)} data-name={this.props.image.name}>{this.state.chosenTag}</span>
</OverlayTrigger>
<span className="text" onClick={self.handleTagOverlayClick.bind(self, this.props.image.name)} data-name={this.props.image.name}>{this.state.chosenTag}</span>
</div>
<div className="action">
<a className="btn btn-action" onClick={self.handleClick.bind(self, this.props.image.name)}>Create</a>

View File

@ -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 (
<ImageCard key={image.name} image={image} />
);
});
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 = (
<div className="no-results">
<h2>There was an error contacting Docker Hub.</h2>
</div>
);
} else if (filter === 'userrepos' && !accountStore.getState().username) {
results = (
<div className="no-results">
<h2><Router.Link to="login">Log In</Router.Link> or <Router.Link to="signup">Sign Up</Router.Link> to access your Docker Hub repositories.</h2>
<RetinaImage src="connect-art.png" checkIfRetinaImgExists={false}/>
</div>
);
} else if (filter === 'userrepos' && !accountStore.getState().verified) {
let spinner = this.state.accountLoading ? <div className="spinner la-ball-clip-rotate la-dark"><div></div></div> : null;
results = (
<div className="no-results">
<h2>Please verify your Docker Hub account email address</h2>
<div className="verify">
<button className="btn btn-primary btn-lg" onClick={this.handleCheckVerification}>{'I\'ve Verified my Email Address'}</button> {spinner}
</div>
<RetinaImage src="inspection.png" checkIfRetinaImgExists={false}/>
</div>
);
} else if (this.state.loading) {
results = (
<div className="no-results">
<div className="loader">
<h2>Loading Images</h2>
<div className="spinner la-ball-clip-rotate la-dark la-lg"><div></div></div>
</div>
</div>
);
} else if (repos.length) {
let recommendedItems = repos.filter(repo => repo.is_recommended).map(image => <ImageCard key={image.namespace + '/' + image.name} image={image} />);
let otherItems = repos.filter(repo => !repo.is_recommended && !repo.is_user_repo).map(image => <ImageCard key={image.namespace + '/' + image.name} image={image} />);
let recommendedResults = recommendedItems.length ? (
<div>
<h4>Recommended</h4>
<div className="result-grid">
{recommendedItems}
</div>
</div>
) : null;
let userRepoItems = repos.filter(repo => repo.is_user_repo).map(image => <ImageCard key={image.namespace + '/' + image.name} image={image} />);
let userRepoResults = userRepoItems.length ? (
<div>
<h4>My Repositories</h4>
<div className="result-grid">
{userRepoItems}
</div>
</div>
) : null;
let otherResults = otherItems.length ? (
<div>
<h4>Other Repositories</h4>
<div className="result-grid">
{otherItems}
</div>
</div>
) : null;
results = (
<div className="result-grid">
{items}
<div className="result-grids">
{userRepoResults}
{recommendedResults}
{otherResults}
</div>
);
} else {
if (this.state.results.length === 0 && this.state.query === '') {
results = (
<div className="no-results">
<div className="loader">
<h2>Loading Images</h2>
<div className="spinner la-ball-clip-rotate la-dark la-lg"><div></div></div>
</div>
</div>
);
} else {
results = (
<div className="no-results">
<h1>Cannot find a matching image.</h1>
</div>
);
}
results = (
<div className="no-results">
<h2>Cannot find a matching image.</h2>
</div>
);
}
let loadingClasses = classNames({
@ -114,16 +196,12 @@ module.exports = React.createClass({
'search-icon': true
});
let allTabClasses = classNames({
'results-filter':
});
return (
<div className="details">
<div className="new-container">
<div className="new-container-header">
<div className="text">
Select a Docker image to create a new container.
Select a Docker image to create a container.
</div>
<div className="search">
<div className="search-bar">
@ -136,9 +214,9 @@ module.exports = React.createClass({
<div className="results">
<div className="results-filters">
<span className="results-filter results-filter-title">FILTER BY</span>
<span className="results-filter results-all tab">All</span>
<span className="results-filter results-recommended tab">Recommended</span>
<span className="results-filter results-userrepos tab">My Repositories</span>
<span className={`results-filter results-all tab ${filter === 'all' ? 'active' : ''}`} onClick={this.handleFilter.bind(this, 'all')}>All</span>
<span className={`results-filter results-recommended tab ${filter === 'recommended' ? 'active' : ''}`} onClick={this.handleFilter.bind(this, 'recommended')}>Recommended</span>
<span className={`results-filter results-userrepos tab ${filter === 'userrepos' ? 'active' : ''}`} onClick={this.handleFilter.bind(this, 'userrepos')}>My Repositories</span>
</div>
{results}
</div>

View File

@ -45,7 +45,7 @@ var routes = (
</Route>
</Route>
<Route name="new" path="containers/new">
<DefaultRoute name="search" handler={NewContainerSearch}/>
<Route name="search" path="containers/new/search" handler={NewContainerSearch}></Route>
<Route name="pull" path="containers/new/pull" handler={NewContainerPull}></Route>
</Route>
<Route name="preferences" path="/preferences" handler={Preferences}/>

View File

@ -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}) {

View File

@ -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;
}
}

43
src/stores/TagStore.js Normal file
View File

@ -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);

View File

@ -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();
});
});
});

View File

@ -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 = {};

View File

@ -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});
});
});
}

View File

@ -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;

View File

@ -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;
}
}
}
}