Merge pull request #165 from kitematic/container-dropdowns

Added drop down settings for container ports and volumes.
This commit is contained in:
Jeffrey Morgan 2015-01-29 16:54:44 -05:00
commit e00454f1ba
9 changed files with 436 additions and 130 deletions

View File

@ -4,6 +4,7 @@ var React = require('react/addons');
var Router = require('react-router');
var exec = require('exec');
var path = require('path');
var assign = require('object-assign');
var remote = require('remote');
var dialog = remote.require('dialog');
var ContainerStore = require('./ContainerStore');
@ -13,7 +14,7 @@ var boot2docker = require('./boot2docker');
var ProgressBar = require('react-bootstrap/ProgressBar');
var ContainerDetails = React.createClass({
mixins: [Router.State],
mixins: [Router.State, Router.Navigation],
_oldHeight: 0,
PAGE_LOGS: 'logs',
PAGE_SETTINGS: 'settings',
@ -22,7 +23,12 @@ var ContainerDetails = React.createClass({
logs: [],
page: this.PAGE_LOGS,
env: {},
pendingEnv: {}
pendingEnv: {},
ports: {},
defaultPort: null,
volumes: {},
popoverVolumeOpen: false,
popoverViewOpen: false,
};
},
componentWillReceiveProps: function () {
@ -34,29 +40,72 @@ var ContainerDetails = React.createClass({
this.init();
ContainerStore.on(ContainerStore.SERVER_PROGRESS_EVENT, this.updateProgress);
ContainerStore.on(ContainerStore.SERVER_LOGS_EVENT, this.updateLogs);
// Make clicking anywhere close popovers
$('body').on('click', function (e) {
var popoverViewIsTarget = $('.popover-view').is(e.target) || $('.popover-view').has(e.target).length !== 0 || $('.dropdown-view').is(e.target) || $('.dropdown-view').has(e.target).length !== 0;
var popoverVolumeIsTarget = $('.popover-volume').is(e.target) || $('.popover-volume').has(e.target).length !== 0 || $('.dropdown-volume').is(e.target) || $('.dropdown-volume').has(e.target).length !== 0;
var state = {};
if (!popoverViewIsTarget) {
state.popoverViewOpen = false;
}
if (!popoverVolumeIsTarget) {
state.popoverVolumeOpen = false;
}
if (this.state.popoverViewOpen || this.state.popoverVolumeOpen) {
this.setState(state);
}
}.bind(this));
},
componentWillUnmount: function () {
ContainerStore.removeListener(ContainerStore.SERVER_PROGRESS_EVENT, this.updateProgress);
ContainerStore.removeListener(ContainerStore.SERVER_LOGS_EVENT, this.updateLogs);
},
componentDidUpdate: function () {
// Scroll logs to bottom
var parent = $('.details-logs');
if (!parent.length) {
return;
if (parent.length) {
if (parent.scrollTop() >= this._oldHeight) {
parent.stop();
parent.scrollTop(parent[0].scrollHeight - parent.height());
}
this._oldHeight = parent[0].scrollHeight - parent.height();
}
if (parent.scrollTop() >= this._oldHeight) {
parent.stop();
parent.scrollTop(parent[0].scrollHeight - parent.height());
var $viewDropdown = $(this.getDOMNode()).find('.dropdown-view');
var $volumeDropdown = $(this.getDOMNode()).find('.dropdown-volume');
var $viewPopover = $(this.getDOMNode()).find('.popover-view');
var $volumePopover = $(this.getDOMNode()).find('.popover-volume');
if ($viewDropdown && $volumeDropdown && $viewPopover && $volumePopover) {
$viewPopover.offset({
top: $viewDropdown.offset().top + 32,
left: $viewDropdown.offset().left - ($viewPopover.outerWidth() / 2) + 14
});
$volumePopover.offset({
top: $volumeDropdown.offset().top + 32,
left: $volumeDropdown.offset().left + $volumeDropdown.outerWidth() - $volumePopover.outerWidth() / 2 - 20
});
}
this._oldHeight = parent[0].scrollHeight - parent.height();
},
init: function () {
var container = ContainerStore.container(this.getParams().name);
if (!container) {
return;
}
this.setState({
env: ContainerUtil.env(ContainerStore.container(this.getParams().name))
env: ContainerUtil.env(container),
});
ContainerStore.fetchLogs(this.getParams().name, function () {
this.updateLogs();
}.bind(this));
var ports = ContainerUtil.ports(container);
var webPorts = ['80', '8000', '8080', '3000', '5000', '2368'];
this.setState({
ports: ports,
defaultPort: _.find(_.keys(ports), function (port) {
return webPorts.indexOf(port) !== -1;
})
});
this.updateLogs();
},
updateLogs: function (name) {
if (name && name !== this.getParams().name) {
@ -84,27 +133,31 @@ var ContainerDetails = React.createClass({
});
},
handleView: function () {
var container = this.props.container;
boot2docker.ip(function (err, ip) {
var ports = _.map(container.NetworkSettings.Ports, function (value, key) {
var portProtocolPair = key.split('/');
var res = {
'port': portProtocolPair[0],
'protocol': portProtocolPair[1]
};
if (value && value.length) {
var port = value[0].HostPort;
res.host = ip;
res.port = port;
res.url = 'http://' + ip + ':' + port;
} else {
return null;
}
return res;
});
exec(['open', ports[0].url], function (err) {
if (this.state.defaultPort) {
console.log(this.state.defaultPort);
exec(['open', this.state.ports[this.state.defaultPort].url], function (err) {
if (err) { throw err; }
});
}
},
handleViewLink: function (url) {
exec(['open', url], function (err) {
if (err) { throw err; }
});
},
handleViewDropdown: function(e) {
this.setState({
popoverViewOpen: !this.state.popoverViewOpen
});
},
handleVolumeDropdown: function(e) {
this.setState({
popoverVolumeOpen: !this.state.popoverVolumeOpen
});
},
handleRestart: function () {
ContainerStore.restart(this.props.container.Name, function (err) {
console.log(err);
});
},
handleRestart: function () {
@ -122,6 +175,17 @@ var ContainerDetails = React.createClass({
}
});
},
handleSaveContainerName: function () {
var newName = $('#input-container-name').val();
ContainerStore.updateContainer(this.props.container.Name, {
name: newName
}, function (err) {
this.transitionTo('container', {name: newName});
if (err) {
console.error(err);
}
}.bind(this));
},
handleSaveEnvVar: function () {
var $rows = $('.env-vars .keyval-row');
var envVarList = [];
@ -133,8 +197,19 @@ var ContainerDetails = React.createClass({
}
envVarList.push(key + '=' + val);
});
ContainerStore.updateContainer(this.props.container.Name, {
var self = this;
ContainerStore.updateContainer(self.props.container.Name, {
Env: envVarList
}, function (err) {
if (err) {
console.error(err);
} else {
self.setState({
pendingEnv: {}
});
$('#new-env-key').val('');
$('#new-env-val').val('');
}
});
},
handleAddPendingEnvVar: function () {
@ -224,6 +299,66 @@ var ContainerDetails = React.createClass({
);
});
var disabledClass = '';
if (!this.props.container.State.Running) {
disabledClass = 'disabled';
}
var buttonClass = React.addons.classSet({
btn: true,
'btn-action': true,
'with-icon': true,
disabled: !this.props.container.State.Running
});
var viewButtonClass = React.addons.classSet({
btn: true,
'btn-action': true,
'with-icon': true,
disabled: !this.props.container.State.Running || !this.state.defaultPort
});
var textButtonClasses = React.addons.classSet({
'btn': true,
'btn-action': true,
'only-icon': true,
'active': this.state.page === this.PAGE_LOGS,
disabled: this.props.container.State.Downloading
});
var gearButtonClass = React.addons.classSet({
'btn': true,
'btn-action': true,
'only-icon': true,
'active': this.state.page === this.PAGE_SETTINGS,
disabled: this.props.container.State.Downloading
});
var viewPopoverClasses = React.addons.classSet({
popover: true,
hidden: false
});
var popoverVolumeClasses = React.addons.classSet({
'popover-volume': true,
hidden: !this.state.popoverVolumeOpen
});
var popoverViewClasses = React.addons.classSet({
'popover-view': true,
hidden: !this.state.popoverViewOpen
});
var dropdownClasses = {
btn: true,
'btn-action': true,
'with-icon': true,
'dropdown-toggle': true,
disabled: !this.props.container.State.Running
};
var dropdownViewButtonClass = React.addons.classSet(assign({'dropdown-view': true}, dropdownClasses));
var dropdownVolumeButtonClass = React.addons.classSet(assign({'dropdown-volume': true}, dropdownClasses));
var body;
if (this.props.container.State.Downloading) {
body = (
@ -245,7 +380,10 @@ var ContainerDetails = React.createClass({
<div className="details-panel">
<div className="settings">
<h3>Container Name</h3>
<input id="input-container-name" type="text" className="line" placeholder="Container Name" defaultValue={this.props.container.Name}></input>
<div className="container-name">
<input id="input-container-name" type="text" className="line" placeholder="Container Name" defaultValue={this.props.container.Name}></input>
</div>
<a className="btn btn-action" onClick={this.handleSaveContainerName}>Save</a>
<h3>Environment Variables</h3>
<div className="env-vars-labels">
<div className="label-key">KEY</div>
@ -269,38 +407,27 @@ var ContainerDetails = React.createClass({
}
}
var disabledClass = '';
if (!this.props.container.State.Running) {
disabledClass = 'disabled';
}
var buttonClass = React.addons.classSet({
btn: true, 'btn-action': true,
'with-icon': true,
disabled: !this.props.container.State.Running
});
var dropdownButtonClass = React.addons.classSet({
btn: true,
'btn-action': true,
'with-icon': true,
'dropdown-toggle': true,
disabled: !this.props.container.State.Running
var ports = _.map(_.pairs(self.state.ports), function (pair, index, list) {
var key = pair[0];
var val = pair[1];
return (
<div key={key} className="table-values">
<span className="value-left">{key}</span><span className="icon icon-arrow-right"></span>
<a className="value-right" onClick={self.handleViewLink.bind(self, val.url)}>{val.display}</a>
</div>
);
});
var textButtonClasses = React.addons.classSet({
'btn': true,
'btn-action': true,
'only-icon': true,
'active': this.state.page === this.PAGE_LOGS,
disabled: this.props.container.State.Downloading
});
var gearButtonClass = React.addons.classSet({
'btn': true,
'btn-action': true,
'only-icon': true,
'active': this.state.page === this.PAGE_SETTINGS,
disabled: this.props.container.State.Downloading
var volumes = _.map(self.props.container.Volumes, function (val, key) {
if (!val || val.indexOf(process.env.HOME) === -1) {
val = 'No Host Folder';
}
return (
<div key={key} className="table-values">
<span className="value-left">{key}</span><span className="icon icon-arrow-right"></span>
<a className="value-right">{val.replace(process.env.HOME, '~')}</a>
</div>
);
});
return (
@ -311,10 +438,11 @@ var ContainerDetails = React.createClass({
</div>
<div className="details-header-actions">
<div className="action btn-group">
<a className={buttonClass} onClick={this.handleView}><span className="icon icon-preview-2"></span><span className="content">View</span></a><a className={dropdownButtonClass}><span className="icon-dropdown icon icon-arrow-37"></span></a>
<a className={viewButtonClass} onClick={this.handleView}><span className="icon icon-preview-2"></span><span className="content">View</span></a>
<a className={dropdownViewButtonClass} onClick={this.handleViewDropdown}><span className="icon-dropdown icon icon-arrow-37"></span></a>
</div>
<div className="action">
<a className={dropdownButtonClass} onClick={this.handleView}><span className="icon icon-folder-1"></span> <span className="content">Volumes</span> <span className="icon-dropdown icon icon-arrow-37"></span></a>
<a className={dropdownVolumeButtonClass} onClick={this.handleVolumeDropdown}><span className="icon icon-folder-1"></span> <span className="content">Volumes</span> <span className="icon-dropdown icon icon-arrow-37"></span></a>
</div>
<div className="action">
<a className={buttonClass} onClick={this.handleRestart}><span className="icon icon-refresh"></span> <span className="content">Restart</span></a>
@ -327,6 +455,24 @@ var ContainerDetails = React.createClass({
<a className={gearButtonClass} onClick={this.showSettings}><span className="icon icon-setting-gear"></span></a>
</div>
</div>
<Popover className={popoverViewClasses} placement="bottom">
<div className="table ports">
<div className="table-labels">
<div className="label-left">DOCKER PORT</div>
<div className="label-right">MAC PORT</div>
</div>
{ports}
</div>
</Popover>
<Popover className={popoverVolumeClasses} placement="bottom">
<div className="table volumes">
<div className="table-labels">
<div className="label-left">DOCKER FOLDER</div>
<div className="label-right">MAC FOLDER</div>
</div>
{volumes}
</div>
</Popover>
</div>
{body}
</div>

View File

@ -41,6 +41,10 @@ var ContainerModal = React.createClass({
this._searchRequest = null;
}
if (!query.length) {
return;
}
this.setState({
loading: true
});
@ -227,6 +231,7 @@ var ContainerModal = React.createClass({
<div className={magnifierClasses}></div>
<RetinaImage className={loadingClasses} src="loading.png"/>
</div>
{question}
<div className="results">
<div className="title">{title}</div>
{results}

View File

@ -1,5 +1,6 @@
var EventEmitter = require('events').EventEmitter;
var async = require('async');
var path = require('path');
var assign = require('object-assign');
var Stream = require('stream');
var Convert = require('ansi-to-html');
@ -15,7 +16,9 @@ var _recommended = [];
var _containers = {};
var _progress = {};
var _logs = {};
var _streams = {};
var _muted = {};
var _config = {};
var ContainerStore = assign(EventEmitter.prototype, {
CLIENT_CONTAINER_EVENT: 'client_container',
@ -110,30 +113,44 @@ var ContainerStore = assign(EventEmitter.prototype, {
_createContainer: function (name, containerData, callback) {
var existing = docker.client().getContainer(name);
var self = this;
if (!containerData.name) containerData.name = containerData.Name;
if (!containerData.name && containerData.Name) {
containerData.name = containerData.Name;
} else if (!containerData.name) {
containerData.name = name;
}
if (containerData.Config && containerData.Config.Image) {
containerData.Image = containerData.Config.Image;
}
existing.kill(function (err, data) {
existing.remove(function (err, data) {
docker.client().createContainer(containerData, function (err, container) {
if (err) {
callback(err, null);
return;
}
if (containerData.State && !containerData.State.Running) {
self.fetchContainer(name, callback);
} else {
container.start({
PublishAllPorts: true
}, function (err) {
if (err) {
callback(err);
return;
}
self.fetchContainer(name, callback);
docker.client().getImage(containerData.Image).inspect(function (err, data) {
var binds = [];
if (data.Config.Volumes) {
_.each(data.Config.Volumes, function (value, key) {
binds.push(path.join(process.env.HOME, 'Kitematic', containerData.name, key)+ ':' + key);
});
}
docker.client().createContainer(containerData, function (err, container) {
if (err) {
callback(err, null);
return;
}
if (containerData.State && !containerData.State.Running) {
self.fetchContainer(containerData.name, callback);
} else {
container.start({
PublishAllPorts: true,
Binds: binds
}, function (err) {
if (err) {
callback(err);
return;
}
self.fetchContainer(containerData.name, callback);
});
}
});
});
});
});
@ -209,10 +226,13 @@ var ContainerStore = assign(EventEmitter.prototype, {
// If the event is delete, remove the container
if (data.status === 'destroy') {
var container = _.findWhere(_.values(_containers), {Id: data.id});
if (!container || _muted[container.Name]) {
if (!container) {
return;
}
delete _containers[container.Name];
if (_muted[container.Name]) {
return;
}
this.emit(this.SERVER_CONTAINER_EVENT, container.Name, data.status);
} else {
this.fetchContainer(data.id, function (err) {
@ -256,10 +276,13 @@ var ContainerStore = assign(EventEmitter.prototype, {
container.State.Downloading = !!env.KITEMATIC_DOWNLOADING;
container.KitematicDownloadingImage = env.KITEMATIC_DOWNLOADING_IMAGE;
this.fetchLogs(container.Name, function (err) {
}.bind(this));
_containers[container.Name] = container;
callback(null, container);
}
});
}.bind(this));
},
fetchAllContainers: function (callback) {
var self = this;
@ -305,57 +328,45 @@ var ContainerStore = assign(EventEmitter.prototype, {
});
},
fetchLogs: function (name, callback) {
if (_logs[name]) {
callback();
} else {
_logs[name] = [];
}
var index = 0;
var self = this;
docker.client().getContainer(name).logs({
follow: false,
follow: true,
stdout: true,
stderr: true,
timestamps: true
}, function (err, stream) {
callback(err);
if (_streams[name]) {
return;
}
_streams[name] = stream;
if (err) {
return;
}
_logs[name] = [];
stream.setEncoding('utf8');
var timeout;
stream.on('data', function (buf) {
// Every other message is a header
if (index % 2 === 1) {
var time = buf.substr(0,buf.indexOf(' '));
var msg = buf.substr(buf.indexOf(' ')+1);
if (timeout) {
clearTimeout(timeout);
timeout = null;
}
timeout = setTimeout(function () {
timeout = null;
self.emit(self.SERVER_LOGS_EVENT, name);
}, 100);
_logs[name].push(convert.toHtml(self._escapeHTML(msg)));
}
index += 1;
});
stream.on('end', function (buf) {
self.emit(self.SERVER_LOGS_EVENT, name);
callback();
docker.client().getContainer(name).logs({
follow: true,
stdout: true,
stderr: true,
timestamps: true,
tail: 0
}, function (err, stream) {
if (err) {
return;
}
stream.setEncoding('utf8');
stream.on('data', function (buf) {
// Every other message is a header
if (index % 2 === 1) {
var time = buf.substr(0,buf.indexOf(' '));
var msg = buf.substr(buf.indexOf(' ')+1);
_logs[name].push(convert.toHtml(self._escapeHTML(msg)));
self.emit(self.SERVER_LOGS_EVENT, name);
}
index += 1;
});
});
stream.on('end', function () {
delete _streams[name];
console.log('end', name);
});
});
},
@ -395,11 +406,15 @@ var ContainerStore = assign(EventEmitter.prototype, {
}
});
},
updateContainer: function (name, data) {
updateContainer: function (name, data, callback) {
_muted[name] = true;
if (!data.name) {
data.name = data.Name;
}
var fullData = assign(_containers[name], data);
console.log(fullData);
this._createContainer(name, fullData, function (err) {
this.emit(this.CLIENT_CONTAINER_EVENT, name);
callback(err);
_muted[name] = false;
}.bind(this));
},

View File

@ -1,4 +1,5 @@
var _ = require('underscore');
var docker = require('./docker');
var ContainerUtil = {
env: function (container) {
@ -10,6 +11,26 @@ var ContainerUtil = {
var splits = [env.slice(0, i), env.slice(i + 1)];
return splits;
}));
},
ports: function (container, callback) {
var res = {};
var ip = docker.host;
console.log(container);
_.each(container.NetworkSettings.Ports, function (value, key) {
var dockerPort = key;
var localUrl = null;
var localUrlDisplay = null;
if (value && value.length) {
var port = value[0].HostPort;
localUrl = 'http://' + ip + ':' + port;
localUrlDisplay = ip + ': ' + port;
}
res[dockerPort] = {
url: localUrl,
display: localUrlDisplay
};
});
return res;
}
};

View File

@ -125,7 +125,7 @@ var setupSteps = [
boot2docker.ip(function (err, ip) {
if (err) {callback(err); return;}
console.log('Setting host IP to: ' + ip);
// Docker.setHost(ip);
docker.setHost(ip);
callback(err);
});
},
@ -147,6 +147,8 @@ var Setup = React.createClass({
radial = <Radial progress={this.state.progress}/>;
} else if (this.state.error) {
radial = <Radial error={true} spin="true" progress="100"/>;
} else {
radial = <Radial spin="true" progress="100"/>;
}
if (this.state.error) {
return (

View File

@ -1,7 +1,7 @@
.create-modal {
@modal-padding: 32px;
@search-width: 372px;
@custom-width: 270px;
@custom-width: 0;
.modal-dialog {
margin-top: 80px;
width: calc(@modal-padding + @search-width + 2 * @modal-padding + @custom-width);
@ -20,12 +20,6 @@
flex-direction: row;
padding: 32px 32px;
aside.custom {
flex: 0 auto;
padding-left: 32px;
min-width: 270px;
}
.popover {
width: 180px;
text-align: center;
@ -62,12 +56,8 @@
section.search {
min-width: 404px;
padding-right: 32px;
border-right: 1px solid #eee;
.question {
color: @gray-lightest;
font-size: 10px;
text-align: right;
}

View File

@ -1,4 +1,119 @@
.popover {
&.popover-view {
min-width: 290px;
}
&.popover-volume {
min-width: 400px;
}
.popover-content {
display: flex;
flex-direction: column;
padding: 14px 14px 20px;
.table {
margin-bottom: 0;
.icon-arrow-right {
color: #aaa;
margin: 2px 9px 0;
flex: 0 auto;
min-width: 13px;
}
.btn {
min-width: 22px;
margin-left: 10px;
}
.table-labels {
flex: 1 auto;
display: flex;
font-size: 12px;
color: @gray-lightest;
.label-left {
flex: 0 auto;
min-width: 80px;
margin-right: 30px;
text-align: right;
}
.label-right {
flex: 1 auto;
display: inline-block;
width: 40%;
}
}
.table-values {
flex: 1 auto;
display: flex;
flex-direction: row;
margin: 8px 0;
.value-left {
text-align: right;
min-width: 80px;
flex: 0 auto;
}
.value-right {
flex: 1 auto;
-webkit-user-select: text;
width: 154px;
}
}
.table-new {
margin-top: 10px;
flex: 1 auto;
display: flex;
input {
padding: 0;
font-weight: 400;
}
input.new-left {
flex: 0 auto;
text-align: right;
min-width: 80px;
max-width: 80px;
}
.new-right-wrapper {
position: relative;
display: flex;
flex: 1 auto;
.new-right-placeholder {
position: absolute;
top: 3px;
left: 0;
font-weight: 400;
}
input.new-right {
flex: 1 auto;
height: 24px;
position :relative;
padding-left: 107px;
}
}
}
&.volumes {
.label-left {
min-width: 120px;
}
.value-left {
min-width: 120px;
}
.icon {
color: #aaa;
margin: 2px 9px 0;
}
}
}
.question {
margin: 12px 6px 6px;
}
}
}
.containers {
box-sizing: border-box;
height: 100%;
display: flex;
flex-direction: column;
@ -349,6 +464,13 @@
}
}
.container-name {
margin-bottom: 20px;
input {
width: 20%;
}
}
.env-vars-labels {
width: 100%;
font-size: 12px;

View File

@ -48,6 +48,11 @@ html, body {
}
}
.question {
color: @gray-lightest;
font-size: 10px;
}
.popover {
font-family: 'Clear Sans', sans-serif;
color: @gray-normal;

View File

@ -17,6 +17,7 @@ if (argv.test) {
}
process.env.NODE_PATH = __dirname + '/../node_modules';
process.chdir(path.join(__dirname, '..'));
app.on('activate-with-no-open-windows', function () {
if (mainWindow) {
@ -98,7 +99,6 @@ app.on('ready', function() {
});
ipc.on('vm', function (event, arg) {
console.log('save vm', arg);
saveVMOnQuit = arg;
});