Icu 7490 UI add new address field to target (#1579)

* feat: 🎸 Add network address field to targets

✅ Closes: ICU-7490
This commit is contained in:
Zhihe Li authored and GitHub committed 2023-01-17 15:00:43 -05:00
1 parent bfc275e0f6
commit 0cecb0d856
11 files changed
+340 -33

No files matched your search

@@ -40,3 +40,4 @@ fullscreen: Fullscreen
clear-all-filters: Clear all filters
learn-more: Learn more
refresh: Refresh
remove-resources: Remove resources and save
@@ -304,6 +304,7 @@ target:
add-host-sources: Add Host Sources
add-brokered-credential-sources: Add Brokered Credentials
add-injected-application-credential-sources: Add Injected Application Credentials
remove-address: Remove address and save
types:
tcp: Generic TCP
ssh: SSH
@@ -314,6 +315,13 @@ target:
type:
label: Type
help: Target type is the protocol with which end users should connect to this target. Choose Generic TCP for broad support of common protocols including RDP, K8s, many databases, and more.
target-address:
label: Target Address
help: Must be a valid IP address or DNS name. We recommend leaving this blank and using host catalogs and host sets instead if you want to use this target on multiple hosts.
questions:
delete-host-sources:
title: Remove associated host sources?
message: You have {numHostSources, plural, one {# host source} other {# host sources}} associated with this target. Adding an address will remove these host sources when you save your changes.
host-source:
title: Host Source
title_plural: Host Sources
@@ -327,6 +335,10 @@ target:
add:
title: Add Host Sources
description: Select host sources to assign to this target.
questions:
delete-address:
title: Remove target address?
message: This target has an assigned address. Adding a host source will remove the assigned address from the target when you save your changes.
brokered-credential-source:
title: Brokered Credential
title_plural: Brokered Credentials
@@ -1,7 +1,7 @@
{{#if this.hasAvailableHostSets}}
<Rose::Form
class='full-width'
@onSubmit={{fn this.submit @submit}}
@onSubmit={{this.submit}}
@cancel={{@cancel}}
@disabled={{@model.isSaving}}
as |form|
@@ -1,8 +1,16 @@
import Component from '@glimmer/component';
import { computed, action } from '@ember/object';
import { A } from '@ember/array';
import { inject as service } from '@ember/service';
import { loading } from 'ember-loading';
import { notifyError } from 'core/decorators/notify';
export default class FormTargetAddHostSetsComponent extends Component {
// =services
@service confirm;
@service intl;
// =properties
/**
@@ -49,7 +57,32 @@ export default class FormTargetAddHostSetsComponent extends Component {
}
@action
submit(fn) {
fn(this.selectedHostSetIDs);
@loading
@notifyError(({ message }) => message)
async submit() {
const target = this.args.model;
if (target.address && this.selectedHostSetIDs.length) {
try {
await this.confirm.confirm(
this.intl.t(
'resources.target.host-source.questions.delete-address.message'
),
{
title:
'resources.target.host-source.questions.delete-address.title',
confirm: 'resources.target.actions.remove-address',
}
);
} catch (e) {
// if the user denies, do nothing and return
return;
}
target.address = null;
await target.save();
}
await this.args.submit(this.selectedHostSetIDs);
}
}
@@ -1,5 +1,5 @@
<Rose::Form
@onSubmit={{@submit}}
@onSubmit={{this.submit}}
@cancel={{@cancel}}
@disabled={{@model.isSaving}}
@showEditToggle={{if @model.isNew false true}}
@@ -81,6 +81,57 @@
</InfoField>
{{/if}}
{{#if (feature-flag 'target-network-address')}}
<Hds::Form::TextInput::Field
@isRequired={{false}}
@isOptional={{true}}
@value={{@model.address}}
@isInvalid={{@model.errors.address}}
@type='text'
name='address'
disabled={{form.disabled}}
{{on 'input' (set-from-event @model 'address')}}
as |F|
>
<F.Label>{{t 'resources.target.form.target-address.label'}}</F.Label>
<F.HelperText>{{t
'resources.target.form.target-address.help'
}}</F.HelperText>
{{#if @model.errors.address}}
<F.Error as |E|>
{{#each @model.errors.address as |error|}}
<E.Message>{{error.message}}</E.Message>
{{/each}}
</F.Error>
{{/if}}
</Hds::Form::TextInput::Field>
{{/if}}
<Hds::Form::TextInput::Field
@isRequired={{@model.isTCP}}
@isOptional={{not @model.isTCP}}
@value={{@model.default_port}}
@isInvalid={{@model.errors.default_port}}
@type='text'
name='default_port'
disabled={{form.disabled}}
placeholder={{@defaultPort}}
{{on 'input' (set-from-event @model 'default_port')}}
as |F|
>
<F.Label data-test-default-port-label>{{t
'form.default_port.label'
}}</F.Label>
<F.HelperText>{{t 'form.default_port.help'}}</F.HelperText>
{{#if @model.errors.default_port}}
<F.Error as |E|>
{{#each @model.errors.default_port as |error|}}
<E.Message>{{error.message}}</E.Message>
{{/each}}
</F.Error>
{{/if}}
</Hds::Form::TextInput::Field>
<Hds::Form::TextInput::Field
@isRequired={{false}}
@isOptional={{true}}
@@ -125,31 +176,6 @@
{{/if}}
</Hds::Form::TextInput::Field>
<Hds::Form::TextInput::Field
@isRequired={{@model.isTCP}}
@isOptional={{not @model.isTCP}}
@value={{@model.default_port}}
@isInvalid={{@model.errors.default_port}}
@type='text'
name='default_port'
disabled={{form.disabled}}
placeholder={{@defaultPort}}
{{on 'input' (set-from-event @model 'default_port')}}
as |F|
>
<F.Label data-test-default-port-label>{{t
'form.default_port.label'
}}</F.Label>
<F.HelperText>{{t 'form.default_port.help'}}</F.HelperText>
{{#if @model.errors.default_port}}
<F.Error as |E|>
{{#each @model.errors.default_port as |error|}}
<E.Message>{{error.message}}</E.Message>
{{/each}}
</F.Error>
{{/if}}
</Hds::Form::TextInput::Field>
{{#if (feature-flag 'target-worker-filters-v2')}}
<Hds::Form::Fieldset
class='target-workers'
@@ -1,5 +1,9 @@
import Component from '@glimmer/component';
import { TYPES_TARGET } from 'api/models/target';
import { loading } from 'ember-loading';
import { inject as service } from '@ember/service';
import { action } from '@ember/object';
import { notifyError } from 'core/decorators/notify';
// NOTE: this is all a temporary solution till we have a resource type helper.
const types = [...TYPES_TARGET].reverse();
@@ -9,6 +13,11 @@ const icons = {
};
export default class FormTargetComponent extends Component {
// =services
@service confirm;
@service intl;
// =properties
/**
* maps resource type with icon
@@ -37,4 +46,39 @@ export default class FormTargetComponent extends Component {
get showDeprecationMessage() {
return !this.args.model.isNew && this.args.model.worker_filter;
}
@action
@loading
@notifyError(({ message }) => message)
async submit() {
const target = this.args.model;
const numHostSources = target.host_sources?.length;
const address = target.address;
if (address && numHostSources) {
try {
await this.confirm.confirm(
this.intl.t(
'resources.target.questions.delete-host-sources.message',
{ numHostSources }
),
{
title: 'resources.target.questions.delete-host-sources.title',
confirm: 'actions.remove-resources',
}
);
} catch (e) {
// if the user denies, do nothing and return
return;
}
await target.removeHostSources(
target.host_sources.map((hs) => hs.host_source_id)
);
// After saving the host sources, the model gets reset to an empty address,
// so we need to update the address with the previous value before saving
target.address = address;
}
await this.args.submit();
}
}
+1 -1
View File
@@ -55,7 +55,7 @@ export default class ScopesScopeTargetsRoute extends Route {
*/
@action
@loading
@notifyError(({ message }) => message)
@notifyError(({ message }) => message, { catch: true })
@notifySuccess(({ isNew }) =>
isNew ? 'notifications.create-success' : 'notifications.save-success'
)
+5 -1
View File
@@ -132,7 +132,11 @@
</dialog.body>
<dialog.footer>
<Rose::Button @style='primary' {{on 'click' accept}}>
{{t 'actions.ok'}}
{{if
confirmation.options.confirm
(t confirmation.options.confirm)
(t 'actions.ok')
}}
</Rose::Button>
<Rose::Button @style='secondary' {{on 'click' deny}}>
{{t 'actions.cancel'}}
@@ -136,13 +136,13 @@ module('Acceptance | targets | create', function (hooks) {
);
});
test('defualt port is not marked required for SSH targets', async function (assert) {
test('default port is not marked required for SSH targets', async function (assert) {
assert.expect(1);
await visit(urls.newTarget);
assert.dom('[data-test-default-port-label]').includesText('Optional');
});
test('defualt port is marked required for TCP targets', async function (assert) {
test('default port is marked required for TCP targets', async function (assert) {
assert.expect(1);
await visit(urls.newTarget);
await click('[value="tcp"]');
@@ -285,4 +285,31 @@ module('Acceptance | targets | create', function (hooks) {
assert.dom('[role="alert"] div').hasText('The request was invalid.');
assert.dom('.hds-form-error__message').hasText('Name is required.');
});
test('can save address', async function (assert) {
assert.expect(2);
const targetCount = getTargetCount();
await visit(urls.targets);
await click(`[href="${urls.newTarget}"]`);
await fillIn('[name="name"]', 'random string');
await fillIn('[name="address"]', '0.0.0.0');
await click('[type="submit"]');
assert.strictEqual(getTargetCount(), targetCount + 1);
assert.strictEqual(
this.server.schema.targets.all().models[getTargetCount() - 1].address,
'0.0.0.0'
);
});
test('address field does not exist when target network address feature is disabled', async function (assert) {
assert.expect(1);
featuresService.disable('target-network-address');
await visit(urls.targets);
await click(`[href="${urls.newTarget}"]`);
assert.dom('[name="address"]').doesNotExist();
});
});
@@ -252,4 +252,79 @@ module('Acceptance | targets | host-sources', function (hooks) {
assert.strictEqual(getTargetHostSetCount(), targetHostSetCount);
assert.dom('[role="alert"] div').hasText('The request was invalid.');
});
test('saving host source with address brings up confirmation modal and removes address', async function (assert) {
assert.expect(4);
const confirmService = this.owner.lookup('service:confirm');
confirmService.enabled = true;
const target = this.server.create('target', {
scope: instances.scopes.project,
address: '0.0.0.0',
});
const targetHostSetCount = this.server.schema.targets.find(target.id)
.hostSets.models.length;
assert.strictEqual(
this.server.schema.targets.find(target.id).address,
'0.0.0.0'
);
const targetUrl = `${urls.targets}/${target.id}`;
await visit(targetUrl);
await click(`[href="${targetUrl}/host-sources"]`);
await click('.rose-layout-page-actions a', 'Click add host set');
await click('tbody label');
await click('form [type="submit"]');
assert.dom('.rose-dialog').isVisible();
await click('.rose-dialog-footer .rose-button-primary', 'Remove resources');
assert.strictEqual(
this.server.schema.targets.find(target.id).address,
null
);
assert.strictEqual(
this.server.schema.targets.find(target.id).hostSets.models.length,
targetHostSetCount + 1
);
});
test('saving host source with address brings up confirmation modal and can cancel', async function (assert) {
assert.expect(4);
const confirmService = this.owner.lookup('service:confirm');
confirmService.enabled = true;
const target = this.server.create('target', {
scope: instances.scopes.project,
address: '0.0.0.0',
});
const targetHostSetCount = this.server.schema.targets.find(target.id)
.hostSets.models.length;
assert.strictEqual(
this.server.schema.targets.find(target.id).address,
'0.0.0.0'
);
const targetUrl = `${urls.targets}/${target.id}`;
await visit(targetUrl);
await click(`[href="${targetUrl}/host-sources"]`);
await click('.rose-layout-page-actions a', 'Click add host set');
await click('tbody label');
await click('form [type="submit"]');
assert.dom('.rose-dialog').isVisible();
await click(
'.rose-dialog-footer .rose-button-secondary',
'Remove resources'
);
assert.strictEqual(
this.server.schema.targets.find(target.id).address,
'0.0.0.0'
);
assert.strictEqual(
this.server.schema.targets.find(target.id).hostSets.models.length,
targetHostSetCount
);
});
});
@@ -199,4 +199,89 @@ module('Acceptance | targets | update', function (hooks) {
assert.dom('form [type="button"]').doesNotExist();
});
test('saving address with existing host sources brings up confirmation modal and removes host sources', async function (assert) {
assert.expect(4);
const confirmService = this.owner.lookup('service:confirm');
confirmService.enabled = true;
this.server.createList(
'host-catalog',
8,
{ scope: instances.scopes.project },
'withChildren'
);
this.server.createList(
'credential-store',
3,
{ scope: instances.scopes.project },
'withAssociations'
);
const target = this.server.create(
'target',
{ scope: instances.scopes.project },
'withAssociations'
);
assert.true(this.server.schema.targets.find(target.id).hostSets.length > 0);
const url = `${urls.targets}/${target.id}`;
await visit(urls.targets);
await click(`[href="${url}"]`);
await click('form [type="button"]', 'Activate edit mode');
await fillIn('[name="address"]', '0.0.0.0');
await click('[type="submit"]');
assert.dom('.rose-dialog').isVisible();
await click('.rose-dialog-footer .rose-button-primary', 'Remove resources');
assert.strictEqual(
this.server.schema.targets.find(target.id).address,
'0.0.0.0'
);
assert.strictEqual(
this.server.schema.targets.find(target.id).hostSets.length,
0
);
});
test('saving address with existing host sources brings up confirmation modal and can cancel', async function (assert) {
assert.expect(4);
const confirmService = this.owner.lookup('service:confirm');
confirmService.enabled = true;
this.server.createList(
'host-catalog',
8,
{ scope: instances.scopes.project },
'withChildren'
);
this.server.createList(
'credential-store',
3,
{ scope: instances.scopes.project },
'withAssociations'
);
const target = this.server.create(
'target',
{ scope: instances.scopes.project },
'withAssociations'
);
assert.true(this.server.schema.targets.find(target.id).hostSets.length > 0);
const url = `${urls.targets}/${target.id}`;
await visit(urls.targets);
await click(`[href="${url}"]`);
await click('form [type="button"]', 'Activate edit mode');
await fillIn('[name="address"]', '0.0.0.0');
await click('[type="submit"]');
assert.dom('.rose-dialog').isVisible();
await click('.rose-dialog-footer .rose-button-secondary', 'Cancel');
assert.strictEqual(
this.server.schema.targets.find(target.id).address,
undefined
);
assert.true(this.server.schema.targets.find(target.id).hostSets.length > 0);
});
});