CLOUDSTACK-7882: SSH Keypair Creation/Selection in UI

Thanks Ilia Shakitko for the porting and testing.
This commit is contained in:
Wei Zhou 2014-12-12 14:30:34 +01:00
parent ff15320a4e
commit 19e99848c8
19 changed files with 567 additions and 13 deletions

View File

@ -84,6 +84,7 @@ import org.apache.cloudstack.api.response.RemoteAccessVpnResponse;
import org.apache.cloudstack.api.response.ResourceCountResponse;
import org.apache.cloudstack.api.response.ResourceLimitResponse;
import org.apache.cloudstack.api.response.ResourceTagResponse;
import org.apache.cloudstack.api.response.SSHKeyPairResponse;
import org.apache.cloudstack.api.response.SecurityGroupResponse;
import org.apache.cloudstack.api.response.ServiceOfferingResponse;
import org.apache.cloudstack.api.response.ServiceResponse;
@ -186,6 +187,7 @@ import com.cloud.storage.snapshot.SnapshotPolicy;
import com.cloud.storage.snapshot.SnapshotSchedule;
import com.cloud.template.VirtualMachineTemplate;
import com.cloud.user.Account;
import com.cloud.user.SSHKeyPair;
import com.cloud.user.User;
import com.cloud.user.UserAccount;
import com.cloud.uservm.UserVm;
@ -446,4 +448,5 @@ public interface ResponseGenerator {
ListResponse<UpgradeRouterTemplateResponse> createUpgradeRouterTemplateResponse(List<Long> jobIds);
SSHKeyPairResponse createSSHKeyPairResponse(SSHKeyPair sshkeyPair, boolean privatekey);
}

View File

@ -91,7 +91,7 @@ public class CreateSSHKeyPairCmd extends BaseCmd {
@Override
public void execute() {
SSHKeyPair r = _mgr.createSSHKeyPair(this);
CreateSSHKeyPairResponse response = new CreateSSHKeyPairResponse(r.getName(), r.getFingerprint(), r.getPrivateKey());
CreateSSHKeyPairResponse response = (CreateSSHKeyPairResponse) _responseGenerator.createSSHKeyPairResponse(r, true);
response.setResponseName(getCommandName());
response.setObjectName("keypair");
setResponseObject(response);

View File

@ -68,7 +68,7 @@ public class ListSSHKeyPairsCmd extends BaseListProjectAndAccountResourcesCmd {
Pair<List<? extends SSHKeyPair>, Integer> resultList = _mgr.listSSHKeyPairs(this);
List<SSHKeyPairResponse> responses = new ArrayList<SSHKeyPairResponse>();
for (SSHKeyPair result : resultList.first()) {
SSHKeyPairResponse r = new SSHKeyPairResponse(result.getName(), result.getFingerprint());
SSHKeyPairResponse r = _responseGenerator.createSSHKeyPairResponse(result, false);
r.setObjectName("sshkeypair");
responses.add(r);
}

View File

@ -99,7 +99,7 @@ public class RegisterSSHKeyPairCmd extends BaseCmd {
@Override
public void execute() {
SSHKeyPair result = _mgr.registerSSHKeyPair(this);
SSHKeyPairResponse response = new SSHKeyPairResponse(result.getName(), result.getFingerprint());
SSHKeyPairResponse response = _responseGenerator.createSSHKeyPairResponse(result, false);
response.setResponseName(getCommandName());
response.setObjectName("keypair");
setResponseObject(response);

View File

@ -120,6 +120,9 @@ public class ListVMsCmd extends BaseListTaggedResourcesCmd {
@Parameter(name = ApiConstants.AFFINITY_GROUP_ID, type = CommandType.UUID, entityType = AffinityGroupResponse.class, description = "list vms by affinity group")
private Long affinityGroupId;
@Parameter(name = ApiConstants.SSH_KEYPAIR, type = CommandType.STRING, description = "list vms by ssh keypair name")
private String keypair;
@Parameter(name = ApiConstants.SERVICE_OFFERING_ID, type = CommandType.UUID, entityType = ServiceOfferingResponse.class, description = "list by the service offering", since = "4.4")
private Long serviceOffId;
@ -184,6 +187,10 @@ public class ListVMsCmd extends BaseListTaggedResourcesCmd {
return affinityGroupId;
}
public String getKeyPairName() {
return keypair;
}
public EnumSet<VMDetails> getDetails() throws InvalidParameterValueException {
EnumSet<VMDetails> dv;
if (viewDetails == null || viewDetails.size() <= 0) {

View File

@ -29,6 +29,15 @@ public class SSHKeyPairResponse extends BaseResponse {
@Param(description = "Name of the keypair")
private String name;
@SerializedName(ApiConstants.ACCOUNT) @Param(description="the owner of the keypair")
private String accountName;
@SerializedName(ApiConstants.DOMAIN_ID) @Param(description="the domain id of the keypair owner")
private String domainId;
@SerializedName(ApiConstants.DOMAIN) @Param(description="the domain name of the keypair owner")
private String domain;
@SerializedName("fingerprint")
@Param(description = "Fingerprint of the public key")
private String fingerprint;
@ -57,4 +66,15 @@ public class SSHKeyPairResponse extends BaseResponse {
this.fingerprint = fingerprint;
}
public void setAccountName(String accountName) {
this.accountName = accountName;
}
public void setDomainId(String domainId) {
this.domainId = domainId;
}
public void setDomainName(String domain) {
this.domain = domain;
}
}

View File

@ -827,6 +827,7 @@ label.menu.templates=Templates
label.menu.virtual.appliances=Virtual Appliances
label.menu.virtual.resources=Virtual Resources
label.menu.volumes=Volumes
label.menu.sshkeypair=SSH KeyPair
label.migrate.instance.to.host=Migrate instance to another host
label.migrate.instance.to.ps=Migrate instance to another primary storage
label.migrate.instance.to=Migrate instance to

View File

@ -58,6 +58,7 @@ import org.apache.cloudstack.api.response.ControlledEntityResponse;
import org.apache.cloudstack.api.response.ControlledViewEntityResponse;
import org.apache.cloudstack.api.response.CounterResponse;
import org.apache.cloudstack.api.response.CreateCmdResponse;
import org.apache.cloudstack.api.response.CreateSSHKeyPairResponse;
import org.apache.cloudstack.api.response.DiskOfferingResponse;
import org.apache.cloudstack.api.response.DomainResponse;
import org.apache.cloudstack.api.response.DomainRouterResponse;
@ -105,6 +106,7 @@ import org.apache.cloudstack.api.response.RemoteAccessVpnResponse;
import org.apache.cloudstack.api.response.ResourceCountResponse;
import org.apache.cloudstack.api.response.ResourceLimitResponse;
import org.apache.cloudstack.api.response.ResourceTagResponse;
import org.apache.cloudstack.api.response.SSHKeyPairResponse;
import org.apache.cloudstack.api.response.SecurityGroupResponse;
import org.apache.cloudstack.api.response.SecurityGroupRuleResponse;
import org.apache.cloudstack.api.response.ServiceOfferingResponse;
@ -291,6 +293,7 @@ import com.cloud.storage.snapshot.SnapshotSchedule;
import com.cloud.template.VirtualMachineTemplate;
import com.cloud.user.Account;
import com.cloud.user.AccountManager;
import com.cloud.user.SSHKeyPair;
import com.cloud.user.User;
import com.cloud.user.UserAccount;
import com.cloud.uservm.UserVm;
@ -3654,4 +3657,17 @@ public class ApiResponseHelper implements ResponseGenerator {
return response;
}
@Override
public SSHKeyPairResponse createSSHKeyPairResponse(SSHKeyPair sshkeyPair, boolean privatekey) {
SSHKeyPairResponse response = new SSHKeyPairResponse(sshkeyPair.getName(), sshkeyPair.getFingerprint());
if (privatekey) {
response = new CreateSSHKeyPairResponse(sshkeyPair.getName(), sshkeyPair.getFingerprint(), sshkeyPair.getPrivateKey());
}
Account account = ApiDBUtils.findAccountById(sshkeyPair.getAccountId());
response.setAccountName(account.getAccountName());
Domain domain = ApiDBUtils.findDomainById(sshkeyPair.getDomainId());
response.setDomainId(domain.getUuid());
response.setDomainName(domain.getName());
return response;
}
}

View File

@ -835,6 +835,7 @@ public class QueryManagerImpl extends ManagerBase implements QueryService {
Object isoId = cmd.getIsoId();
Object vpcId = cmd.getVpcId();
Object affinityGroupId = cmd.getAffinityGroupId();
Object keyPairName = cmd.getKeyPairName();
Object serviceOffId = cmd.getServiceOfferingId();
Object pod = null;
Object hostId = null;
@ -886,6 +887,10 @@ public class QueryManagerImpl extends ManagerBase implements QueryService {
sb.and("affinityGroupId", sb.entity().getAffinityGroupId(), SearchCriteria.Op.EQ);
}
if (keyPairName != null) {
sb.and("keyPairName", sb.entity().getKeypairName(), SearchCriteria.Op.EQ);
}
if (!isRootAdmin) {
sb.and("displayVm", sb.entity().isDisplayVm(), SearchCriteria.Op.EQ);
}
@ -978,6 +983,10 @@ public class QueryManagerImpl extends ManagerBase implements QueryService {
sc.setParameters("affinityGroupId", affinityGroupId);
}
if (keyPairName != null) {
sc.setParameters("keyPairName", keyPairName);
}
if (cmd instanceof ListVMsCmdByAdmin) {
ListVMsCmdByAdmin aCmd = (ListVMsCmdByAdmin)cmd;
if (aCmd.getPodId() != null) {

View File

@ -86,6 +86,7 @@ public class Criteria {
public static final String AFFINITY_GROUP_ID = "affinitygroupid";
public static final String SERVICE_OFFERING_ID = "serviceofferingid";
public static final String DISPLAY = "display";
public static final String SSH_KEYPAIR = "keypair";
public Criteria(String orderBy, Boolean ascending, Long offset, Long limit) {
this.offset = offset;

View File

@ -16,7 +16,9 @@
// under the License.
package com.cloud.server;
import java.io.UnsupportedEncodingException;
import java.lang.reflect.Field;
import java.net.URLDecoder;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Calendar;
@ -3607,7 +3609,15 @@ public class ManagementServerImpl extends ManagerBase implements ManagementServe
}
String name = cmd.getName();
String publicKey = SSHKeysHelper.getPublicKeyFromKeyMaterial(cmd.getPublicKey());
String key = cmd.getPublicKey();
try {
if (key != null) {
key = URLDecoder.decode(key, "UTF-8");
}
} catch (UnsupportedEncodingException e) {
} finally {
}
String publicKey = SSHKeysHelper.getPublicKeyFromKeyMaterial(key);
if (publicKey == null) {
throw new InvalidParameterValueException("Public key is invalid");

View File

@ -5500,7 +5500,7 @@ label.error {
}
.multi-wizard .progress ul {
width: 780px;
width: 900px;
height: 40px;
float: left;
clear: both;
@ -6072,7 +6072,7 @@ label.error {
}
/*** Data disk offering*/
.multi-wizard.instance-wizard .data-disk-offering .content .section {
.multi-wizard.instance-wizard .content .section {
padding: 9px 0 16px;
margin: 12px 0 15px 8px;
}
@ -12447,6 +12447,14 @@ div.ui-dialog div.autoscaler div.field-group div.form-container form div.form-it
background-position: -68px -612px;
}
.resetSSHKeyForVirtualMachine .icon {
background-position: -196px -3px;
}
.resetSSHKeyForVirtualMachine:hover .icon {
background-position: -195px -586px;
}
.changeService .icon {
background-position: -38px -33px;
}

View File

@ -829,6 +829,7 @@ dictionary = {
'label.menu.virtual.appliances': '<fmt:message key="label.menu.virtual.appliances" />',
'label.menu.virtual.resources': '<fmt:message key="label.menu.virtual.resources" />',
'label.menu.volumes': '<fmt:message key="label.menu.volumes" />',
'label.menu.sshkeypair': '<fmt:message key="label.menu.sshkeypair" />',
'label.migrate.instance.to': '<fmt:message key="label.migrate.instance.to" />',
'label.migrate.instance.to.host': '<fmt:message key="label.migrate.instance.to.host" />',
'label.migrate.instance.to.ps': '<fmt:message key="label.migrate.instance.to.ps" />',

View File

@ -101,7 +101,8 @@
<li><span class="number">4</span><span class="multiline"><fmt:message key="label.disk.offering"/></span><span class="arrow"></span></li>
<li><span class="number">5</span><span><fmt:message key="label.affinity"/></span><span class="arrow"></span></li>
<li><span class="number">6</span><span><fmt:message key="label.menu.network"/></span><span class="arrow"></span></li>
<li class="last"><span class="number">7</span><span><fmt:message key="label.review"/></span></li>
<li><span class="number">7</span><span><fmt:message key="label.menu.sshkeypair"/></span><span class="arrow"></span></li>
<li class="last"><span class="number">8</span><span><fmt:message key="label.review"/></span></li>
</ul>
</div>
<form>
@ -393,7 +394,18 @@
</div>
</div>
</div>
<!-- Step 7: Review -->
<!-- Step 7: SSH Key pairs -->
<div class="step sshkeyPairs" wizard-step-id="sshkeyPairs">
<div class="content">
<div class="section no-thanks">
<input type="radio" name="sshkeypair" value="" />
<label><fmt:message key="label.no.thanks"/></label>
</div>
<!-- Existing key pairs -->
<div class="select-container"></div>
</div>
</div>
<!-- Step 8: Review -->
<div class="step review" wizard-step-id="review">
<div class="main-desc">
<fmt:message key="message.vm.review.launch"/>
@ -535,6 +547,19 @@
</div>
</div>
<!-- SSH Key Pairs -->
<div class="select">
<div class="name">
<span>SSH Key Pairs</span>
</div>
<div class="value">
<span wizard-field="sshkey-pairs"></span>
</div>
<div class="edit">
<a href="7"><fmt:message key="label.edit"/></a>
</div>
</div>
<!-- userdata -->
<div class="select">
<div class="select">

View File

@ -24,7 +24,7 @@
sectionSelect: {
label: 'label.select-view',
preFilter: function() {
return ['accounts'];
return ['accounts', 'sshkeypairs'];
}
},
sections: {
@ -1468,6 +1468,280 @@
}
}
}
},
sshkeypairs: {
type: 'select',
id: 'sshkeypairs',
title: 'SSH Key Pairs',
listView: {
name: 'sshkeypairs',
fields: {
name: {
label: 'label.name'
},
domain: {
label: 'label.domain'
},
account: {
label: 'label.account'
},
privatekey: {
label: 'Private Key',
span: false
}
},
dataProvider: function(args) {
var data = {
// domainid: g_domainid,
// account: g_account
};
listViewDataProvider(args, data);
$.ajax({
url: createURL('listSSHKeyPairs'),
data: data,
success: function(json) {
var items = json.listsshkeypairsresponse.sshkeypair;
args.response.success({
data: items
});
}
});
},
actions: {
add: {
label: 'Create a SSH Key Pair',
preFilter: function(args) {
return true;
},
messages: {
notification: function(args) {
return 'Created a SSH Key Pair.';
}
},
createForm: {
title: 'Create a SSH Key Pair',
desc: 'Please fill in the following data to create or register a ssh key pair.<br><br>(1) If public key is set, CloudStack will register the public key. You can use it through your private key.<br><br>(2) If public key is not set, CloudStack will create a new SSH Key pair. In this case, please copy and save the private key. CloudStack will not keep it.<br>',
fields: {
name: {
label: 'label.name',
validation: {
required: true
},
},
publickey: {
label: 'Public Key'
},
domain: {
label: 'label.domain',
isHidden: function(args) {
if (isAdmin() || isDomainAdmin())
return false;
else
return true;
},
select: function(args) {
if (isAdmin() || isDomainAdmin()) {
$.ajax({
url: createURL("listDomains&listAll=true"),
success: function(json) {
var items = [];
items.push({
id: "",
description: ""
});
var domainObjs = json.listdomainsresponse.domain;
$(domainObjs).each(function() {
items.push({
id: this.id,
description: this.path
});
});
args.response.success({
data: items
});
}
});
args.$select.change(function() {
var $form = $(this).closest('form');
if ($(this).val() == "") {
$form.find('.form-item[rel=account]').hide();
} else {
$form.find('.form-item[rel=account]').css('display', 'inline-block');
}
});
} else {
var items = [];
items.push({
id: "",
description: ""
});
args.response.success({
data: items
});
}
},
},
account: {
label: 'label.account',
isHidden: function(args) {
if (isAdmin() || isDomainAdmin())
return false;
else
return true;
}
}
}
},
action: function(args) {
var data = {
name: args.data.name
};
if (args.data.domain != null && args.data.domain.length > 0) {
$.extend(data, {
domainid: args.data.domain
});
if (args.data.account != null && args.data.account.length > 0) {
$.extend(data, {
account: args.data.account
});
}
}
if (args.data.publickey != null && args.data.publickey.length > 0) {
$.extend(data, {
publickey: encodeURIComponent(args.data.publickey)
});
$.ajax({
url: createURL('registerSSHKeyPair'),
data: data,
type: "POST",
success: function(json) {
var item = json.registersshkeypairresponse.keypair;
args.response.success({
data: item
});
},
error: function(XMLHttpResponse) {
var errorMsg = parseXMLHttpResponse(XMLHttpResponse);
args.response.error(errorMsg);
}
});
} else {
$.ajax({
url: createURL('createSSHKeyPair'),
data: data,
success: function(json) {
var item = json.createsshkeypairresponse.keypair;
args.response.success({
data: item
});
},
error: function(XMLHttpResponse) {
var errorMsg = parseXMLHttpResponse(XMLHttpResponse);
args.response.error(errorMsg);
}
});
}
},
notification: {
poll: function(args) {
args.complete();
}
}
}
},
detailView: {
name: 'SSH Key Pair Details',
isMaximized: true,
viewAll: {
label: 'label.instances',
path: 'instances'
},
actions: {
remove: {
label: 'Remove SSH Key Pair',
messages: {
confirm: function(args) {
return 'Please confirm that you want to remove this SSH Key Pair';
},
notification: function(args) {
return 'Removed a SSH Key Pair';
}
},
action: function(args) {
var data = {
domainid: args.context.sshkeypairs[0].domainid,
account: args.context.sshkeypairs[0].account,
name: args.context.sshkeypairs[0].name
};
$.ajax({
url: createURL('deleteSSHKeyPair'),
data: data,
success: function(json) {
args.response.success();
$(window).trigger('cloudStack.fullRefresh');
}
});
}
}
},
tabs: {
details: {
title: 'label.details',
fields: [{
name: {
label: 'label.name',
isEditable: true,
validation: {
required: true
}
}
}, {
domain: {
label: 'label.domain'
},
account: {
label: 'label.account'
},
privatekey: {
label: 'Private Key',
span: false
},
fingerprint: {
label: 'FingerPrint'
}
}],
dataProvider: function(args) {
var data = {
name: args.context.sshkeypairs[0].name
};
$.ajax({
url: createURL('listSSHKeyPairs&listAll=true'),
data: data,
success: function(json) {
args.response.success({
actionFilter: sshkeypairActionfilter,
data: json.listsshkeypairsresponse.sshkeypair[0]
});
}
});
}
}
}
}
}
}
}
};
@ -1546,4 +1820,9 @@
return allowedActions;
}
var sshkeypairActionfilter = function(args) {
var allowedActions = [];
allowedActions.push("remove");
return allowedActions;
}
})(cloudStack);

View File

@ -665,7 +665,22 @@
},
// Step 7: Review
// Step 7: SSH Key Pairs
function(args) {
$.ajax({
url: createURL('listSSHKeyPairs'),
success: function(json) {
var sshkeypair = json.listsshkeypairsresponse.sshkeypair;
args.response.success({
data: {
sshkeyPairs: sshkeypair
}
});
}
});
},
// Step 8: Review
function(args) {
return false;
}
@ -952,6 +967,13 @@
}
}
//step 4: select ssh key pair
if (args.data.sshkeypair != null && args.data.sshkeypair.length > 0) {
$.extend(deployVmData, {
keypair : args.data.sshkeypair
});
}
var displayname = args.data.displayname;
if (displayname != null && displayname.length > 0) {
$.extend(deployVmData, {

View File

@ -362,6 +362,14 @@
});
}
if ("sshkeypairs" in args.context) {
$.extend(data, {
domainid: args.context.sshkeypairs[0].domainid,
account: args.context.sshkeypairs[0].account,
keypair: args.context.sshkeypairs[0].name
});
}
$.ajax({
url: createURL('listVirtualMachines'),
data: data,
@ -1624,7 +1632,99 @@
poll: pollAsyncJobResult
}
},
resetSSHKeyForVirtualMachine: {
label: 'Reset SSH Key Pair',
createForm: {
title: 'Reset SSH Key Pair on VM',
desc: 'Please specify a ssh key pair that you would like to add to this VM. Please note the root password will be changed by this operation if password is enabled.',
fields: {
sshkeypair: {
label: 'New SSH Key Pair',
validation: {
required: true
},
select: function(args) {
var data = {
domainid: args.context.instances[0].domainid,
account: args.context.instances[0].account,
listAll: true
};
$.ajax({
url: createURL("listSSHKeyPairs"),
data: data,
async: false,
success: function(json) {
var items = [];
var sshkeypairs = json.listsshkeypairsresponse.sshkeypair;
if (sshkeypairs == null) {
} else {
for (var i = 0; i < sshkeypairs.length; i++) {
var sshkeypair = sshkeypairs[i];
if (sshkeypair.name != args.context.instances[0].keypair) {
items.push({
id: sshkeypair.name,
description: sshkeypair.name
});
}
}
}
args.response.success({
data: items
});
}
});
}
}
}
},
action: function(args) {
var data = {
domainid: args.context.instances[0].domainid,
account: args.context.instances[0].account,
id: args.context.instances[0].id,
keypair: args.data.sshkeypair
};
$.ajax({
url: createURL("resetSSHKeyForVirtualMachine"),
data: data,
async: true,
success: function(json) {
var jid = json.resetSSHKeyforvirtualmachineresponse.jobid;
args.response.success({
_custom: {
jobId: jid,
getUpdatedItem: function(json) {
return json.queryasyncjobresultresponse.jobresult.virtualmachine;
},
getActionFilter: function() {
return vmActionfilter;
}
}
});
}
});
},
messages: {
notification: function(args) {
return 'Reset SSH Key Pair on VM';
},
complete: function(args) {
if (args.password != null) {
return 'Password of the VM has been reset to ' + args.password;
}
return false;
}
},
notification: {
poll: pollAsyncJobResult
}
},
assignVmToAnotherAccount: {
label: 'label.assign.instance.another',
createForm: {
@ -1899,6 +1999,9 @@
publicip: {
label: 'label.public.ip'
},
keypair: {
label: 'SSH Key Pair'
},
domain: {
label: 'label.domain'
},
@ -2387,7 +2490,7 @@
if (isAdmin() || isDomainAdmin()) {
allowedActions.push("assignVmToAnotherAccount");
}
allowedActions.push("resetSSHKeyForVirtualMachine");
} else if (jsonObj.state == 'Starting') {
// allowedActions.push("stop");
} else if (jsonObj.state == 'Error') {

View File

@ -715,6 +715,51 @@
};
},
'sshkeyPairs': function($step, formData) {
var originalValues = function(formData) {
if (formData.sshkeypair) {
$step.find('input[type=radio]').filter(function() {
return $(this).val() == formData.sshkeypair;
}).click();
} else {
$step.find('input[type=radio]:first').click();
}
};
return {
response: {
success: function(args) {
$step.find('.main-desc, p.no-sshkey-pairs').remove();
if (args.data.sshkeyPairs && args.data.sshkeyPairs.length) {
$step.prepend(
$('<div>').addClass('main-desc').append(
$('<p>').html(_l('Please select a ssh key pair you want this VM to use:'))
)
);
$step.find('.section.no-thanks').show();
$step.find('.select-container').append(
makeSelects(
'sshkeypair',
args.data.sshkeyPairs, {
name: 'name',
id: 'name'
}, {
'wizard-field': 'sshkey-pairs'
}
)
);
originalValues(formData); // if we can select only one.
} else {
$step.find('.section.no-thanks').hide();
$step.find('.select-container').append(
$('<p>').addClass('no-sshkey-pairs').html(_l('You do not have any ssh key pairs. Please continue to the next step.'))
);
}
}
}
};
},
'network': function($step, formData) {
var showAddNetwork = true;
@ -1253,7 +1298,7 @@
return $wizard.dialog({
title: _l('label.vm.add'),
width: 800,
width: 896,
height: 570,
closeOnEscape: false,
zIndex: 5000

View File

@ -1135,6 +1135,10 @@
});
$ul.appendTo($td);
} else if (field.span == false) {
$td.append(
$('<pre>').html(_s(content))
);
} else {
$td.append(
$('<span>').html(_s(content))