From 2aba2351f5eaccf5b53b0422bb73d4355a73298f Mon Sep 17 00:00:00 2001 From: Vincent Fiduccia Date: Wed, 3 Jun 2015 18:30:25 -0700 Subject: [PATCH] Add scheduler rules on containers and services --- app/components/labels-section/component.js | 3 +- app/components/radio-button/component.js | 4 +- .../scheduling-rule-row/component.js | 252 ++++++++++++++++++ .../scheduling-rule-row/template.hbs | 125 +++++++++ app/containers/new/view.js | 1 + app/mixins/edit-container.js | 41 ++- app/mixins/edit-labels.js | 13 +- app/mixins/edit-service.js | 19 +- app/mixins/read-labels.js | 3 +- app/service/model.js | 9 +- app/service/new/template.hbs | 4 +- app/service/new/view.js | 1 + app/styles/bootstrap-tweak.scss | 7 + app/styles/layout.scss | 2 +- app/templates/container/edit-name.hbs | 2 +- app/templates/container/edit-services.hbs | 6 +- app/templates/container/new-advanced.hbs | 9 +- app/templates/container/new-command.hbs | 2 +- app/templates/container/new-image.hbs | 2 +- app/templates/container/new-scheduling.hbs | 61 +++++ app/templates/container/new-security.hbs | 25 +- app/templates/edit-labels.hbs | 2 +- app/utils/constants.js | 4 + package.json | 3 +- 24 files changed, 550 insertions(+), 50 deletions(-) create mode 100644 app/components/scheduling-rule-row/component.js create mode 100644 app/components/scheduling-rule-row/template.hbs diff --git a/app/components/labels-section/component.js b/app/components/labels-section/component.js index 53b699833..6758d741d 100644 --- a/app/components/labels-section/component.js +++ b/app/components/labels-section/component.js @@ -1,4 +1,5 @@ import Ember from 'ember'; +import C from 'ui/utils/constants'; export default Ember.Component.extend({ model: null, @@ -8,7 +9,7 @@ export default Ember.Component.extend({ var obj = this.get('model')||{}; var keys = Ember.keys(obj); keys.forEach(function(key) { - var isUser = key.indexOf('io.rancher') !== 0; + var isUser = key.indexOf(C.LABEL.SYSTEM_PREFIX) !== 0; out.push(Ember.Object.create({ key: key, value: obj[key], diff --git a/app/components/radio-button/component.js b/app/components/radio-button/component.js index a319174c0..c3c45316d 100644 --- a/app/components/radio-button/component.js +++ b/app/components/radio-button/component.js @@ -4,10 +4,10 @@ export default Ember.Component.extend({ tagName: 'input', type: 'radio', disabled: false, - attributeBindings: ['name', 'type', 'value', 'checked:checked', 'disabled:disabled'], + attributeBindings: ['name', 'type', 'checked:checked', 'disabled:disabled'], click : function() { - this.set('selection', this.$().val()); + this.set('selection', this.get('value')); }, checked : function() { diff --git a/app/components/scheduling-rule-row/component.js b/app/components/scheduling-rule-row/component.js new file mode 100644 index 000000000..7244cb222 --- /dev/null +++ b/app/components/scheduling-rule-row/component.js @@ -0,0 +1,252 @@ +import Ember from 'ember'; +import C from 'ui/utils/constants'; + +function splitEquals(str) { + var idx = str.indexOf('='); + if ( idx === -1 ) + { + return null; + } + + return [ str.substr(0,idx) , str.substr(idx+1) ]; +} + +export default Ember.Component.extend({ + rule: null, + instance: null, + allHosts: null, + + tagName: 'TR', + + kind: null, + suffix: null, + userKey: null, + userValue: null, + + actions: { + setKey: function(key) { + this.set('userKey', key); + }, + + setValue: function(value) { + this.set('userValue', value); + }, + + remove: function() { + this.sendAction('remove', this.get('rule')); + } + }, + + init: function() { + this._super(); + + var key = this.get('rule.key')||''; + var value = this.get('rule.value')||''; + var splitValue = splitEquals(value); + + var match = key.match(/((_soft)?(_ne)?)$/); + if ( match ) + { + this.set('suffix', match[1]); + key = key.substr(0, key.length - match[1].length); + } + else + { + this.set('suffix',''); + } + + // Convert from an existing key into the 4 fields + switch ( key ) + { + case C.LABEL.SCHED_CONTAINER: + this.setProperties({ + kind: 'container_name', + userKey: '', + userValue: value, + }); + break; + case C.LABEL.SCHED_CONTAINER_LABEL: + if ( splitValue[0] === C.LABEL.SERVICE_NAME ) + { + this.setProperties({ + kind: 'service_name', + userKey: '', + userValue: splitValue[1], + }); + } + else + { + this.setProperties({ + kind: 'container_label', + userKey: splitValue[0], + userValue: splitValue[1], + }); + } + break; + case C.LABEL.SCHED_HOST_LABEL: + this.setProperties({ + kind: 'host_label', + userKey: splitValue[0], + userValue: splitValue[1], + }); + break; + } + }, + + valuesChanged: function() { + var key = ''; + var value = null; + + var userKey = this.get('userKey')||''; + var userValue = this.get('userValue')||''; + + switch ( this.get('kind') ) + { + case 'host_label': + key = C.LABEL.SCHED_HOST_LABEL; + if ( userKey && userValue ) + { + value = encodeURIComponent(userKey) + '=' + encodeURIComponent(userValue); + } + break; + case 'container_label': + key = C.LABEL.SCHED_CONTAINER_LABEL; + if ( userKey && userValue ) + { + value = encodeURIComponent(userKey) + '=' + encodeURIComponent(userValue); + } + break; + case 'container_name': + key = C.LABEL.SCHED_CONTAINER; + if ( userValue ) + { + value = encodeURIComponent(userValue); + } + break; + case 'service_name': + key = C.LABEL.SCHED_CONTAINER_LABEL; + if ( userValue ) + { + value = encodeURIComponent(C.LABEL.SERVICE_NAME) + '=' + encodeURIComponent(userValue); + } + break; + } + + key += this.get('suffix'); + + Ember.setProperties(this.get('rule'),{ + key: key, + value: value + }); + }.observes('kind','suffix','userKey','userValue'), + + schedulingRuleSuffixChoices: [ + {label: 'must', value: ''}, + {label: 'should', value: '_soft'}, + {label: 'should not', value: '_soft_ne'}, + {label: 'must not', value: '_ne'}, + ], + + schedulingRuleKindChoices: [ + {label: 'host label', value: 'host_label'}, + {label: 'container with label', value: 'container_label'}, + {label: 'service with the name', value: 'service_name'}, + {label: 'container with the name', value: 'container_name'}, + ], + + hostLabelKeyChoices: function() { + var out = []; + this.get('allHosts').forEach((host) => { + var keys = Ember.keys(host.get('labels')||{}).filter((key) => { + return key.indexOf(C.LABEL.SYSTEM_PREFIX) !== 0; + }); + out.pushObjects(keys); + }); + + return out.sort().uniq(); + }.property('allHosts.@each.labels'), + + hostLabelValueChoices: function() { + var key = this.get('userKey'); + + var out = []; + this.get('allHosts').forEach((host) => { + var label = (host.get('labels')||{})[key]; + if ( label ) + { + var parts = label.split(/\s*,\s*/); + out.pushObjects(parts); + } + }); + + return out.sort().uniq(); + }.property('userKey','allHosts.@each.labels'), + + allContainers: function() { + var out = []; + this.get('allHosts').map((host) => { + var containers = (host.get('instances')||[]).filter(function(instance) { + return instance.get('kind') === 'container' && + !instance.get('systemContainer'); + }); + + out.pushObjects(containers); + }); + + return out.sortBy('name','id').uniq(); + }.property('allHosts.@each.instancesUpdated'), + + containerLabelKeyChoices: function() { + var out = []; + this.get('allContainers').forEach((container) => { + var keys = Ember.keys(container.get('labels')||{}).filter((key) => { + return key.indexOf(C.LABEL.SYSTEM_PREFIX) !== 0; + }); + out.pushObjects(keys); + }); + + return out.sort().uniq(); + }.property('allContainers.@each.labels'), + + containerLabelValueChoices: function() { + var key = this.get('userKey'); + var out = []; + this.get('allContainers').forEach((container) => { + var label = (container.get('labels')||{})[key]; + if ( label ) + { + var parts = label.split(/\s*,\s*/); + out.pushObjects(parts); + } + }); + + return out.sort().uniq(); + }.property('userKey','allContainers.@each.labels'), + + containerValueChoices: function() { + var out = []; + this.get('allContainers').forEach((container) => { + var name = container.get('name'); + if ( name ) + { + out.push(name); + } + }); + + return out.sort().uniq(); + }.property('allContainers.@each.name'), + + serviceValueChoices: function() { + var out = []; + this.get('allContainers').forEach((container) => { + var label = (container.get('labels')||{})[C.LABEL.SERVICE_NAME]; + if ( label ) + { + var parts = label.split(/\s*,\s*/); + out.pushObjects(parts); + } + }); + + return out.sort().uniq(); + }.property('allContainers.@each.labels'), +}); diff --git a/app/components/scheduling-rule-row/template.hbs b/app/components/scheduling-rule-row/template.hbs new file mode 100644 index 000000000..e020da7b9 --- /dev/null +++ b/app/components/scheduling-rule-row/template.hbs @@ -0,0 +1,125 @@ +
The host
+ + {{view "select" + class="form-control input-sm" + content=schedulingRuleSuffixChoices + value=suffix + optionValuePath="content.value" + optionLabelPath="content.label" + }} + +
have a
+ + {{view "select" + class="form-control input-sm" + content=schedulingRuleKindChoices + value=kind + optionValuePath="content.value" + optionLabelPath="content.label" + }} + + + {{#if (not (or (eq kind "service_name") (eq kind "container_name")))}} +
of
+ {{/if}} + + + {{#if (eq kind "host_label")}} +
+ {{input type="text" class="form-control input-sm" value=userKey}} +
+ + +
+
+ {{/if}} + {{#if (eq kind "container_label")}} +
+ {{input type="text" class="form-control input-sm" value=userKey}} +
+ + +
+
+ {{/if}} + + + {{#if (not (or (eq kind "service_name") (eq kind "container_name")))}} +
=
+ {{/if}} + + + {{#if (eq kind "host_label")}} +
+ {{input type="text" class="form-control input-sm" value=userValue}} + {{#if hostLabelValueChoices.length}} +
+ + +
+ {{/if}} +
+ {{/if}} + {{#if (eq kind "container_label")}} +
+ {{input type="text" class="form-control input-sm" value=userValue}} + {{#if containerLabelValueChoices.length}} +
+ + +
+ {{/if}} +
+ {{/if}} + {{#if (eq kind "service_name")}} +
+ {{input type="text" class="form-control input-sm" value=userValue}} + {{#if serviceValueChoices.length}} +
+ + +
+ {{/if}} +
+ {{/if}} + {{#if (eq kind "container_name")}} +
+ {{input type="text" class="form-control input-sm" value=userValue}} + {{#if containerValueChoices.length}} +
+ + +
+ {{/if}} +
+ {{/if}} + + +
+ +
+ diff --git a/app/containers/new/view.js b/app/containers/new/view.js index 453eb841b..07b09096d 100644 --- a/app/containers/new/view.js +++ b/app/containers/new/view.js @@ -12,6 +12,7 @@ export default Ember.View.extend({ addDnsSearch: addAction('addDnsSearch', '.dns-search-value'), addDevice: addAction('addDevice', '.device-host'), addLabel: addAction('addLabel', '.label-key'), + addSchedulingRule: addAction('addSchedulingRule', '.schedule-rule'), selectTab: function(name) { this.set('context.tab',name); diff --git a/app/mixins/edit-container.js b/app/mixins/edit-container.js index 3f36e5566..60ef35203 100644 --- a/app/mixins/edit-container.js +++ b/app/mixins/edit-container.js @@ -131,7 +131,15 @@ export default Ember.Mixin.create(Cattle.NewOrEditMixin, EditHealthCheck, EditLa ary.removeObject(item); } }); - } + }, + + addSchedulingRule: function() { + this.send('addSystemLabel'); + }, + + removeSchedulingRule: function(obj) { + this.send('removeLabel', obj); + }, }, // ---------------------------------- @@ -161,6 +169,7 @@ export default Ember.Mixin.create(Cattle.NewOrEditMixin, EditHealthCheck, EditLa this.initMemory(); this.initLabels(); this.initHealthCheck(); + this.initScheduling(); } }, @@ -835,6 +844,36 @@ export default Ember.Mixin.create(Cattle.NewOrEditMixin, EditHealthCheck, EditLa }.observes('strEntryPoint'), // ---------------------------------- + // Scheduling + // ---------------------------------- + initScheduling: function() { + if ( this.get('instance.requestedHostId') ) + { + this.set('isRequestedHost',true); + } + else + { + this.set('isRequestedHost',false); + } + + // @TODO import existing for clone + }, + + isRequestedHost: null, + isRequestedHostDidChange: function() { + if ( this.get('isRequestedHost') ) + { + if ( !this.get('instance.requestedHostId') ) + { + this.set('instance.requestedHostId', this.get('hostChoices.firstObject.id')); + } + } + else + { + this.set('instance.requestedHostId', null); + } + }.observes('isRequestedHost'), + // ---------------------------------- // Save // ---------------------------------- diff --git a/app/mixins/edit-labels.js b/app/mixins/edit-labels.js index 44f37b4ac..1e9933622 100644 --- a/app/mixins/edit-labels.js +++ b/app/mixins/edit-labels.js @@ -1,4 +1,5 @@ import Ember from 'ember'; +import C from 'ui/utils/constants'; export default Ember.Mixin.create({ labelResource: Ember.computed.alias('primaryResource'), @@ -82,6 +83,10 @@ export default Ember.Mixin.create({ return (this.get('labelArray')||[]).filterProperty('isUser',true); }.property('labelArray.@each.isUser'), + systemLabelArray: function() { + return (this.get('labelArray')||[]).filterProperty('isUser',false); + }.property('labelArray.@each.isUser'), + initFields: function() { this._super(); this.initLabels(); @@ -95,7 +100,7 @@ export default Ember.Mixin.create({ out.push(Ember.Object.create({ key: key, value: obj[key], - isUser: key.indexOf('io.rancher') !== 0, + isUser: key.indexOf(C.LABEL.SYSTEM_PREFIX) !== 0, })); }); @@ -108,7 +113,11 @@ export default Ember.Mixin.create({ this.get('labelArray').forEach(function(row) { if ( row.key ) { - out[row.key] = row.value; + // System labels have to have a value before they're added, users ones can be just key. + if ( row.isUser || row.value ) + { + out[row.key] = row.value; + } } }); this.set('labelResource.labels', out); diff --git a/app/mixins/edit-service.js b/app/mixins/edit-service.js index 3962aaad8..2e2d48e4f 100644 --- a/app/mixins/edit-service.js +++ b/app/mixins/edit-service.js @@ -77,18 +77,22 @@ export default Ember.Mixin.create(EditLabels, { // ---------------------------------- // Scheduling // ---------------------------------- - isScalePlural: Ember.computed.gt('service.scale', 1), - isGlobalStr: null, - isGlobal: Ember.computed.equal('isGlobalStr','yes'), + isGlobal: null, initScheduling: function() { var existing = this.getLabel(C.LABEL.SCHED_GLOBAL); - this.set('isGlobalStr', (!!existing ? 'yes' : 'no')); + this.set('isGlobal', !!existing); + this._super(); + if ( this.get('isRequestedHost') ) + { + this.set('isGlobal', false); + } }, globalDidChange: function() { if ( this.get('isGlobal') ) { this.setLabel(C.LABEL.SCHED_GLOBAL,'true'); + this.set('isRequestedHost', false); } else { @@ -96,6 +100,13 @@ export default Ember.Mixin.create(EditLabels, { } }.observes('isGlobal'), + isRequestedHostDidChangeGlobal: function() { + if ( this.get('isRequestedHost') ) + { + this.set('isGlobal', false); + } + }.observes('isRequestedHost'), + // ---------------------------------- // Save // ---------------------------------- diff --git a/app/mixins/read-labels.js b/app/mixins/read-labels.js index 0608894b2..e0f3c763a 100644 --- a/app/mixins/read-labels.js +++ b/app/mixins/read-labels.js @@ -1,4 +1,5 @@ import Ember from 'ember'; +import C from 'ui/utils/constants'; export default Ember.Mixin.create({ labelResource: null, @@ -8,7 +9,7 @@ export default Ember.Mixin.create({ var obj = this.get('labelResource.labels')||{}; var keys = Ember.keys(obj).sort(); keys.forEach(function(key) { - var isUser = key.indexOf('io.rancher') !== 0; + var isUser = key.indexOf(C.LABEL.SYSTEM_PREFIX) !== 0; out.push(Ember.Object.create({ key: key, value: obj[key], diff --git a/app/service/model.js b/app/service/model.js index 25e88ddc4..106b67ad9 100644 --- a/app/service/model.js +++ b/app/service/model.js @@ -1,4 +1,5 @@ import Cattle from 'ui/utils/cattle'; +import C from 'ui/utils/constants'; var Service = Cattle.TransitioningResource.extend({ type: 'service', @@ -9,9 +10,12 @@ var Service = Cattle.TransitioningResource.extend({ }.observes('consumedservices.@each.{id,name,state}'), healthState: function() { + var isGlobal = Object.keys(this.get('labels')||{}).indexOf(C.LABEL.SCHED_GLOBAL) >= 0; + var instances = this.get('instances')||[]; + // Get the state of each instance var healthy = 0; - (this.get('instances')||[]).forEach((instance) => { + instances.forEach((instance) => { var resource = instance.get('state'); var health = instance.get('healthState'); @@ -21,7 +25,7 @@ var Service = Cattle.TransitioningResource.extend({ } }); - if ( healthy >= this.get('scale') ) + if ( (isGlobal && healthy >= instances.get('length')) || (!isGlobal && healthy >= this.get('scale')) ) { return 'healthy'; } @@ -34,6 +38,7 @@ var Service = Cattle.TransitioningResource.extend({ combinedState: function() { var service = this.get('state'); var health = this.get('healthState'); + if ( ['active','updating-active'].indexOf(service) === -1 ) { // If the service isn't active, return its state diff --git a/app/service/new/template.hbs b/app/service/new/template.hbs index 7a11d7bd9..f483735f5 100644 --- a/app/service/new/template.hbs +++ b/app/service/new/template.hbs @@ -10,7 +10,7 @@
- +
@@ -20,7 +20,7 @@
- +
diff --git a/app/service/new/view.js b/app/service/new/view.js index ac6262034..d1013e926 100644 --- a/app/service/new/view.js +++ b/app/service/new/view.js @@ -5,5 +5,6 @@ export default NewContainerView.extend({ actions: { addVolumeFromService: addAction('addVolumeFromService', '.volumefromservice-container'), addServiceLink: addAction('addServiceLink', '.service-link'), + addSchedulingRule: addAction('addSchedulingRule', '.schedule-rule'), }, }); diff --git a/app/styles/bootstrap-tweak.scss b/app/styles/bootstrap-tweak.scss index a0d1033d5..eba489d46 100644 --- a/app/styles/bootstrap-tweak.scss +++ b/app/styles/bootstrap-tweak.scss @@ -174,6 +174,8 @@ fieldset[disabled] .btn { } .table { + margin-bottom: 0; + & > THEAD > TR > TH, & > THEAD > TR > TD, & > TBODY > TR > TH, @@ -418,3 +420,8 @@ FORM LABEL, .help-block { color: #444; } + +.form-group { + padding-bottom: 15px; + margin-bottom: 0; +} diff --git a/app/styles/layout.scss b/app/styles/layout.scss index 4e1268264..5d7b8486a 100644 --- a/app/styles/layout.scss +++ b/app/styles/layout.scss @@ -631,7 +631,7 @@ ASIDE { } HR { - margin: 0 0 15px 0; + margin: 8px 0 8px 0; } } diff --git a/app/templates/container/edit-name.hbs b/app/templates/container/edit-name.hbs index 8a55c52e3..20336dba7 100644 --- a/app/templates/container/edit-name.hbs +++ b/app/templates/container/edit-name.hbs @@ -7,7 +7,7 @@
-
+
diff --git a/app/templates/container/edit-services.hbs b/app/templates/container/edit-services.hbs index a7c4428a8..db47d82a1 100644 --- a/app/templates/container/edit-services.hbs +++ b/app/templates/container/edit-services.hbs @@ -1,10 +1,10 @@ -
+
- +
-
+
{{#if serviceChoices.length}} {{else}} diff --git a/app/templates/container/new-advanced.hbs b/app/templates/container/new-advanced.hbs index 856de2af0..2d3be5ac4 100644 --- a/app/templates/container/new-advanced.hbs +++ b/app/templates/container/new-advanced.hbs @@ -11,6 +11,7 @@ + @@ -46,11 +47,9 @@
- {{#if isService}} -
- {{partial "container/new-scheduling"}} -
- {{/if}} +
+ {{partial "container/new-scheduling"}} +
diff --git a/app/templates/container/new-command.hbs b/app/templates/container/new-command.hbs index bd20a3a01..224b23e47 100644 --- a/app/templates/container/new-command.hbs +++ b/app/templates/container/new-command.hbs @@ -120,7 +120,7 @@ {{/each}} -
+
ProTip: Paste one or more lines of name=value pairs into any name field for easy bulk entry.
{{/if}} diff --git a/app/templates/container/new-image.hbs b/app/templates/container/new-image.hbs index 3079fa74a..239fadca8 100644 --- a/app/templates/container/new-image.hbs +++ b/app/templates/container/new-image.hbs @@ -1,4 +1,4 @@ -
+
diff --git a/app/templates/container/new-scheduling.hbs b/app/templates/container/new-scheduling.hbs index e69de29bb..df8fb606b 100644 --- a/app/templates/container/new-scheduling.hbs +++ b/app/templates/container/new-scheduling.hbs @@ -0,0 +1,61 @@ +
+
+ + {{#if isRequestedHost}}: + {{view "select" + class="form-control" + content=hostChoices + value=instance.requestedHostId + optionValuePath="content.id" + optionLabelPath="content.name" + disabled=(not isRequestedHost) + style="display: inline-block; width: auto;" + }} + {{/if}} +
+ +
+ +
+ + {{#unless isRequestedHost}} +
+ +
+ {{#if systemLabelArray.length}} + + + + + + + + + + + + + + + + {{#each rule in labelArray}} + {{#unless rule.isUser}} + {{scheduling-rule-row rule=rule allHosts=allHosts remove="removeSchedulingRule"}} + {{/unless}} + {{/each}} + +
ConditionFieldKeyValue
+ {{/if}} + {{/unless}} +
diff --git a/app/templates/container/new-security.hbs b/app/templates/container/new-security.hbs index 480f7b65c..eaea2e19e 100644 --- a/app/templates/container/new-security.hbs +++ b/app/templates/container/new-security.hbs @@ -1,16 +1,11 @@
- +
- {{view "select" - prompt="Automatically pick a host" - class="form-control" - content=hostChoices - value=instance.requestedHostId - optionValuePath="content.id" - optionLabelPath="content.name" - }} +
+ +
@@ -41,18 +36,6 @@
-
-
- -
- -
-
- -
-
-
-
diff --git a/app/templates/edit-labels.hbs b/app/templates/edit-labels.hbs index 4cad0bca0..ad6cf88ff 100644 --- a/app/templates/edit-labels.hbs +++ b/app/templates/edit-labels.hbs @@ -30,7 +30,7 @@ {{/each}} -
+
ProTip: Paste one or more lines of key=value pairs into any key field for easy bulk entry.
{{/if}} diff --git a/app/utils/constants.js b/app/utils/constants.js index 102804b04..9f08a917d 100644 --- a/app/utils/constants.js +++ b/app/utils/constants.js @@ -75,7 +75,11 @@ export default { }, LABEL: { + SYSTEM_PREFIX: 'io.rancher.', SERVICE_NAME: 'io.rancher.service.name', SCHED_GLOBAL: 'io.rancher.scheduler.global', + SCHED_CONTAINER: 'io.rancher.scheduler.affinity:container', + SCHED_HOST_LABEL: 'io.rancher.scheduler.affinity:host_label', + SCHED_CONTAINER_LABEL: 'io.rancher.scheduler.affinity:container_label', }, }; diff --git a/package.json b/package.json index 695e50672..30f69b699 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ui", - "version": "0.23.0-rc1", + "version": "0.23.0-rc4", "private": true, "directories": { "doc": "doc", @@ -38,6 +38,7 @@ "ember-cli-uglify": "1.0.1", "ember-export-application-global": "^1.0.2", "ember-inline-svg": "^0.1.2", + "ember-truth-helpers": "0.0.5", "express": "^4.8.5", "forever-agent": "^0.5.2", "glob": "^5.0.3",