Merge pull request #529 from kitematic/hub

Docker Hub Integration
This commit is contained in:
Jeffrey Morgan 2015-05-26 13:38:11 -07:00
commit 01c95b7b81
64 changed files with 2036 additions and 935 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/connect-to-hub.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 856 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

BIN
images/feedback.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 671 B

BIN
images/feedback@2x.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 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/logo-active.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 597 B

BIN
images/logo-active@2x.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 667 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 976 B

After

Width:  |  Height:  |  Size: 624 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 1.2 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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 732 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

BIN
images/user.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 931 B

BIN
images/user@2x.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

BIN
images/userdropdown.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 349 B

BIN
images/userdropdown@2x.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 681 B

BIN
images/whaleicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 711 B

BIN
images/whaleicon@2x.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -66,7 +66,7 @@
"classnames": "^1.2.0",
"coveralls": "^2.11.2",
"deep-extend": "^0.4.0",
"dockerode": "^2.1.1",
"dockerode": "^2.1.4",
"exec": "0.2.0",
"install": "^0.1.8",
"jquery": "^2.1.3",
@ -83,7 +83,8 @@
"request": "^2.55.0",
"request-progress": "^0.3.1",
"rimraf": "^2.3.2",
"underscore": "^1.8.3"
"underscore": "^1.8.3",
"validator": "^3.39.0"
},
"devDependencies": {
"babel": "^5.1.10",

View File

@ -0,0 +1,31 @@
import alt from '../alt';
import hub from '../utils/HubUtil';
class AccountActions {
login (username, password) {
this.dispatch({});
hub.login(username, password);
}
signup (username, password, email, subscribe) {
this.dispatch({});
hub.signup(username, password, email, subscribe);
}
logout () {
this.dispatch({});
hub.logout();
}
skip () {
this.dispatch({});
hub.setPrompted(true);
}
verify () {
this.dispatch({});
hub.verify();
}
}
export default alt.createActions(AccountActions);

View File

@ -0,0 +1,16 @@
import alt from '../alt';
class AccountServerActions {
constructor () {
this.generateActions(
'signedup',
'loggedin',
'loggedout',
'prompted',
'errors',
'verified'
);
}
}
export default alt.createActions(AccountServerActions);

View File

@ -0,0 +1,21 @@
import alt from '../alt';
import regHubUtil from '../utils/RegHubUtil';
class RepositoryActions {
recommended () {
this.dispatch({});
regHubUtil.recommended();
}
search (query) {
this.dispatch({});
regHubUtil.search(query);
}
repos () {
this.dispatch({});
regHubUtil.repos();
}
}
export default alt.createActions(RepositoryActions);

View File

@ -0,0 +1,14 @@
import alt from '../alt';
class RepositoryServerActions {
constructor () {
this.generateActions(
'reposLoading',
'resultsUpdated',
'recommendedUpdated',
'reposUpdated'
);
}
}
export default alt.createActions(RepositoryServerActions);

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

@ -0,0 +1,11 @@
import alt from '../alt';
import regHubUtil from '../utils/RegHubUtil';
class TagActions {
tags (repo) {
this.dispatch({repo});
regHubUtil.tags(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

@ -9,10 +9,24 @@ var metrics = require('./utils/MetricsUtil');
var router = require('./router');
var template = require('./menutemplate');
var webUtil = require('./utils/WebUtil');
var hubUtil = require('./utils/HubUtil');
var urlUtil = require ('./utils/URLUtil');
var app = remote.require('app');
var request = require('request');
var docker = require('./utils/DockerUtil');
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();
if (hubUtil.loggedin()) {
repositoryActions.repos();
}
repositoryActions.recommended();
webUtil.addWindowSizeSaving();
webUtil.addLiveReload();
@ -27,12 +41,20 @@ setInterval(function () {
metrics.track('app heartbeat');
}, 14400000);
var router = Router.create({
routes: routes
});
router.run(Handler => React.render(<Handler/>, document.body));
routerContainer.set(router);
SetupStore.setup().then(() => {
Menu.setApplicationMenu(Menu.buildFromTemplate(template()));
docker.init();
router.transitionTo('search');
if (!hub.prompted() && !hub.loggedin()) {
router.transitionTo('login');
} else {
router.transitionTo('search');
}
}).catch(err => {
metrics.track('Setup Failed', {
step: 'catch',
@ -63,3 +85,7 @@ ipc.on('application:open-url', opts => {
urlUtil.openUrl(opts.url, flags, app.getVersion());
});
});
module.exports = {
router: router
};

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();
@ -28,13 +27,13 @@ app.on('open-url', function (event, url) {
app.on('ready', function () {
var mainWindow = new BrowserWindow({
width: size.width || 1000,
height: size.height || 700,
height: size.height || 780,
'min-width': 1000,
'min-height': 700,
'min-height': 600,
'standard-window': false,
resizable: true,
frame: false,
show: true,
show: false,
});
mainWindow.loadUrl(path.normalize('file://' + path.join(__dirname, '..', 'build/index.html')));

View File

@ -0,0 +1,75 @@
var React = require('react/addons');
var Router = require('react-router');
var RetinaImage = require('react-retina-image');
var Header = require('./Header.react');
var metrics = require('../utils/MetricsUtil');
var accountStore = require('../stores/AccountStore');
var accountActions = require('../actions/AccountActions');
module.exports = React.createClass({
mixins: [Router.Navigation],
getInitialState: function () {
return accountStore.getState();
},
componentDidMount: function () {
document.addEventListener('keyup', this.handleDocumentKeyUp, false);
accountStore.listen(this.update);
},
componentWillUnmount: function () {
document.removeEventListener('keyup', this.handleDocumentKeyUp, false);
accountStore.unlisten(this.update);
},
componentWillUpdate: function (nextProps, nextState) {
if (!this.state.username && nextState.username) {
if (nextState.prompted) {
this.goBack();
} else {
this.transitionTo('search');
}
}
},
handleSkip: function () {
accountActions.skip();
this.transitionTo('search');
metrics.track('Skipped Login');
},
handleClose: function () {
this.goBack();
metrics.track('Closed Login');
},
update: function () {
this.setState(accountStore.getState());
},
render: function () {
let close = this.state.prompted ?
<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}/>
</div>
<div className="desc">
<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>
</div>
</div>
</div>
</div>
);
}
});

View File

@ -0,0 +1,85 @@
var _ = require('underscore');
var React = require('react/addons');
var Router = require('react-router');
var validator = require('validator');
var accountActions = require('../actions/AccountActions');
var metrics = require('../utils/MetricsUtil');
var shell = require('shell');
module.exports = React.createClass({
mixins: [Router.Navigation, React.addons.LinkedStateMixin],
getInitialState: function () {
return {
username: '',
password: '',
errors: {}
};
},
componentDidMount: function () {
React.findDOMNode(this.refs.usernameInput).focus();
},
componentWillReceiveProps: function (nextProps) {
this.setState({errors: nextProps.errors});
},
validate: function () {
let errors = {};
if (!validator.isLowercase(this.state.username) || !validator.isAlphanumeric(this.state.username) || !validator.isLength(this.state.username, 4, 30)) {
errors.username = 'Must be 4-30 lower case letters or numbers';
}
if (!validator.isLength(this.state.password, 5)) {
errors.password = 'Must be at least 5 characters long';
}
return errors;
},
handleBlur: function () {
this.setState({errors: _.omit(this.validate(), (val, key) => !this.state[key].length)});
},
handleLogin: function () {
let errors = this.validate();
this.setState({errors});
if (_.isEmpty(errors)) {
accountActions.login(this.state.username, this.state.password);
metrics.track('Clicked Log In');
}
},
handleClickSignup: function () {
if (!this.props.loading) {
this.replaceWith('signup');
metrics.track('Switched to Sign Up');
}
},
handleClickForgotPassword: function () {
shell.openExternal('https://hub.docker.com/account/forgot-password/');
},
render: function () {
let loading = this.props.loading ? <div className="spinner la-ball-clip-rotate la-dark"><div></div></div> : null;
return (
<form className="form-connect">
<input ref="usernameInput"maxLength="30" name="username" placeholder="username" type="text" disabled={this.props.loading} valueLink={this.linkState('username')} onBlur={this.handleBlur}/>
<p className="error-message">{this.state.errors.username}</p>
<input ref="passwordInput" name="password" placeholder="password" type="password" disabled={this.props.loading} valueLink={this.linkState('password')} onBlur={this.handleBlur}/>
<p className="error-message">{this.state.errors.password}</p>
<a className="link" onClick={this.handleClickForgotPassword}>Forgot your password?</a>
<p className="error-message">{this.state.errors.detail}</p>
<div className="submit">
{loading}
<button className="btn btn-action" disabled={this.props.loading} onClick={this.handleLogin} type="submit">Log In</button>
</div>
<br/>
<div className="extra">Don&#39;t have an account yet? <a disabled={this.state.loading} onClick={this.handleClickSignup}>Sign Up</a></div>
</form>
);
}
});

View File

@ -0,0 +1,91 @@
var _ = require('underscore');
var React = require('react/addons');
var Router = require('react-router');
var validator = require('validator');
var accountActions = require('../actions/AccountActions');
var metrics = require('../utils/MetricsUtil');
module.exports = React.createClass({
mixins: [Router.Navigation, React.addons.LinkedStateMixin],
getInitialState: function () {
return {
username: '',
password: '',
email: '',
subscribe: true,
errors: {}
};
},
componentDidMount: function () {
React.findDOMNode(this.refs.usernameInput).focus();
},
componentWillReceiveProps: function (nextProps) {
this.setState({errors: nextProps.errors});
},
validate: function () {
let errors = {};
if (!validator.isLowercase(this.state.username) || !validator.isAlphanumeric(this.state.username) || !validator.isLength(this.state.username, 4, 30)) {
errors.username = 'Must be 4-30 lower case letters or numbers';
}
if (!validator.isLength(this.state.password, 5)) {
errors.password = 'Must be at least 5 characters long';
}
if (!validator.isEmail(this.state.email)) {
errors.email = 'Must be a valid email address';
}
return errors;
},
handleBlur: function () {
this.setState({errors: _.omit(this.validate(), (val, key) => !this.state[key].length)});
},
handleSignUp: function () {
let errors = this.validate();
this.setState({errors});
if (_.isEmpty(errors)) {
accountActions.signup(this.state.username, this.state.password, this.state.email, this.state.subscribe);
metrics.track('Clicked Signed Up');
}
},
handleClickLogin: function () {
if (!this.props.loading) {
this.replaceWith('login');
metrics.track('Switched to Log In');
}
},
render: function () {
let loading = this.props.loading ? <div className="spinner la-ball-clip-rotate la-dark"><div></div></div> : null;
return (
<form className="form-connect" onSubmit={this.handleSignUp}>
<input ref="usernameInput" maxLength="30" name="username" placeholder="Username" type="text" disabled={this.props.loading} valueLink={this.linkState('username')} onBlur={this.handleBlur}/>
<p className="error-message">{this.state.errors.username}</p>
<input ref="emailInput" name="email" placeholder="Email" type="text" valueLink={this.linkState('email')} disabled={this.props.loading} onBlur={this.handleBlur}/>
<p className="error-message">{this.state.errors.email}</p>
<input ref="passwordInput" name="password" placeholder="Password" type="password" valueLink={this.linkState('password')} disabled={this.props.loading} onBlur={this.handleBlur}/>
<p className="error-message">{this.state.errors.password}</p>
<div className="checkbox">
<label>
<input type="checkbox" disabled={this.props.loading} checkedLink={this.linkState('subscribe')}/> Subscribe to the Docker newsletter.
</label>
</div>
<p className="error-message">{this.state.errors.detail}</p>
<div className="submit">
{loading}
<button className="btn btn-action" disabled={this.props.loading} type="submit">Sign Up</button>
</div>
<br/>
<div className="extra">Already have an account? <a disabled={this.state.loading} onClick={this.handleClickLogin}>Log In</a></div>
</form>
);
}
});

View File

@ -51,13 +51,11 @@ var ContainerHome = React.createClass({
</div>
);
} else if (this.props.container && this.props.container.State.Downloading) {
if (this.props.container.Progress !== undefined) {
if (this.props.container.Progress) {
let values = [];
let sum = 0.0;
for (let i = 0; i < this.props.container.Progress.amount; i++) {
values.push(Math.round(this.props.container.Progress.progress[i].value));
sum += this.props.container.Progress.progress[i].value;
}
@ -67,9 +65,9 @@ var ContainerHome = React.createClass({
body = (
<div className="details-progress">
<h2>Downloading Image</h2>
<h2>{Math.round(sum*100)/100}%</h2>
<h2>{(Math.round(sum*100)/100).toFixed(2)}%</h2>
<div className="container-progress-wrapper">
<ContainerProgress pBar1={values[0]} pBar2={values[1]} pBar3={values[2]} pBar4={values[3]} />
<ContainerProgress pBar1={values[0]} pBar2={values[1]} pBar3={values[2]} pBar4={values[3]}/>
</div>
</div>
);

View File

@ -64,7 +64,7 @@ module.exports = React.createClass({
},
render: function () {
var logs = this.state.logs.map(function (l, i) {
return <span key={i} dangerouslySetInnerHTML={{__html: l}}></span>;
return <span key={i} dangerouslySetInnerHTML={{__html: l}}></span>;
});
if (logs.length === 0) {
logs = "No logs for this container.";

View File

@ -5,10 +5,7 @@ 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 remote = require('remote');
var metrics = require('../utils/MetricsUtil');
var autoUpdater = remote.require('auto-updater');
var RetinaImage = require('react-retina-image');
var shell = require('shell');
var machine = require('../utils/DockerMachineUtil');
@ -21,31 +18,21 @@ var Containers = React.createClass({
getInitialState: function () {
return {
sidebarOffset: 0,
containers: {},
sorted: [],
updateAvailable: false,
currentButtonLabel: ''
containers: containerStore.getState().containers,
sorted: this.sorted(containerStore.getState().containers)
};
},
componentDidMount: function () {
containerStore.listen(this.update);
ipc.on('application:update-available', () => {
this.setState({
updateAvailable: true
});
});
autoUpdater.checkForUpdates();
},
componentDidUnmount: function () {
containerStore.unlisten(this.update);
},
update: function () {
let containers = containerStore.getState().containers;
let sorted = _.values(containers).sort(function (a, b) {
sorted: function (containers) {
return _.values(containers).sort(function (a, b) {
if (a.State.Downloading && !b.State.Downloading) {
return -1;
} else if (!a.State.Downloading && b.State.Downloading) {
@ -60,6 +47,11 @@ var Containers = React.createClass({
}
}
});
},
update: function () {
let containers = containerStore.getState().containers;
let sorted = this.sorted(containerStore.getState().containers);
let name = this.context.router.getCurrentParams().name;
if (containerStore.getState().pending) {
@ -97,11 +89,6 @@ var Containers = React.createClass({
metrics.track('Pressed New Container');
},
handleAutoUpdateClick: function () {
metrics.track('Restarted to Update');
ipc.send('application:quit-install');
},
handleClickPreferences: function () {
metrics.track('Opened Preferences', {
from: 'app'
@ -164,12 +151,6 @@ var Containers = React.createClass({
if (this.state.sidebarOffset) {
sidebarHeaderClass += ' sep';
}
var updateWidget;
if (this.state.updateAvailable) {
updateWidget = (
<a className="btn btn-action small" onClick={this.handleAutoUpdateClick}>New Update</a>
);
}
var container = this.context.router.getCurrentParams().name ? this.state.containers[this.context.router.getCurrentParams().name] : {};
return (
@ -187,14 +168,11 @@ var Containers = React.createClass({
</section>
<section className="sidebar-containers" onScroll={this.handleScroll}>
<ContainerList containers={this.state.sorted} newContainer={this.state.newContainer} />
<div className="sidebar-buttons">
<div className="btn-label">{this.state.currentButtonLabel}</div>
<span className="btn-sidebar" onClick={this.handleClickDockerTerminal} onMouseEnter={this.handleMouseEnterDockerTerminal} onMouseLeave={this.handleMouseLeaveDockerTerminal}><RetinaImage src="docker-terminal.png"/></span>
<span className="btn-sidebar" onClick={this.handleClickReportIssue} onMouseEnter={this.handleMouseEnterReportIssue} onMouseLeave={this.handleMouseLeaveReportIssue}><RetinaImage src="report-issue.png"/></span>
<span className="btn-sidebar" onClick={this.handleClickPreferences} onMouseEnter={this.handleMouseEnterPreferences} onMouseLeave={this.handleMouseLeavePreferences}><RetinaImage src="preferences.png"/></span>
{updateWidget}
</div>
<div className="sidebar-buttons-padding"></div>
</section>
<section className="sidebar-buttons">
<span className="btn-sidebar btn-terminal" onClick={this.handleClickDockerTerminal} onMouseEnter={this.handleMouseEnterDockerTerminal} onMouseLeave={this.handleMouseLeaveDockerTerminal}><RetinaImage src="whaleicon.png"/> <span className="text">DOCKER CLI</span></span>
<span className="btn-sidebar btn-feedback" onClick={this.handleClickReportIssue} onMouseEnter={this.handleMouseEnterDockerTerminal} onMouseLeave={this.handleMouseLeaveDockerTerminal}><RetinaImage src="feedback.png"/></span>
<span className="btn-sidebar" onClick={this.handleClickPreferences} onMouseEnter={this.handleMouseEnterDockerTerminal} onMouseLeave={this.handleMouseLeaveDockerTerminal}><RetinaImage src="preferences.png"/></span>
</section>
</div>
<Router.RouteHandler pending={this.state.pending} containers={this.state.containers} container={container}/>

View File

@ -1,18 +1,49 @@
var React = require('react/addons');
var remote = require('remote');
var RetinaImage = require('react-retina-image');
var remote = require('remote');
var ipc = require('ipc');
var autoUpdater = remote.require('auto-updater');
var metrics = require('../utils/MetricsUtil');
var Menu = remote.require('menu');
var MenuItem = remote.require('menu-item');
var accountStore = require('../stores/AccountStore');
var accountActions = require('../actions/AccountActions');
var Router = require('react-router');
var classNames = require('classNames');
var Header = React.createClass({
mixins: [Router.Navigation],
getInitialState: function () {
return {
fullscreen: false
fullscreen: false,
updateAvailable: false,
username: accountStore.getState().username,
verified: accountStore.getState().verified
};
},
componentDidMount: function () {
document.addEventListener('keyup', this.handleDocumentKeyUp, false);
accountStore.listen(this.update);
ipc.on('application:update-available', () => {
this.setState({
updateAvailable: true
});
});
autoUpdater.checkForUpdates();
},
componentWillUnmount: function () {
document.removeEventListener('keyup', this.handleDocumentKeyUp, false);
accountStore.unlisten(this.update);
},
update: function () {
let accountState = accountStore.getState();
this.setState({
username: accountState.username,
verified: accountState.verified
});
},
handleDocumentKeyUp: function (e) {
if (e.keyCode === 27 && remote.getCurrentWindow().isFullScreen()) {
@ -35,30 +66,89 @@ var Header = React.createClass({
handleFullscreenHover: function () {
this.update();
},
handleAutoUpdateClick: function () {
metrics.track('Restarted to Update');
ipc.send('application:quit-install');
},
handleUserClick: function (e) {
let menu = new Menu();
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 () {
this.transitionTo('login');
metrics.track('Opened Log In Screen');
},
handleLogoutClick: function () {
metrics.track('Logged Out');
accountActions.logout();
},
handleVerifyClick: function () {
metrics.track('Verified Account', {
from: 'header'
});
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;
if (this.state.fullscreen) {
return (
<div className="header no-drag">
<div className="buttons">
<div className="button button-close disabled"></div>
<div className="button button-minimize disabled"></div>
<div className="button button-fullscreenclose enabled" onClick={this.handleFullscreen}></div>
</div>
<RetinaImage className="logo" src="logo.png"/>
buttons = (
<div className="buttons">
<div className="button button-close disabled"></div>
<div className="button button-minimize disabled"></div>
<div className="button button-fullscreenclose enabled" onClick={this.handleFullscreen}></div>
</div>
);
} else {
return (
<div className="header">
<div className="buttons">
<div className="button button-close enabled" onClick={this.handleClose}></div>
<div className="button button-minimize enabled" onClick={this.handleMinimize}></div>
<div className="button button-fullscreen enabled" onClick={this.handleFullscreen}></div>
</div>
<RetinaImage className="logo" src="logo.png"/>
buttons = (
<div className="buttons">
<div className="button button-close enabled" onClick={this.handleClose}></div>
<div className="button button-minimize enabled" onClick={this.handleMinimize}></div>
<div className="button button-fullscreen enabled" onClick={this.handleFullscreen}></div>
</div>
);
}
let username;
if (this.props.hideLogin) {
username = null;
} else if (this.state.username) {
username = (
<span className="no-drag" onClick={this.handleUserClick}>
<RetinaImage src="user.png"/> {this.state.username} {this.state.verified ? null : '(Unverified)'} <RetinaImage src="userdropdown.png"/>
</span>
);
} else {
username = (
<span className="no-drag" onClick={this.handleLoginClick}>
<RetinaImage src="user.png"/> Log In
</span>
);
}
let headerClasses = classNames({
bordered: !this.props.hideLogin,
header: true,
'no-drag': true
});
return (
<div className={headerClasses}>
{buttons}
<div className="updates">
{updateWidget}
</div>
<div className="login">
{username}
</div>
</div>
);
}
});

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
@ -25,72 +44,55 @@ var ImageCard = React.createClass({
$tagOverlay.fadeOut(300);
metrics.track('Selected Image Tag');
},
handleClick: function (repository) {
handleClick: function () {
metrics.track('Created Container', {
from: 'search'
from: 'search',
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
});
let name = containerStore.generateName(repository);
containerActions.run(name, repository, this.state.chosenTag);
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);
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/';
if (this.props.image.is_official) {
$repoUri = $repoUri + "_/";
var repoUri = 'https://registry.hub.docker.com/';
if (this.props.image.namespace === 'library') {
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 +113,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 +130,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 +151,7 @@ var ImageCard = React.createClass({
</div>
<div className="card">
<div className="badges">
{officialBadge}
{badge}
</div>
<div className="name">
{name}
@ -152,20 +160,16 @@ 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>
<a className="btn btn-action" onClick={self.handleClick}>Create</a>
</div>
</div>
</div>

View File

@ -1,171 +0,0 @@
var _ = require('underscore');
var $ = require('jquery');
var React = require('react');
var RetinaImage = require('react-retina-image');
var Radial = require('./Radial.react');
var ImageCard = require('./ImageCard.react');
var Promise = require('bluebird');
var metrics = require('../utils/MetricsUtil');
var classNames = require('classnames');
var _recommended = [];
var _searchPromise = null;
var NewContainer = React.createClass({
getInitialState: function () {
return {
query: '',
loading: false,
results: _recommended
};
},
componentDidMount: function () {
this.refs.searchInput.getDOMNode().focus();
this.recommended();
},
componentWillUnmount: function () {
if (_searchPromise) {
_searchPromise.cancel();
}
},
search: function (query) {
if (_searchPromise) {
_searchPromise.cancel();
_searchPromise = null;
}
if (!query.length) {
this.setState({
query: query,
results: _recommended,
loading: false
});
return;
}
this.setState({
query: query,
loading: true
});
_searchPromise = Promise.delay(200).then(() => Promise.resolve($.get('https://registry.hub.docker.com/v1/search?q=' + query))).cancellable().then(data => {
metrics.track('Searched for Images');
this.setState({
results: data.results,
query: query,
loading: false
});
_searchPromise = null;
}).catch(Promise.CancellationError, () => {
});
},
recommended: function () {
if (_recommended.length) {
return;
}
Promise.resolve($.ajax({
url: 'https://kitematic.com/recommended.json',
cache: false,
dataType: 'json',
})).then(res => res.repos).map(repo => {
var query = repo.repo;
var vals = query.split('/');
if (vals.length === 1) {
query = 'library/' + vals[0];
}
return $.get('https://registry.hub.docker.com/v1/repositories_info/' + query).then(data => {
var res = _.extend(data, repo);
res.description = data.short_description;
res.is_official = data.namespace === 'library';
res.name = data.repo;
res.star_count = data.stars;
return res;
});
}).then(results => {
_recommended = results.filter(r => !!r);
if (!this.state.query.length && this.isMounted()) {
this.setState({
results: _recommended
});
}
}).catch(err => {
console.log(err);
});
},
handleChange: function (e) {
var query = e.target.value;
if (query === this.state.query) {
return;
}
this.search(query);
},
render: function () {
var title = this.state.query ? 'Results' : 'Recommended';
var data = this.state.results;
var results;
if (data.length) {
var items = data.map(function (image) {
return (
<ImageCard key={image.name} image={image} />
);
});
results = (
<div className="result-grid">
{items}
</div>
);
} else {
if (this.state.results.length === 0 && this.state.query === '') {
results = (
<div className="no-results">
<div className="loader">
<h2>Loading Images</h2>
<Radial spin="true" progress={90} thick={true} transparent={true} />
</div>
</div>
);
} else {
results = (
<div className="no-results">
<h1>Cannot find a matching image.</h1>
</div>
);
}
}
var loadingClasses = classNames({
hidden: !this.state.loading,
loading: true
});
var magnifierClasses = classNames({
hidden: this.state.loading,
icon: true,
'icon-magnifier': true,
'search-icon': true
});
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.
</div>
<div className="search">
<div className="search-bar">
<input type="search" ref="searchInput" className="form-control" placeholder="Search Docker Hub for an image" onChange={this.handleChange}/>
<div className={magnifierClasses}></div>
<RetinaImage className={loadingClasses} src="loading.png"/>
</div>
</div>
</div>
<div className="results">
<h4>{title}</h4>
{results}
</div>
</div>
</div>
);
}
});
module.exports = NewContainer;

View File

@ -1,32 +1,57 @@
var _ = require('underscore');
var $ = require('jquery');
var React = require('react/addons');
var Router = require('react-router');
var RetinaImage = require('react-retina-image');
var Radial = require('./Radial.react');
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,
results: _recommended
loading: repositoryStore.loading(),
repos: repositoryStore.all(),
username: accountStore.getState().username,
verified: accountStore.getState().verified,
accountLoading: accountStore.getState().loading,
error: repositoryStore.getState().error
};
},
componentDidMount: function () {
this.refs.searchInput.getDOMNode().focus();
this.recommended();
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) {
@ -34,63 +59,16 @@ module.exports = React.createClass({
_searchPromise = null;
}
if (!query.length) {
this.setState({
query: query,
results: _recommended,
loading: false
});
return;
}
this.setState({
query: query,
loading: true
});
_searchPromise = Promise.delay(200).cancellable().then(() => Promise.resolve($.get('https://registry.hub.docker.com/v1/search?q=' + query))).then(data => {
_searchPromise = Promise.delay(200).cancellable().then(() => {
metrics.track('Searched for Images');
this.setState({
results: data.results,
query: query,
loading: false
});
_searchPromise = null;
}).catch(Promise.CancellationError, () => {
});
},
recommended: function () {
if (_recommended.length) {
return;
}
Promise.resolve($.ajax({
url: 'https://kitematic.com/recommended.json',
cache: false,
dataType: 'json',
})).then(res => res.repos).map(repo => {
var query = repo.repo;
var vals = query.split('/');
if (vals.length === 1) {
query = 'library/' + vals[0];
}
return $.get('https://registry.hub.docker.com/v1/repositories_info/' + query).then(data => {
var res = _.extend(data, repo);
res.description = data.short_description;
res.is_official = data.namespace === 'library';
res.name = data.repo;
res.star_count = data.stars;
return res;
});
}).then(results => {
_recommended = results.filter(r => !!r);
if (!this.state.query.length && this.isMounted()) {
this.setState({
results: _recommended
});
}
}).catch(err => {
console.log(err);
});
repositoryActions.search(query);
}).catch(Promise.CancellationError, () => {});
},
handleChange: function (e) {
var query = e.target.value;
@ -99,67 +77,162 @@ 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});
metrics.track('Filtered Results', {
filter: filter
});
},
handleCheckVerification: function () {
accountActions.verify();
metrics.track('Verified Account', {
from: 'search'
});
},
render: function () {
var title = this.state.query ? 'Results' : 'Recommended';
var data = this.state.results;
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.toLowerCase().indexOf(this.state.query.toLowerCase()) !== -1 || repo.namespace.toLowerCase().indexOf(this.state.query.toLowerCase()) !== -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">
{recommendedResults}
{userRepoResults}
{otherResults}
</div>
);
} else {
if (this.state.results.length === 0 && this.state.query === '') {
if (this.state.query.length) {
results = (
<div className="no-results">
<div className="loader">
<h2>Loading Images</h2>
<Radial spin="true" progress={90} thick={true} transparent={true} />
</div>
<h2>Cannot find a matching image.</h2>
</div>
);
} else {
results = (
<div className="no-results">
<h1>Cannot find a matching image.</h1>
<h2>No Images</h2>
</div>
);
}
}
var loadingClasses = classNames({
let loadingClasses = classNames({
hidden: !this.state.loading,
loading: true
spinner: true,
loading: true,
'la-ball-clip-rotate': true,
'la-dark': true,
'la-sm': true
});
var magnifierClasses = classNames({
let magnifierClasses = classNames({
hidden: this.state.loading,
icon: true,
'icon-magnifier': true,
'search-icon': true
});
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">
<input type="search" ref="searchInput" className="form-control" placeholder="Search Docker Hub for an image" onChange={this.handleChange}/>
<div className={magnifierClasses}></div>
<RetinaImage className={loadingClasses} src="loading.png"/>
<div className={loadingClasses}><div></div></div>
</div>
</div>
</div>
<div className="results">
<h4>{title}</h4>
<div className="results-filters">
<span className="results-filter results-filter-title">FILTER BY</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>
</div>

View File

@ -76,15 +76,17 @@ var Setup = React.createClass({
renderStep: function () {
return (
<div className="setup">
<Header />
<div className="image">
{this.renderContents()}
</div>
<div className="desc">
<div className="content">
<h4>Step {SetupStore.number()} out of {SetupStore.stepCount()}</h4>
<h1>{SetupStore.step().title}</h1>
<p>{SetupStore.step().message}</p>
<Header hideLogin={true}/>
<div className="setup-content">
<div className="image">
{this.renderContents()}
</div>
<div className="desc">
<div className="content">
<h4>Step {SetupStore.number()} out of {SetupStore.stepCount()}</h4>
<h1>{SetupStore.step().title}</h1>
<p>{SetupStore.step().message}</p>
</div>
</div>
</div>
</div>
@ -93,17 +95,19 @@ var Setup = React.createClass({
renderCancelled: function () {
return (
<div className="setup">
<Header />
<div className="image">
{this.renderContents()}
</div>
<div className="desc">
<div className="content">
<h4>Setup Cancelled</h4>
<h1>Couldn&#39;t Install Requirements</h1>
<p>Kitematic didn&#39;t receive the administrative privileges required to install or upgrade VirtualBox &amp; Docker.</p>
<p>Please click retry. If VirtualBox is not installed, you can download &amp; install it manually from the <a onClick={this.handleOpenWebsite}>official Oracle website</a>.</p>
<p><button className="btn btn-action" onClick={this.handleCancelRetry}>Retry</button></p>
<Header hideLogin={true}/>
<div className="setup-content">
<div className="image">
{this.renderContents()}
</div>
<div className="desc">
<div className="content">
<h4>Setup Cancelled</h4>
<h1>Couldn&#39;t Install Requirements</h1>
<p>Kitematic didn&#39;t receive the administrative privileges required to install or upgrade VirtualBox &amp; Docker.</p>
<p>Please click retry. If VirtualBox is not installed, you can download &amp; install it manually from the <a onClick={this.handleOpenWebsite}>official Oracle website</a>.</p>
<p><button className="btn btn-action" onClick={this.handleCancelRetry}>Retry</button></p>
</div>
</div>
</div>
</div>
@ -112,24 +116,26 @@ var Setup = React.createClass({
renderError: function () {
return (
<div className="setup">
<Header />
<div className="image">
<div className="contents">
<RetinaImage src="install-error.png" checkIfRetinaImgExists={false}/>
<div className="detail">
<Header hideLogin={true}/>
<div className="setup-content">
<div className="image">
<div className="contents">
<RetinaImage src="install-error.png" checkIfRetinaImgExists={false}/>
<div className="detail">
</div>
</div>
</div>
</div>
<div className="desc">
<div className="content">
<h4>Setup Error</h4>
<h1>We&#39;re Sorry!</h1>
<p>There seems to have been an unexpected error with Kitematic:</p>
<p className="error">{this.state.error.message || this.state.error}</p>
<p className="setup-actions">
<button className="btn btn-action" onClick={this.handleErrorRetry}>Retry Setup</button>
<button className="btn btn-action" onClick={this.handleErrorRemoveRetry}>Delete VM and Retry Setup</button>
</p>
<div className="desc">
<div className="content">
<h4>Setup Error</h4>
<h1>We&#39;re Sorry!</h1>
<p>There seems to have been an unexpected error with Kitematic:</p>
<p className="error">{this.state.error.message || this.state.error}</p>
<p className="setup-actions">
<button className="btn btn-action" onClick={this.handleErrorRetry}>Retry Setup</button>
<button className="btn btn-action" onClick={this.handleErrorRemoveRetry}>Delete VM and Retry Setup</button>
</p>
</div>
</div>
</div>
</div>

View File

@ -1,8 +1,11 @@
var Router = require('react-router');
var routes = require('./routes');
module.exports = {
router: null,
var router = Router.create({
routes: routes
});
get: function () {
return this.router;
},
module.exports = router;
set: function (router) {
this.router = router;
}
};

View File

@ -1,5 +1,8 @@
var React = require('react/addons');
var Setup = require('./components/Setup.react');
var Account = require('./components/Account.react');
var AccountSignup = require('./components/AccountSignup.react');
var AccountLogin = require('./components/AccountLogin.react');
var Containers = require('./components/Containers.react');
var ContainerDetails = require('./components/ContainerDetails.react');
var ContainerHome = require('./components/ContainerHome.react');
@ -16,7 +19,6 @@ var Router = require('react-router');
var Route = Router.Route;
var DefaultRoute = Router.DefaultRoute;
var RouteHandler = Router.RouteHandler;
var Redirect = Router.Redirect;
var App = React.createClass({
render: function () {
@ -28,6 +30,10 @@ var App = React.createClass({
var routes = (
<Route name="app" path="/" handler={App}>
<Route name="account" path="/account" handler={Account}>
<Route name="signup" path="/account/signup" handler={AccountSignup}/>
<Route name="login" path="/account/login" handler={AccountLogin}/>
</Route>
<Route name="containers" handler={Containers}>
<Route name="container" path="containers/details/:name" handler={ContainerDetails}>
<DefaultRoute name="containerHome" handler={ContainerHome} />
@ -45,7 +51,6 @@ var routes = (
<Route name="preferences" path="/preferences" handler={Preferences}/>
</Route>
<DefaultRoute name="setup" handler={Setup}/>
<Redirect from="containers/details/:name" to="containerHome"/>
</Route>
);

View File

@ -0,0 +1,72 @@
import alt from '../alt';
import accountServerActions from '../actions/AccountServerActions';
import accountActions from '../actions/AccountActions';
class AccountStore {
constructor () {
this.bindActions(accountServerActions);
this.bindActions(accountActions);
this.prompted = false;
this.loading = false;
this.errors = {};
this.verified = false;
this.username = null;
}
skip () {
this.setState({
prompted: true
});
}
login () {
this.setState({
loading: true,
errors: {}
});
}
logout () {
this.setState({
loading: false,
errors: {},
username: null,
verified: false
});
}
signup () {
this.setState({
loading: true,
errors: {}
});
}
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, loading: false});
}
prompted ({prompted}) {
this.setState({prompted});
}
errors ({errors}) {
this.setState({errors, loading: false});
}
}
export default alt.createStore(AccountStore);

View File

@ -0,0 +1,76 @@
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.bindActions(accountServerActions);
this.results = [];
this.recommended = [];
this.repos = [];
this.reposLoading = false;
this.recommendedLoading = false;
this.resultsLoading = false;
this.error = null;
}
error ({error}) {
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;
}
}
export default alt.createStore(RepositoryStore);

View File

@ -59,7 +59,7 @@ var _steps = [{
message: 'To run Docker containers on your computer, Kitematic is starting a Linux virtual machine. This may take a minute...',
totalPercent: 60,
percent: 0,
seconds: 58,
seconds: 72,
run: Promise.coroutine(function* (progressCallback) {
setupUtil.simulateProgress(this.seconds, progressCallback);
var exists = yield machine.exists();

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,7 @@ import path from 'path';
import dockerode from 'dockerode';
import _ from 'underscore';
import util from './Util';
import registry from '../utils/RegistryUtil';
import hubUtil from './HubUtil';
import metrics from '../utils/MetricsUtil';
import containerServerActions from '../actions/ContainerServerActions';
import Promise from 'bluebird';
@ -317,7 +317,6 @@ export default {
stream.setEncoding('utf8');
stream.on('data', json => {
let data = JSON.parse(json);
// console.log(data);
if (data.status === 'pull' || data.status === 'untag' || data.status === 'delete') {
return;
@ -335,160 +334,117 @@ export default {
},
pullImage (repository, tag, callback, progressCallback, blockedCallback) {
registry.layers(repository, tag, (err, layerSizes) => {
let opts = {}, config = hubUtil.config();
if (!hubUtil.config()) {
opts = {};
} else {
let [username, password] = hubUtil.creds(config);
opts = {
authconfig: {
username,
password,
auth: ''
}
};
}
// 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, opts, (err, stream) => {
if (err) {
callback(err);
return;
}
let existingIds = new Set(images.map(function (image) {
return image.Id.slice(0, 12);
}));
stream.setEncoding('utf8');
let layersToDownload = layerSizes.filter(function (layerSize) {
return !existingIds.has(layerSize.Id);
});
// scheduled to inform about progression at given interval
let tick = null;
let layerProgress = {};
console.log("existingIds:" + existingIds.size)
console.log("layersToDownload:" + layersToDownload.length)
// Split the loading in a few columns for more feedback
let columns = {};
columns.amount = 4; // arbitrary
columns.toFill = 0; // the current column index, waiting for layer IDs to be displayed
this.client.pull(repository + ':' + tag, (err, stream) => {
if (err) {
callback(err);
return;
}
stream.setEncoding('utf8');
// data is associated with one layer only (can be identified with id)
stream.on('data', str => {
var data = JSON.parse(str);
// layerProgress contains progression infos for all layers
let layerProgress = layersToDownload.reduce(function (r, layer) {
if (_.findWhere(images, {Id: layer.Id})) {
// If the layer is already here, we set current and total to 1
r[layer.Id] = {current:1, total:1, column:-1};
} else {
// At this point, the total layer size is unknown
// so we set total to -1 to avoid displaying it
r[layer.Id] = {current:0, total:-1, column:-1};
}
return r;
}, {});
if (data.error) {
callback(data.error);
return;
}
if (data.status && (data.status === 'Pulling dependent layers' || data.status.indexOf('already being pulled by another client') !== -1)) {
blockedCallback();
return;
}
let layersToLoad = _.keys(layerProgress).length;
console.log("nbLayers:" + layersToLoad);
// Split the loading in a few columns for more feedback
let columns = {};
columns.amount = 4; // arbitrary
columns.progress = []; // layerIDs, nbLayers, maxLayers, progress value
columns.toFill = 0; // the current column index, waiting for layer IDs to be displayed
for (let i = 0; i < columns.amount; i++)
{
let layerAmount = Math.ceil(layersToLoad / (columns.amount - i));
layersToLoad -= layerAmount;
columns.progress[i] = { layerIDs:[], nbLayers:0 , maxLayers:layerAmount , value:0.0 };
}
// scheduled to inform about progression at given interval
let tick = null;
// data is associated with one layer only (can be identified with id)
stream.on('data', str => {
var data = JSON.parse(str);
if (data.error) {
return;
}
if (data.status && (data.status === 'Pulling dependent layers' || data.status.indexOf('already being pulled by another client') !== -1)) {
blockedCallback();
return;
}
if (data.status === 'Already exists') {
console.log("Already exists.");
//layerProgress[data.id].current = 1;
//layerProgress[data.id].total = 1;
} else if (data.status === 'Downloading') {
// aduermael: How total could be <= 0 ?
// if (data.progressDetail.total <= 0) {
// progressCallback(0);
// return;
// } else {
layerProgress[data.id].current = data.progressDetail.current;
layerProgress[data.id].total = data.progressDetail.total;
// Assign to a column if not done yet
if (layerProgress[data.id].column == -1)
{
// test if we can still add layers to that column
if (columns.progress[columns.toFill].nbLayers == columns.progress[columns.toFill].maxLayers) columns.toFill++;
layerProgress[data.id].column = columns.toFill;
columns.progress[columns.toFill].layerIDs.push(data.id);
columns.progress[columns.toFill].nbLayers++;
if (data.status === 'Pulling fs layer') {
layerProgress[data.id] = {
current: 0,
total: 1
};
} else if (data.status === 'Downloading') {
if (!columns.progress) {
columns.progress = []; // layerIDs, nbLayers, maxLayers, progress value
let layersToLoad = _.keys(layerProgress).length;
let layersPerColumn = Math.floor(layersToLoad / columns.amount);
let leftOverLayers = layersToLoad % columns.amount;
for (let i = 0; i < columns.amount; i++) {
let layerAmount = layersPerColumn;
if (i < leftOverLayers) {
layerAmount += 1;
}
columns.progress[i] = {layerIDs: [], nbLayers:0 , maxLayers: layerAmount, value: 0.0};
}
}
//}
layerProgress[data.id].current = data.progressDetail.current;
layerProgress[data.id].total = data.progressDetail.total;
if (!tick) {
tick = setInterval( function(){
// console.log(JSON.stringify(layerProgress))
// Assign to a column if not done yet
if (!layerProgress[data.id].column) {
// test if we can still add layers to that column
if (columns.progress[columns.toFill].nbLayers === columns.progress[columns.toFill].maxLayers && columns.toFill < columns.amount - 1) {
columns.toFill++;
}
// update values
for (let i = 0; i < columns.amount; i++)
{
columns.progress[i].value = 0.0;
layerProgress[data.id].column = columns.toFill;
columns.progress[columns.toFill].layerIDs.push(data.id);
columns.progress[columns.toFill].nbLayers++;
}
// Start only if the column has accurate values for all layers
if (columns.progress[i].nbLayers == columns.progress[i].maxLayers)
{
let layer;
let totalSum = 0;
let currentSum = 0;
if (!tick) {
tick = setTimeout(() => {
clearInterval(tick);
tick = null;
for (let i = 0; i < columns.amount; i++) {
columns.progress[i].value = 0.0;
if (columns.progress[i].nbLayers > 0) {
let layer;
let totalSum = 0;
let currentSum = 0;
for (let j = 0; j < columns.progress[i].nbLayers; j++)
{
layer = layerProgress[columns.progress[i].layerIDs[j]];
totalSum += layer.total;
currentSum += layer.current;
}
if (totalSum > 0) columns.progress[i].value = 100.0 * currentSum / totalSum;
else columns.progress[i].value = 0.0;
}
for (let j = 0; j < columns.progress[i].nbLayers; j++) {
layer = layerProgress[columns.progress[i].layerIDs[j]];
totalSum += layer.total;
currentSum += layer.current;
}
progressCallback(columns);
},33);
if (totalSum > 0) {
columns.progress[i].value = Math.min(100.0 * currentSum / totalSum, 100);
} else {
columns.progress[i].value = 0.0;
}
}
}
}
});
stream.on('end', function () {
clearInterval(tick);
callback();
});
});
progressCallback(columns);
}, 16);
}
}
});
stream.on('end', function () {
callback();
});
});
},

181
src/utils/HubUtil.js Normal file
View File

@ -0,0 +1,181 @@
var _ = require('underscore');
var request = require('request');
var accountServerActions = require('../actions/AccountServerActions');
module.exports = {
init: function () {
accountServerActions.prompted({prompted: localStorage.getItem('auth.prompted')});
let username = localStorage.getItem('auth.username');
let verified = localStorage.getItem('auth.verified') === 'true';
if (username) {
accountServerActions.loggedin({username, verified});
}
},
username: function () {
return localStorage.getItem('auth.username') || null;
},
// Returns the base64 encoded index token or null if no token exists
config: function () {
let config = localStorage.getItem('auth.config');
if (!config) {
return null;
}
return config;
},
// Retrives the current jwt hub token or null if no token exists
jwt: function () {
let jwt = localStorage.getItem('auth.jwt');
if (!jwt) {
return null;
}
return jwt;
},
prompted: function () {
return localStorage.getItem('auth.prompted');
},
setPrompted: function (prompted) {
localStorage.setItem('auth.prompted', true);
accountServerActions.prompted({prompted});
},
request: function (req, callback) {
let jwt = this.jwt();
if (jwt) {
_.extend(req, {
headers: {
Authorization: `JWT ${jwt}`
}
});
}
// First attempt with existing JWT
request(req, (error, response, body) => {
let data = JSON.parse(body);
// If the JWT has expired, then log in again to get a new JWT
if (data && data.detail === 'Signature has expired.') {
let config = this.config();
if (!this.config()) {
this.logout();
return;
}
let [username, password] = this.creds(config);
this.auth(username, password, (error, response, body) => {
let data = JSON.parse(body);
if (response.statusCode === 200 && data && data.token) {
localStorage.setItem('auth.jwt', data.token);
} else {
this.logout();
}
this.request(req, callback);
});
} else {
callback(error, response, body);
}
});
},
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');
},
login: function (username, password) {
this.auth(username, password, (error, response, body) => {
if (error) {
accountServerActions.errors({errors: {detail: error.message}});
return;
}
let data = JSON.parse(body);
if (response.statusCode === 200) {
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});
accountServerActions.prompted({prompted: true});
require('./RegHubUtil').repos();
} else {
accountServerActions.errors({errors: {detail: 'Did not receive login token.'}});
}
} else if (response.statusCode === 401) {
if (data && data.detail && data.detail.indexOf('Account not active yet') !== -1) {
accountServerActions.loggedin({username, verified: false});
accountServerActions.prompted({prompted: true});
localStorage.setItem('auth.username', username);
localStorage.setItem('auth.verified', false);
localStorage.setItem('auth.config', new Buffer(username + ':' + password).toString('base64'));
} else {
accountServerActions.errors({errors: data});
}
}
});
},
auth: function (username, password, callback) {
request.post('https://hub.docker.com/v2/users/login/', {form: {username, password}}, (error, response, body) => {
callback(error, response, body);
});
},
verify: function () {
let config = this.config();
if (!config) {
this.logout();
return;
}
let [username, password] = this.creds(config);
this.login(username, password);
},
creds: function (config) {
return new Buffer(config, 'base64').toString().split(/:(.+)?/).slice(0, 2);
},
// 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/', {
form: {
username,
password,
email,
subscribe
}
}, (err, response, body) => {
// TODO: save username to localstorage
if (response.statusCode === 204) {
accountServerActions.signedup({username, verified: false});
accountServerActions.prompted({prompted: true});
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 = {};
for (let key in data) {
errors[key] = data[key][0];
}
accountServerActions.errors({errors});
}
});
},
};

142
src/utils/RegHubUtil.js Normal file
View File

@ -0,0 +1,142 @@
var _ = require('underscore');
var request = require('request');
var async = require('async');
var util = require('../utils/Util');
var hubUtil = require('../utils/HubUtil');
var repositoryServerActions = require('../actions/RepositoryServerActions');
var tagServerActions = require('../actions/TagServerActions');
let searchReq = null;
module.exports = {
// 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 (searchReq) {
searchReq.abort();
searchReq = null;
}
if (!query) {
repositoryServerActions.resultsUpdated({repos: []});
}
searchReq = request.get({
url: 'https://registry.hub.docker.com/v1/search?',
qs: {q: query, page}
}, (error, response, body) => {
if (error) {
repositoryServerActions.searchError({error});
}
let data = JSON.parse(body);
let repos = _.map(data.results, result => {
return this.normalize(result);
});
if (response.statusCode === 200) {
repositoryServerActions.resultsUpdated({repos});
}
});
},
recommended: function () {
request.get('https://kitematic.com/recommended.json', (error, response, body) => {
if (error) {
repositoryServerActions.recommendedError({error});
}
let data = JSON.parse(body);
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 (repo) {
hubUtil.request({
url: `https://registry.hub.docker.com/v2/repositories/${repo}/tags`
}, (error, response, body) => {
if (response.statusCode === 200) {
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 () {
repositoryServerActions.reposLoading({repos: []});
hubUtil.request({
url: 'https://registry.hub.docker.com/v2/namespaces/',
}, (error, response, body) => {
if (error) {
repositoryServerActions.reposError({error});
return;
}
let data = JSON.parse(body);
let namespaces = data.namespaces;
async.map(namespaces, (namespace, cb) => {
hubUtil.request({
url: `https://registry.hub.docker.com/v2/repositories/${namespace}`
}, (error, response, body) => {
if (error) {
repositoryServerActions.reposError({error});
return;
}
let data = JSON.parse(body);
cb(null, data.results);
});
}, (error, lists) => {
let repos = [];
for (let list of lists) {
repos = repos.concat(list);
}
_.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

@ -17,7 +17,7 @@
}
.subtext {
text-align: right;
color: @gray-lightest;
color: @gray-lighter;
margin-top: 2px;
transition: all 0.25s;
&:hover {

View File

@ -1,7 +1,3 @@
.container-progress-wrapper {
margin-left: 37%;
}
.container-progress {
display: inline-block;
position: relative;

View File

@ -1,25 +1,56 @@
.header {
position: absolute;
min-width: 100%;
min-height: 40px;
min-height: 50px;
-webkit-app-region: drag;
-webkit-user-select: none;
&.no-drag {
&.bordered {
border-bottom: 1px solid #E7E7E7;
}
display: flex;
.no-drag {
-webkit-app-region: no-drag;
}
.logo {
position: relative;
float: right;
top: 10px;
right: 10px;
z-index: 1000;
.updates {
flex: 1 auto;
display: flex;
align-items: center;
justify-content: flex-end;
margin-right: 20px;
img {
margin: 0 14px;
height: 16px;
width: 20px;
}
}
.login {
flex: 0 auto;
display: flex;
color: #88919C;
align-items: center;
justify-content: flex-end;
margin-right: 13px;
&:active {
img, span {
-webkit-filter: brightness(0.8);
}
}
img {
margin: 0 5px;
}
}
.buttons {
display: inline-block;
position: relative;
top: 10px;
left: 15px;
z-index: 1000;
display: flex;
margin-left: 14px;
align-items: center;
justify-content: center;
&:hover {
.button-minimize.enabled {
.at2x('minimize.png', 10px, 10px);
@ -35,6 +66,7 @@
}
}
.button {
flex: 0 auto;
.traffic-light();
&.button-close {
background-color: @traffic-light-red;

View File

@ -1,7 +1,7 @@
/* Sidebar */
.sidebar {
padding-top: 28px;
padding-top: 10px;
background-color: white;
margin: 0;
border-right: 1px solid @color-divider;
@ -9,6 +9,7 @@
flex-direction: column;
min-width: 260px;
box-sizing: border-box;
position: relative;
.sidebar-header {
flex: 0 auto;
min-width: 240px;
@ -59,12 +60,15 @@
}
.sidebar-containers {
position: relative;
display: flex;
flex-direction: column;
flex: 1 auto;
overflow-y: auto;
overflow-x: hidden;
box-sizing: border-box;
max-width: 260px;
ul {
flex: 1 auto;
margin: 0;
padding: 0;
display: flex;
@ -240,13 +244,13 @@
/* Sidebar Buttons */
.sidebar-buttons {
border-top: 1px solid #F0F4F8;
min-height: 48px;
flex: 0 auto;
display: flex;
flex-direction: row;
background-color: white;
width: 259px;
opacity: 0.9;
position: fixed;
bottom: 0;
padding: 15px;
padding-top: 5px;
z-index: 10000;
.btn-label {
color: @gray-lighter;
@ -254,8 +258,35 @@
height: 18px;
}
.btn-sidebar {
display: inline-block;
margin-right: 7px;
font-size: 13px;
font-weight: 500;
color: @brand-primary;
flex: 0 auto;
display: flex;
align-items: center;
justify-content: center;
min-width: 48px;
&:active {
img, .text {
-webkit-filter: brightness(0.8);
}
}
}
.btn-terminal {
flex: 1 auto;
border-right: 1px solid #F0F4F8;
img {
margin: 0 10px 0 0;
height: 21px;
width: 25px;
}
}
.btn-feedback {
border-right: 1px solid #F0F4F8;
}
.btn {
position: relative;

View File

@ -15,6 +15,7 @@
@import "container-home.less";
@import "container-logs.less";
@import "container-settings.less";
@import "spinner.less";
@import "animation.less";
@import "container-progress.less";
@ -24,6 +25,7 @@ html, body {
overflow: hidden;
background: none;
-webkit-user-select: none;
-webkit-user-drag: none;
font-family: @font-regular;
cursor: default;
img {

View File

@ -2,7 +2,7 @@
box-sizing: border-box;
display: inline-block;
background: white;
margin-right: 9px;
margin-right: 8px;
height: 12px;
width: 12px;
border: 1px solid @traffic-light-gray-border;

View File

@ -31,58 +31,103 @@
display: flex;
flex: 1 auto;
flex-direction: column;
padding: 35px 20px 32px 25px;
padding: 25px 0 0;
.spinner {
display: inline-block;
}
.results {
display: flex;
flex-direction: column;
flex: 1 auto;
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;
margin: 0 20px;
.results-filter {
text-align: center;
margin: 0 10px;
min-width: 40px;
}
.results-filter-title {
color: @gray-lighter;
font-weight: 500;
padding-top: 6px;
}
}
.no-results {
flex: 1 auto;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
flex-shrink: 0;
img {
width: 380px;
}
.verify {
margin: 15px 0;
position: relative;
.spinner {
position: absolute;
top: 0;
right: -50px;
}
}
.loader {
margin: 0 auto;
margin-top: -20%;
display: flex;
flex-direction: column;
align-items: center;
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%;
}
}
}
.new-container-header {
margin-bottom: 18px;
margin: 0 20px 8px;
display: flex;
flex: 0 auto;
flex-shrink: 0;
.text {
flex: 1 auto;
width: 50%;
font-size: 14px;
color: @gray-lighter;
color: @gray-normal;
}
.search {
flex: 1 auto;
margin-right: 30px;
.search-bar {
top: -7px;
position: relative;
.loading {
position: absolute;
left: 10px;
top: 7px;
width: 16px;
height: 16px;
-webkit-animation-name: spin;
-webkit-animation-duration: 1.8s;
-webkit-animation-iteration-count: infinite;
-webkit-animation-timing-function: linear;
left: 10px;
}
.search-icon {
font-size: 18px;
@ -105,7 +150,7 @@
border-color: @brand-primary;
}
&::-webkit-input-placeholder {
color: @gray-lightest;
color: @gray-lighter;
font-weight: 400;
}
}
@ -114,183 +159,187 @@
}
}
.result-grid {
display: flex;
flex-flow: row wrap;
justify-content: flex-start;
margin-top: 10px;
.result-grids {
overflow: auto;
.image-item {
margin: 0 0 0 20px;
.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;
}
}
}
}

View File

@ -65,38 +65,15 @@
text-align: right;
margin-right: 3px;
margin-top: 3px;
.tab {
margin-left: 16px;
padding: 6px 10px;
font-weight: 400;
display: inline-block;
&:hover {
border-radius: 40px;
background-color: darken(@color-background, 2%);
}
&.active {
border-radius: 40px;
color: white;
.brand-gradient();
}
&.disabled {
opacity: 0.5;
&:hover {
border-radius: 40px;
background-color: transparent;
}
}
}
}
}
.details-header {
flex: 0 auto;
display: flex;
flex-direction: row;
padding: 31px 24px 18px 24px;
padding: 18px 24px 24px 24px;
position: relative;
background-color: white;
height: 75px;
h1 {
margin: 0;
font-size: 20px;
@ -124,9 +101,33 @@
}
}
}
.tab {
margin-left: 16px;
padding: 6px 10px;
font-weight: 400;
display: inline-block;
&.active {
border-radius: 40px;
color: white;
.brand-gradient();
}
&.disabled {
opacity: 0.5;
&:hover {
border-radius: 40px;
background-color: transparent;
}
}
}
.details-progress {
margin: 20% auto 0;
width: 400px;
flex: 1 auto;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
margin-top: -70px;
h2 {
margin-bottom: 20px;
text-align: center;

View File

@ -2,80 +2,220 @@
display: flex;
height: 100%;
width: 100%;
flex-direction: row;
flex-direction: column;
//-webkit-app-region: drag;
.image {
.setup-content {
display: flex;
width: 50%;
height: 100%;
flex: 0 auto;
align-items: center;
justify-content: flex-end;
padding-right: 40px;
padding-left: 80px;
flex-direction: row;
img {
width: 399px;
height: 340px;
flex: 1 auto;
.image {
display: flex;
flex: 1 auto;
align-items: center;
justify-content: flex-end;
padding-right: 40px;
img {
width: 399px;
height: 340px;
}
.contents {
position: relative;
.detail {
position: absolute;
right: -20px;
bottom: 0;
}
}
}
.contents {
position: relative;
.detail {
position: absolute;
right: -20px;
bottom: 0;
.form-section {
display: flex;
flex: 1 auto;
align-items: flex-end;
justify-content: center;
padding-right: 60px;
padding-left: 80px;
flex-direction: column;
img {
width: 323px;
height: 64px;
}
}
}
.desc {
display: flex;
width: 50%;
height: 100%;
form {
margin-top: 40px;
text-align: right;
input[type="text"], input[type="password"] {
display: block;
border: 0;
border-bottom: 1px solid @gray-lightest;
color: @gray-normal;
font-weight: 300;
padding: 10px 5px;
transition: all 0.25s;
font-size: 18px;
width: 340px;
&.error {
border-bottom: 1px solid @brand-negative;
&:focus {
border-bottom: 1px solid @brand-negative;
}
}
&:focus {
outline: 0;
border-bottom: 1px solid @brand-action;
}
&::-webkit-input-placeholder {
color: @gray-lighter;
font-weight: 400;
}
}
align-items: center;
padding-left: 40px;
div.checkbox {
text-align: left;
color: @gray-normal;
}
.content {
max-width: 320px;
div.submit {
margin-top: 10px;
display: flex;
flex-direction: row;
justify-content: flex-end;
align-items: center;
h1 {
color: @gray-darkest;
font-size: 24px;
}
h4 {
color: @gray-lightest;
font-size: 13px;
margin-top: -30px;
}
p {
font-size: 13px;
color: @gray-normal;
&.error {
.spinner {
margin-right: 10px;
flex: 0 auto;
}
button[type="submit"] {
flex: 0 auto;
display: block;
font-size: 18px;
padding: 10px 20px;
color: white;
background-color: @brand-action;
border: 0;
&:hover {
background-color: darken(@brand-action, 5%);
}
}
}
hr {
border-top: 1px solid #D7DFEA;
}
.extra {
text-align: center;
font-size: 14px;
color: @gray-normal;
margin-top: 16px;
.btn {
margin-left: 6px;
position: relative;
top: -3px;
}
a {
color: @brand-primary;
}
}
.link {
display: block;
font-size: 10px;
text-align: left;
position: relative;
top: -15px;
left: 5px;
}
.error-message {
font-size: 12px;
margin-top: 5px;
margin-bottom: 0;
min-height: 17px;
color: @brand-negative;
}
div.error {
font-size: 13px;
color: @gray-normal;
color: @brand-negative;
background-color: lighten(@brand-negative, 32%);
padding: 10px;
border-radius: 4px;
max-height: 400px;
overflow: auto;
}
}
.setup-actions {
button {
margin-right: 12px;
width: 340px;
display: none;
}
}
}
}
p {
&.error {
color: @brand-danger;
word-wrap: break-word;
.btn-skip {
position: absolute;
bottom: 20px;
right: 20px;
font-size: 14px;
}
margin-top: 20px;
.btn-close {
-webkit-app-region: no-drag;
position: absolute;
top: 16px;
right: 16px;
font-size: 14px;
}
.desc {
flex: 1 auto;
display: flex;
align-items: center;
padding-left: 40px;
.content {
max-width: 320px;
h1 {
color: @gray-darkest;
font-size: 24px;
}
h4 {
color: @gray-lightest;
font-size: 13px;
margin-top: -30px;
}
p {
font-size: 13px;
color: @gray-normal;
&.error {
color: @brand-negative;
background-color: lighten(@brand-negative, 32%);
padding: 10px;
border-radius: 4px;
max-height: 400px;
overflow: auto;
}
}
.setup-actions {
button {
margin-right: 12px;
}
}
}
}
p {
&.error {
color: @brand-danger;
word-wrap: break-word;
}
margin-top: 20px;
}
}
}

97
styles/spinner.less Normal file
View File

@ -0,0 +1,97 @@
/*
The MIT License (MIT)
Copyright (c) 2014-2015 Daniel Cardoso
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
@import "variables.less";
.la-ball-clip-rotate {
display: block;
}
.la-ball-clip-rotate > div {
box-sizing: content-box;
color: #fff;
background: #fff;
border-color: #fff;
border-style: solid;
border-width: 0;
}
.la-ball-clip-rotate:after {
display: table;
clear: both;
line-height: 0;
content: "";
}
.la-ball-clip-rotate.la-dark > div {
color: @brand-primary;
background: @brand-primary;
border-color: @brand-primary;
}
/*
* Animation
*/
@-webkit-keyframes ball-clip-rotate {
0% {
transform: rotate(0deg);
}
50% {
transform: rotate(180deg);
}
100% {
transform: rotate(360deg);
}
}
.la-ball-clip-rotate {
width: 32px;
height: 32px;
}
.la-ball-clip-rotate > div {
display: block;
float: left;
width: 28px;
height: 28px;
margin: 0;
background: transparent !important;
border-style: solid;
border-width: 1.8px;
border-bottom-color: transparent !important;
border-radius: 100%;
-webkit-animation: ball-clip-rotate 0.9s linear infinite;
}
.la-ball-clip-rotate.la-sm {
width: 16px;
height: 16px;
}
.la-ball-clip-rotate.la-sm > div {
width: 14px;
height: 14px;
margin: 0;
border-width: 1px;
}
.la-ball-clip-rotate.la-lg {
width: 48px;
height: 48px;
}
.la-ball-clip-rotate.la-lg > div {
width: 42px;
height: 42px;
margin: 0;
border-width: 2px;
}
.la-ball-clip-rotate.la-2x {
width: 64px;
height: 64px;
}
.la-ball-clip-rotate.la-2x > div {
width: 56px;
height: 56px;
margin: 0;
border-width: 2px;
}