Advanced LB service toggle, new port format

This commit is contained in:
Vincent Fiduccia 2015-07-22 01:01:19 -07:00
parent 3f4fbeff82
commit fb9cef34aa
20 changed files with 272 additions and 209 deletions

View File

@ -0,0 +1,15 @@
import Ember from 'ember';
export default Ember.Component.extend({
tgt: null,
targetChoices: null,
isAdvanced: null,
tagName: 'TR',
actions: {
remove: function() {
this.sendAction('remove', this.get('tgt'));
},
}
});

View File

@ -0,0 +1,38 @@
{{#if isAdvanced}}
<td>
{{input classNames="form-control input-sm" value=tgt.hostname placeholder="e.g. svc.com"}}
</td>
<td class="text-center">
<div class="form-control-static input-sm">:</div>
</td>
<td>
{{input classNames="form-control input-sm" value=tgt.srcPort placeholder="e.g. 80"}}
</td>
<td>&nbsp;</td>
<td>
{{input classNames="form-control input-sm" value=tgt.path placeholder="e.g. /svc"}}
</td>
<td class="text-center">
<div class="form-control-static input-sm"><i class="ss-right"></i></div>
</td>
{{/if}}
<td>
{{display-name-select
classNames="form-control input-sm lb-target"
prompt="Select a service..."
value=tgt.value
content=targetChoices
optionValuePath="content.id"
optionLabelPath="content.name"
optionGroupPath="group"
}}
</td>
{{#if isAdvanced}}
<td>&nbsp;</td>
<td>
{{input classNames="form-control input-sm" value=tgt.dstPort placeholder="e.g. 8080"}}
</td>
{{/if}}
<td class="text-right">
<button {{action "remove"}} class="btn-circle-x" type="button" tabindex="-1"></button>
</td>

View File

@ -201,7 +201,7 @@ export default Ember.Component.extend({
out.pushObjects(keys);
});
return out.sort().uniq();
return out.map((key) => { return (key||'').toLowerCase(); }).sort().uniq();
}.property('allHosts.@each.labels'),
hostLabelValueChoices: function() {
@ -217,7 +217,7 @@ export default Ember.Component.extend({
}
});
return out.sort().uniq();
return out.map((key) => { return (key||'').toLowerCase(); }).sort().uniq();
}.property('userKey','allHosts.@each.labels'),
allContainers: function() {
@ -243,7 +243,7 @@ export default Ember.Component.extend({
out.pushObjects(keys);
});
return out.sort().uniq();
return out.map((key) => { return (key||'').toLowerCase(); }).sort().uniq();
}.property('allContainers.@each.labels'),
containerLabelValueChoices: function() {
@ -258,7 +258,7 @@ export default Ember.Component.extend({
}
});
return out.sort().uniq();
return out.map((key) => { return (key||'').toLowerCase(); }).sort().uniq();
}.property('userKey','allContainers.@each.labels'),
containerValueChoices: function() {
@ -271,7 +271,7 @@ export default Ember.Component.extend({
}
});
return out.sort().uniq();
return out.map((key) => { return (key||'').toLowerCase(); }).sort().uniq();
}.property('allContainers.@each.name'),
serviceValueChoices: function() {
@ -285,6 +285,6 @@ export default Ember.Component.extend({
}
});
return out.sort().uniq();
return out.map((key) => { return (key||'').toLowerCase(); }).sort().uniq();
}.property('allContainers.@each.labels'),
});

View File

@ -18,8 +18,8 @@
<span class="sr-only">Toggle Dropdown</span>
</button>
<ul class="dropdown-menu" role="menu">
<li>{{#link-to "service.new-alias" (query-params environmentId=model.id)}}Add Service Alias{{/link-to}}</li>
<li>{{#link-to "service.new-balancer" (query-params environmentId=model.id)}}Add Load Balancer{{/link-to}}</li>
<li>{{#link-to "service.new-alias" (query-params environmentId=model.id)}}Add Service Alias{{/link-to}}</li>
<li>{{#link-to "service.new-external" (query-params environmentId=model.id)}}Add External Service{{/link-to}}</li>
</ul>
</div>

View File

@ -1,105 +1,48 @@
import Ember from 'ember';
// New Format: [hostname][:srcPort][/path]:dstPort
// New Format: [hostname][:srcPort][/path][=dstPort]
// Older format: dstPort:[hostname][/path]
export function parseTarget(str) {
var srcPort = null, dstPort = null, hostname = null, path = null;
var idx;
str = str.trim();
var parts = str.split(':');
if ( parts.length === 2 )
var match;
if ( match = str.match(/^(\d+)$/) )
{
if ( !parts[0].length || !parts[1].length)
{
// Invalid: ":something" or "something:"
return null;
}
if ( parts[0].match(/^[0-9]+$/) )
{
// old: dstPort:[hostname][/path] or new: /path:dstPort
if ( parts[1].match(/^[0-9]+$/) )
{
srcPort = parseInt(parts[0], 10);
dstPort = parseInt(parts[1], 10);
}
else
{
dstPort = parseInt(parts[0], 10);
idx = parts[1].indexOf('/');
if ( idx >= 0 )
{
hostname = parts[1].substr(0,idx) || null;
path = parts[1].substr(idx);
}
else
{
hostname = parts[1];
path = null;
}
}
}
else
{
// new: [hostname][/path]:dstPort or srcPort[/path]:dstPort
dstPort = parseInt(parts[1], 10);
idx = parts[0].indexOf('/');
if ( idx === -1 )
{
srcPort = null;
hostname = parts[0];
path = null;
}
else
{
var begin = parts[0].substr(0,idx);
var end = parts[0].substr(idx);
if ( begin.match(/^[0-9]+$/) )
{
// new: srcPort[/path]:dstPort
hostname = null;
srcPort = parseInt(begin, 10);
path = end;
}
else
{
// new: hostname/path:dstPort
hostname = begin || null;
srcPort = null;
path = end;
}
}
}
// New Format: just a dstPort
hostname = null;
srcPort = null;
path = null;
dstPort = parseInt(match[1], 10);
}
else if ( parts.length === 3)
else if ( match = str.match(/(\d+):([^\/]+)?(\/.*)?$/) )
{
if ( !parts[0].length || !parts[1].length || !parts[2].length)
// Old Format: dstPort[:hostname][/path]
hostname = match[2] || null;
srcPort = null;
path = match[3] || null;
dstPort = parseInt(match[1], 10);
}
else if ( match = str.match(/([^/=:]+)?(:(\d+))?(\/[^=]+)?(=(\d+))?$/) )
{
// New Format: [hostname][:srcPort][/path][=dstPort]
if ( match[1] && match[1].match(/^\d+$/) )
{
// Invalid: ":something" or "something:" or "something::something"
return null;
}
// [hostname]:srcPort[/path]:dstPort
dstPort = parseInt(parts[2], 10);
hostname = parts[0];
idx = parts[1].indexOf('/');
if ( idx === -1 )
{
srcPort = parseInt(parts[1], 10);
path = null;
// It's a port
hostname = null;
srcPort = parseInt(match[1], 10) || null;
}
else
{
srcPort = parseInt(parts[1].substr(0,idx), 10);
path = parts[1].substr(idx);
hostname = match[1] || null;
srcPort = parseInt(match[3], 10) || null;
}
dstPort = parseInt(match[6], 10) || null;
path = match[4] || null;
}
else
{
// Invalid
return null;
}
@ -117,18 +60,24 @@ export function stringifyTarget(tgt) {
var hostname = Ember.get(tgt,'hostname');
var path = Ember.get(tgt,'path');
// New Format: [hostname][:srcPort][/path]:dstPort
if ( hostname || srcPort || path )
// New Format: [hostname][:srcPort][/path][=dstPort]
if ( hostname || path || dstPort )
{
var str = hostname || '';
if ( srcPort )
{
str += (str ? ':' : '') + srcPort;
}
if ( path ) {
str += path;
if ( path )
{
str += (path.substr(0,1) === '/' ? '' : '/') + path;
}
if ( dstPort )
{
str += (str ? '=' : '') + dstPort;
}
str += ':' + dstPort;
return str;
}
@ -140,8 +89,25 @@ export function stringifyTarget(tgt) {
export default Ember.Mixin.create({
actions: {
addTargetService: function() {
this.get('targetsArray').pushObject(Ember.Object.create({isService: true, isAdvanced: false, value: null}));
},
removeTarget: function(obj) {
this.get('targetsArray').removeObject(obj);
},
setAdvanced: function() {
this.set('isAdvanced', true);
},
},
isAdvanced: false,
targetsArray: null,
initTargets: function(service) {
this.set('isAdvanced', false);
var out = [];
var existing = null;
if ( service )
@ -156,8 +122,14 @@ export default Ember.Mixin.create({
var obj = parseTarget(str);
if ( obj )
{
if ( obj.get('hostname') || obj.get('srcPort') || obj.get('path') || obj.get('dstPort') )
{
this.set('isAdvanced', true);
}
obj.setProperties({
isService: true,
isAdvanced: false,
value: map.get('service.id'),
});
@ -166,13 +138,17 @@ export default Ember.Mixin.create({
});
});
}
else
{
out.pushObject(Ember.Object.create({isService: true, isAdvanced: false, value: null}));
}
this.set('targetsArray', out);
},
targetResources: function() {
var out = [];
this.get('targetsArray').filterProperty('isService',true).filterProperty('value').filterProperty('dstPort').map((choice) => {
this.get('targetsArray').filterProperty('isService',true).filterProperty('value').map((choice) => {
var serviceId = Ember.get(choice,'value');
var entry = out.filterProperty('serviceId', serviceId)[0];

View File

@ -3,9 +3,9 @@ import NewOrEdit from 'ui/mixins/new-or-edit';
import ShellQuote from 'npm:shell-quote';
import Util from 'ui/utils/util';
import EditHealthCheck from 'ui/mixins/edit-healthcheck';
import EditLabels from 'ui/mixins/edit-labels';
import EditScheduling from 'ui/mixins/edit-scheduling';
export default Ember.Mixin.create(NewOrEdit, EditHealthCheck, EditLabels, {
export default Ember.Mixin.create(NewOrEdit, EditHealthCheck, EditScheduling, {
needs: ['hosts'],
queryParams: ['tab','hostId','advanced'],
tab: 'command',
@ -132,14 +132,6 @@ export default Ember.Mixin.create(NewOrEdit, EditHealthCheck, EditLabels, {
}
});
},
addSchedulingRule: function() {
this.send('addSystemLabel','','','affinity');
},
removeSchedulingRule: function(obj) {
this.send('removeLabel', obj);
},
},
// ----------------------------------

View File

@ -9,9 +9,9 @@ export default Ember.Mixin.create(EditHealthCheck,{
name: 'uilistener',
isPublic: true,
sourcePort: '',
sourceProtocol: 'tcp',
sourceProtocol: 'http',
targetPort: '',
targetProtocol: 'tcp',
targetProtocol: 'http',
algorithm: 'roundrobin',
}));
},

View File

@ -0,0 +1,14 @@
import Ember from 'ember';
import EditLabels from 'ui/mixins/edit-labels';
export default Ember.Mixin.create(EditLabels, {
actions: {
addSchedulingRule: function() {
this.send('addSystemLabel','','','affinity');
},
removeSchedulingRule: function(obj) {
this.send('removeLabel', obj);
},
},
});

View File

@ -2,8 +2,9 @@ import Ember from 'ember';
import Cattle from 'ui/utils/cattle';
import EditLoadBalancerConfig from 'ui/mixins/edit-loadbalancerconfig';
import EditBalancerTarget from 'ui/mixins/edit-balancer-target';
import EditScheduling from 'ui/mixins/edit-scheduling';
export default Ember.ObjectController.extend(Cattle.LegacyNewOrEditMixin, EditLoadBalancerConfig, EditBalancerTarget, {
export default Ember.ObjectController.extend(Cattle.LegacyNewOrEditMixin, EditLoadBalancerConfig, EditBalancerTarget, EditScheduling, {
queryParams: ['environmentId','serviceId','tab'],
environmentId: null,
serviceId: null,
@ -11,15 +12,8 @@ export default Ember.ObjectController.extend(Cattle.LegacyNewOrEditMixin, EditLo
error: null,
editing: false,
primaryResource: Ember.computed.alias('model.balancer'),
actions: {
addTargetService: function() {
this.get('targetsArray').pushObject({isService: true, value: null, protocol: 'http'});
},
removeTarget: function(obj) {
this.get('targetsArray').removeObject(obj);
},
},
labelResource: Ember.computed.alias('model.launchConfig'),
isGlobal: false,
initFields: function() {
this._super();
@ -56,19 +50,19 @@ export default Ember.ObjectController.extend(Cattle.LegacyNewOrEditMixin, EditLo
}
var bad = this.get('targetsArray').filter(function(obj) {
return !Ember.get(obj,'value') || !Ember.get(obj, 'dstPort');
return !Ember.get(obj,'value');
});
if ( bad.get('length') )
{
errors.push('Target Service and Port are required on each Target');
errors.push('Target Service is required on each Target');
}
bad = this.get('targetsArray').filter(function(obj) {
return !Ember.get(obj,'hostname') && !Ember.get(obj, 'srcPort') && !Ember.get(obj,'path');
return Ember.get('srcPort') && !Ember.get(obj,'hostname') && !Ember.get(obj, 'dstPort') && !Ember.get(obj,'path');
});
if ( bad.get('length') )
{
errors.push('At least one of Request Host, Port, or Path are required on each Target');
errors.push('A Target can\'t have just a Source Port. Add Request Host, Request Path, or Target Port, or remove the Source Port.');
}
if ( errors.length )

View File

@ -121,7 +121,7 @@ export default Ember.Route.extend({
resetController: function (controller, isExisting/*, transition*/) {
if (isExisting)
{
controller.set('tab', 'listeners');
controller.set('tab', 'stickiness');
controller.set('stickiness', 'none');
controller.set('environmentId', null);
controller.set('serviceId', null);

View File

@ -63,6 +63,22 @@
{{partial "loadbalancer/edit-targets"}}
</div>
</div>
{{#if isAdvanced}}
<div class="row">
<div class="col-sm-12 col-md-8 col-md-offset-2">
<p class="help-block">If Request Host and/or Path are specified, connections to HTTP listening ports will be routed to the appropriate target based on the request. For example, you could use this to send traffic for domain1.com to one service and domain2.com to a different service.</p>
<p class="help-block">Matching requests will be sent to the Target Service on the Target Port. If that is not set, then the Default Target port for the Source Port. If that is also not set, then the Source Port.</p>
</div>
</div>
{{else}}
{{#if targetsArray.length}}
<div class="row">
<div class="col-sm-12 col-md-8 col-md-offset-2">
<button {{action "setAdvanced"}} class="btn btn-link btn-sm" type="button">Show advanced routing options</button><small>&ndash; Direct requests to different services based on port HTTP Host header, or request path</small>
</div>
</div>
{{/if}}
{{/if}}
</section>
{{partial "form-divider"}}

View File

@ -4,8 +4,10 @@ import SelectTab from 'ui/mixins/select-tab';
export default Ember.View.extend(SelectTab, {
actions: {
addTargetService: addAction('addTargetService', '.lb-target'),
addListener: addAction('addListener', '.lb-listener-source-port'),
addTargetService: addAction('addTargetService', '.lb-target'),
addListener: addAction('addListener', '.lb-listener-source-port'),
addSchedulingRule: addAction('addSchedulingRule','.schedule-rule'),
addLabel: addAction('addLabel', '.label-key'),
},
didInsertElement: function() {

View File

@ -30,32 +30,6 @@
</div>
{{#unless isRequestedHost}}
<div class="form-control-static">
<button class="btn-circle-plus" {{action "addSchedulingRule" target="view"}}></button>
</div>
{{#if affinityLabelArray.length}}
<table class="table fixed no-lines no-top-padding tight small">
<thead>
<tr class="text-muted">
<th width="70"></th>
<th width="100">Condition</th>
<th width="60"></th>
<th width="180">Field</th>
<th width="30"></th>
<th>Key</th>
<th width="30"></th>
<th>Value</th>
<th width="30"></th>
</tr>
</thead>
<tbody>
{{#each labelArray as |rule|}}
{{#if (eq rule.type "affinity")}}
{{scheduling-rule-row rule=rule allHosts=allHosts remove="removeSchedulingRule" isGlobal=isGlobal}}
{{/if}}
{{/each}}
</tbody>
</table>
{{/if}}
{{partial "container/scheduling-rules"}}
{{/unless}}
</div>

View File

@ -0,0 +1,27 @@
<div class="form-control-static">
<button class="btn-circle-plus btn-circle-text" {{action "addSchedulingRule" target="view"}}> Add Scheduling Rule</button>
</div>
{{#if affinityLabelArray.length}}
<table class="table fixed no-lines no-top-padding tight small">
<thead>
<tr class="text-muted">
<th width="70"></th>
<th width="100">Condition</th>
<th width="60"></th>
<th width="180">Field</th>
<th width="30"></th>
<th>Key</th>
<th width="30"></th>
<th>Value</th>
<th width="30"></th>
</tr>
</thead>
<tbody>
{{#each labelArray as |rule|}}
{{#if (eq rule.type "affinity")}}
{{scheduling-rule-row rule=rule allHosts=allHosts remove="removeSchedulingRule" isGlobal=isGlobal}}
{{/if}}
{{/each}}
</tbody>
</table>
{{/if}}

View File

@ -1,5 +1,5 @@
<div class="form-control-static">
<button class="btn-circle-plus" {{action "addLabel" target="view"}}></button>
<button class="btn-circle-plus btn-circle-text" {{action "addLabel" target="view"}}> Add Label</button>
</div>
{{#if userLabelArray.length}}

View File

@ -1,6 +1,8 @@
<section class="text-center" style="padding: 0;">
<ul class="nav nav-pills" style="display: inline-block">
<li role="presentation" class="tab" data-section="stickiness" {{action "selectTab" "stickiness" target=view}}><a>Stickiness</a></li>
<li role="presentation" class="tab" data-section="labels" {{action "selectTab" "labels" target=view}}><a>Labels</a></li>
<li role="presentation" class="tab" data-section="scheduling" {{action "selectTab" "scheduling" target=view}}><a>Scheduling</a></li>
</ul>
</section>
@ -8,5 +10,21 @@
<div class="section container-fluid tab-section" data-section="stickiness">
{{partial "loadbalancer/edit-stickiness"}}
</div>
<div class="section" data-section="labels">
<div class="row form-group">
<div class="col-sm-12 col-md-2 form-label">
<label class="form-control-static">Labels</label>
</div>
<div class="col-sm-12 col-md-8">
{{partial "edit-labels"}}
</div>
</div>
</div>
<div class="section" data-section="scheduling">
<p>Automatically pick hosts for each balancer container matching scheduling rules:</p>
{{partial "container/scheduling-rules"}}
</div>
</section>

View File

@ -2,47 +2,26 @@
<table class="grid fixed no-lines no-top-padding tight" style="margin-bottom: 0;">
<thead>
<tr>
<th>Request Host</th>
<th width="10">&nbsp;</th>
<th>Source Port</th>
<th width="10">&nbsp;</th>
<th>Request Path</th>
<th width="30">&nbsp;</th>
{{#if isAdvanced}}
<th>Request Host</th>
<th width="10">&nbsp;</th>
<th>Source Port</th>
<th width="10">&nbsp;</th>
<th>Request Path</th>
<th width="30">&nbsp;</th>
{{/if}}
<th>Target Service*</th>
<th width="10">&nbsp;</th>
<th width="90">Target Port*</th>
{{#if isAdvanced}}
<th width="10">&nbsp;</th>
<th width="90">Target Port</th>
{{/if}}
<th width="30">&nbsp;</th>
</tr>
</thead>
<tbody>
{{#each targetsArray as |tgt|}}
<tr>
<td>{{input classNames="form-control input-sm" value=tgt.hostname placeholder="e.g. svc.com"}}</td>
<td class="text-center"><div class="form-control-static input-sm">:</div></td>
<td>{{input classNames="form-control input-sm" value=tgt.srcPort placeholder="e.g. 80"}}</td>
<td>&nbsp;</td>
<td>{{input classNames="form-control input-sm" value=tgt.path placeholder="e.g. /svc"}}</td>
<td class="text-center"><div class="form-control-static input-sm"><i class="ss-right"></i></div></td>
<td>
{{display-name-select
classNames="form-control input-sm lb-target"
prompt="Select a service..."
value=tgt.value
content=targetChoices
optionValuePath="content.id"
optionLabelPath="content.name"
optionGroupPath="group"
}}
</td>
<td>&nbsp;</td>
<td>{{input classNames="form-control input-sm" value=tgt.dstPort placeholder="e.g. 8080"}}</td>
<td class="text-right">
<button {{action "removeTarget" tgt}} class="btn-circle-x" type="button" tabindex="-1"></button>
</td>
</tr>
{{loadbalancer-target-row tgt=tgt targetChoices=targetChoices remove="removeTarget" isAdvanced=isAdvanced}}
{{/each}}
</tbody>
</table>
<p class="help-block">At least one of Request Host, Source Port, or Request Path are required for each Target.</p>
<p class="help-block">If Request Host and/or Path are specified, connections to HTTP listening source ports will be routed to the appropriate target based on the request. For example, you could use this to send traffic for domain1.com to one service and domain2.com to a different service.</p>
{{/if}}

View File

@ -2,13 +2,16 @@
{{#if listenersArray.length}}
<thead>
<tr>
<th>Source Port</th>
<th>Source Port*</th>
<th width="30"></th>
{{#if isAdvanced}}
<th>Default Target Port</th>
<th width="30"></th>
{{/if}}
<th width="100">Access</th>
<th width="30"></th>
<th width="60">Protocol</th>
<th width="30"></th>
<th width="100">Algorithm</th>
<th width="40">&nbsp;</th>
</tr>
</thead>
@ -19,6 +22,12 @@
{{#if listener.id}}
<td>{{listener.sourcePort}}</td>
<td>&nbsp;</td>
{{#if isAdvanced}}
<td>
{{listener.targetPort}}
</td>
<td>&nbsp;</td>
{{/if}}
<td>{{if listener.isPublic "Public" "Internal"}}</td>
<td>&nbsp;</td>
<td>{{listener.sourceProtocol}}</td>
@ -32,6 +41,12 @@
{{input type="text" classNames="form-control lb-listener-source-port input-sm" min="1" max="65535" step="1" value=listener.sourcePort placeholder="e.g. 80"}}
</td>
<td>&nbsp;</td>
{{#if isAdvanced}}
<td>
{{input type="text" classNames="form-control lb-listener-target-port input-sm" min="1" max="65535" step="1" value=listener.targetPort placeholder="e.g. 80"}}
</td>
<td>&nbsp;</td>
{{/if}}
<td>
<div class="btn-group">
<button type="button" class="btn btn-default dropdown-toggle btn-sm" data-toggle="dropdown" aria-expanded="false">{{if listener.isPublic "Public" "Internal"}} <span class="caret"></span></button>
@ -61,9 +76,6 @@
</div>
</td>
<td>&nbsp;</td>
<td>
<div class="form-control-static input-sm">Round Robin</div>
</td>
<td class="text-right">
<button {{action "removeListener" listener}} class="btn-circle-x" type="button" tabindex="-1"></button>
</td>

View File

@ -1,6 +1,6 @@
{
"name": "ui",
"version": "0.34.0",
"version": "0.35.0",
"private": true,
"directories": {
"doc": "doc",

View File

@ -14,25 +14,31 @@ test('it works', function(assert) {
var data = [
// New Format
{str: "example.com:80/path:81", parsed: {hostname: 'example.com', srcPort: 80, path: '/path', dstPort: 81}},
{str: "example.com:80:81", parsed: {hostname: 'example.com', srcPort: 80, path: null, dstPort: 81}},
{str: "example.com/path:81", parsed: {hostname: 'example.com', srcPort: null, path: '/path', dstPort: 81}},
{str: "example.com:81", parsed: {hostname: 'example.com', srcPort: null, path: null, dstPort: 81}},
{str: "80/path:81", parsed: {hostname: null, srcPort: 80, path: '/path', dstPort: 81}},
{str: "80:81", parsed: {hostname: null, srcPort: 80, path: null, dstPort: 81}},
{str: "/path:81", parsed: {hostname: null, srcPort: null, path: '/path', dstPort: 81}},
//{"81", Invalid, but symmetry.. parsed: {hostname: null, srcPort: null, path: null, dstPort: 81}},
{str: "example.com:80/path=81", parsed: {hostname: 'example.com', srcPort: 80, path: '/path', dstPort: 81}},
{str: "example.com:80=81", parsed: {hostname: 'example.com', srcPort: 80, path: null, dstPort: 81}},
{str: "example.com/path=81", parsed: {hostname: 'example.com', srcPort: null, path: '/path', dstPort: 81}},
{str: "example.com=81", parsed: {hostname: 'example.com', srcPort: null, path: null, dstPort: 81}},
{str: "80/path=81", parsed: {hostname: null, srcPort: 80, path: '/path', dstPort: 81}},
{str: "80=81", parsed: {hostname: null, srcPort: 80, path: null, dstPort: 81}},
{str: "/path=81", parsed: {hostname: null, srcPort: null, path: '/path', dstPort: 81}},
{str: "81", parsed: {hostname: null, srcPort: null, path: null, dstPort: 81}},
{str: "example.com:80/path", parsed: {hostname: 'example.com', srcPort: 80, path: '/path', dstPort: null}},
{str: "example.com:80", parsed: {hostname: 'example.com', srcPort: 80, path: null, dstPort: null}},
{str: "example.com/path", parsed: {hostname: 'example.com', srcPort: null, path: '/path', dstPort: null}},
{str: "example.com", parsed: {hostname: 'example.com', srcPort: null, path: null, dstPort: null}},
{str: "80/path", parsed: {hostname: null, srcPort: 80, path: '/path', dstPort: null}},
// {str: "80", Invalid, == dstPort parsed: {hostname: null, srcPort: 80, path: null, dstPort: null}},
{str: "/path", parsed: {hostname: null, srcPort: null, path: '/path', dstPort: null}},
// {"", Invalid, but symmetry... parsed: {hostname: null, srcPort: null, path: null, dstPort: null}},
// Old format
{str: "81:example.com/path", parsed: {hostname: 'example.com', srcPort: null, path: '/path', dstPort: 81}, expected: "example.com/path:81"},
{str: "81:example.com", parsed: {hostname: 'example.com', srcPort: null, path: null, dstPort: 81}, expected: "example.com:81"},
{str: "81:/path", parsed: {hostname: null, srcPort: null, path: '/path', dstPort: 81}, expected: "/path:81"},
{str: "81:example.com/path", parsed: {hostname: 'example.com', srcPort: null, path: '/path', dstPort: 81}, expected: "example.com/path=81"},
{str: "81:example.com", parsed: {hostname: 'example.com', srcPort: null, path: null, dstPort: 81}, expected: "example.com=81"},
{str: "81:/path", parsed: {hostname: null, srcPort: null, path: '/path', dstPort: 81}, expected: "/path=81"},
// Invalid
{str: "purplemonkeydishwasher", parsed: null},
{str: "81", parsed: null},
{str: ":81", parsed: null},
{str: "example.com::81", parsed: null},
// {str: ":81", parsed: null},
// {str: "example.com::81", parsed: null},
];
data.forEach(function(obj) {