Added copy server support, allowing the duplication of existing servers with the option to make certain modifications. #6085 (#7106)

Added copy server support, allowing the duplication of existing servers with the option to make certain modifications. #6085
This commit is contained in:
Akshay Joshi 2024-01-08 12:16:49 +05:30 committed by GitHub
parent 5e710f7ee3
commit 30509d1bc1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 143 additions and 26 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 65 KiB

View File

@ -42,6 +42,12 @@ following options (in alphabetical order):
+-----------------------------+--------------------------------------------------------------------------------------------------------------------------+
| Option | Action |
+=============================+==========================================================================================================================+
| *Register* | |
| | |
| 1) *Server* | Click to open the :ref:`Server <server_dialog>` dialog to register a server. |
| | |
| 2) *Deploy Cloud Instance*| Click to open the :ref:`Cloud Deployment <cloud_deployment>` dialog to deploy an cloud instance. |
+-----------------------------+--------------------------------------------------------------------------------------------------------------------------+
| *Change Password...* | Click to open the :ref:`Change Password... <change_password_dialog>` dialog to change your password. |
+-----------------------------+--------------------------------------------------------------------------------------------------------------------------+
| *Clear Saved Password* | If you have saved the database server password, click to clear the saved password. |
@ -52,6 +58,8 @@ following options (in alphabetical order):
+-----------------------------+--------------------------------------------------------------------------------------------------------------------------+
| *Connect Server* | Click to open the :ref:`Connect to Server <connect_to_server>` dialog to establish a connection with a server. |
+-----------------------------+--------------------------------------------------------------------------------------------------------------------------+
| *Copy Server...* | Click to copy the currently selected server. |
+-----------------------------+--------------------------------------------------------------------------------------------------------------------------+
| *Create* | Click *Create* to access a context menu that provides context-sensitive selections. |
| | Your selection opens a *Create* dialog for creating a new object. |
+-----------------------------+--------------------------------------------------------------------------------------------------------------------------+

View File

@ -22,6 +22,7 @@ New features
| `Issue #2483 <https://github.com/pgadmin-org/pgadmin4/issues/2483>`_ - Administer pgAdmin Users and Preferences Using the Command Line Interface (CLI).
| `Issue #5908 <https://github.com/pgadmin-org/pgadmin4/issues/5908>`_ - Allow users to convert View/Edit table into a Query tool to enable editing the SQL generated.
| `Issue #6085 <https://github.com/pgadmin-org/pgadmin4/issues/6085>`_ - Added copy server support, allowing the duplication of existing servers with the option to make certain modifications.
| `Issue #7016 <https://github.com/pgadmin-org/pgadmin4/issues/7016>`_ - Added keep-alive support for SSH sessions when connecting to a PostgreSQL server via an SSH tunnel.
Housekeeping

View File

@ -39,8 +39,9 @@ def get_icon_css_class(group_id, group_user_id,
group_user_id != current_user.id and
ServerGroupModule.has_shared_server(group_id)):
default_val = 'icon-server_group_shared'
return default_val, True
return default_val
return default_val, False
SG_NOT_FOUND_ERROR = 'The specified server group could not be found.'
@ -86,14 +87,16 @@ class ServerGroupModule(BrowserPluginModule):
).order_by("id")
for idx, group in enumerate(groups):
icon_class, is_shared = get_icon_css_class(group.id, group.user_id)
yield self.generate_browser_node(
"%d" % (group.id), None,
group.name,
get_icon_css_class(group.id, group.user_id),
icon_class,
True,
self.node_type,
can_delete=True if idx > 0 else False,
user_id=group.user_id
user_id=group.user_id,
is_shared=is_shared
)
@property
@ -264,15 +267,17 @@ class ServerGroupView(NodeView):
status=410, success=0, errormsg=e.message
)
icon_class, is_shared = get_icon_css_class(gid, servergroup.user_id)
return jsonify(
node=self.blueprint.generate_browser_node(
gid,
None,
servergroup.name,
get_icon_css_class(gid, servergroup.user_id),
icon_class,
True,
self.node_type,
can_delete=True # This is user created hence can deleted
can_delete=True, # This is user created hence can delete
is_shared=is_shared
)
)
@ -311,16 +316,18 @@ class ServerGroupView(NodeView):
data['id'] = sg.id
data['name'] = sg.name
icon_class, is_shared = get_icon_css_class(sg.id, sg.user_id)
return jsonify(
node=self.blueprint.generate_browser_node(
"%d" % sg.id,
None,
sg.name,
get_icon_css_class(sg.id, sg.user_id),
icon_class,
True,
self.node_type,
# This is user created hence can deleted
can_delete=True
can_delete=True,
is_shared=is_shared
)
)
except exc.IntegrityError:
@ -399,14 +406,17 @@ class ServerGroupView(NodeView):
groups = ServerGroup.query.filter_by(user_id=current_user.id)
for group in groups:
icon_class, is_shared = get_icon_css_class(group.id,
group.user_id)
nodes.append(
self.blueprint.generate_browser_node(
"%d" % group.id,
None,
group.name,
get_icon_css_class(group.id, group.user_id),
icon_class,
True,
self.node_type
self.node_type,
is_shared=is_shared
)
)
else:
@ -417,12 +427,15 @@ class ServerGroupView(NodeView):
errormsg=gettext("Could not find the server group.")
)
icon_class, is_shared = get_icon_css_class(group.id,
group.user_id)
nodes = self.blueprint.generate_browser_node(
"%d" % (group.id), None,
group.name,
get_icon_css_class(group.id, group.user_id),
icon_class,
True,
self.node_type
self.node_type,
is_shared=is_shared
)
return make_json_response(data=nodes)

View File

@ -1201,7 +1201,7 @@ class ServerNode(PGChildNodeView):
)
# To check ssl configuration
is_ssl, connection_params = self.check_ssl_fields(connection_params)
_, connection_params = self.check_ssl_fields(connection_params)
# set the connection params again in the data
if 'connection_params' in data:
data['connection_params'] = connection_params
@ -1221,8 +1221,8 @@ class ServerNode(PGChildNodeView):
config.ALLOW_SAVE_PASSWORD else 0,
comment=data.get('comment', None),
role=data.get('role', None),
db_res=','.join(data['db_res'])
if 'db_res' in data else None,
db_res=','.join(data['db_res']) if 'db_res' in data and
isinstance(data['db_res'], list) else None,
bgcolor=data.get('bgcolor', None),
fgcolor=data.get('fgcolor', None),
service=data.get('service', None),
@ -1763,7 +1763,7 @@ class ServerNode(PGChildNodeView):
if conn.connected():
# Execute the command for reload configuration for the server
status, rid = conn.execute_scalar("SELECT pg_reload_conf();")
status, _ = conn.execute_scalar("SELECT pg_reload_conf();")
if not status:
return internal_server_error(
@ -1782,7 +1782,7 @@ class ServerNode(PGChildNodeView):
def create_restore_point(self, gid, sid):
"""
This method will creates named restore point
This method will create named restore point
Args:
gid: Server group ID

View File

@ -44,9 +44,28 @@ define('pgadmin.node.server', [
title: function(d, action) {
if(action == 'create') {
return gettext('Register - %s', this.label);
} else if (action == 'copy') {
return gettext('Copy Server - %s', d.label);
}
return d._label??'';
},
copy: function(d) {
// This function serves the purpose of facilitating modifications
// during the server copying process.
// Changing the name of the server to "Copy of <existing name>"
d.name = gettext('Copy of %s', d.name);
// If existing server is a shared server from another user then
// copy this server as a local server for the current user.
if (d?.shared && d.user_id != current_user?.id) {
d.gid = null;
d.user_id = current_user?.id;
d.shared = false;
d.server_owner = null;
d.shared_username = null;
}
return d;
},
Init: function() {
/* Avoid multiple registration of same menus */
if (this.initialized)
@ -135,6 +154,11 @@ define('pgadmin.node.server', [
data: {
data_disabled: gettext('SSH Tunnel password is not saved for selected server.'),
},
}, {
name: 'copy_server', node: 'server', module: this,
applies: ['object', 'context'], callback: 'show_obj_properties',
label: gettext('Copy Server...'), data: {action: 'copy'},
priority: 4,
}]);
_.bindAll(this, 'connection_lost');
@ -501,7 +525,9 @@ define('pgadmin.node.server', [
},
getSchema: (treeNodeInfo, itemNodeData)=>{
return new ServerSchema(
getNodeListById(pgBrowser.Nodes['server_group'], treeNodeInfo, itemNodeData),
getNodeListById(pgBrowser.Nodes['server_group'], treeNodeInfo, itemNodeData, {},
// Filter out shared servers group, it should not be visible.
(server)=> !server.is_shared),
itemNodeData.user_id,
{
gid: treeNodeInfo['server_group']._id,

View File

@ -370,6 +370,14 @@ export default class ServerSchema extends BaseUISchema {
validate(state, setError) {
let errmsg = null;
if(isEmptyString(state.gid)) {
errmsg = gettext('Server group must be specified.');
setError('gid', errmsg);
return true;
} else {
setError('gid', null);
}
if (isEmptyString(state.service)) {
errmsg = gettext('Either Host name or Service must be specified.');
if(isEmptyString(state.host)) {

View File

@ -95,6 +95,11 @@ define('pgadmin.browser.node', [
}
return d._label??'';
},
copy: function(d) {
// This function serves the purpose of facilitating modifications
// during the copying process of any node.
return d;
},
hasId: true,
///////
// Initialization function
@ -407,6 +412,36 @@ define('pgadmin.browser.node', [
onSave: onSave,
onClose: onClose,
});
} else if (args.action == 'copy') {
// This else-if block is used to copy the existing object and
// open the respective dialog. Add the copied object into the object
// browser tree upon the 'Save' button click.
treeNodeInfo = pgBrowser.tree.getTreeNodeHierarchy(nodeItem);
const panelId = _.uniqueId(BROWSER_PANELS.EDIT_PROPERTIES);
const onClose = (force=false)=>pgBrowser.docker.close(panelId, force);
const onSave = (newNodeData)=>{
// Clear the cache for this node now.
setTimeout(()=>{
this.clear_cache.apply(this, item);
}, 0);
try {
pgBrowser.Events.trigger(
'pgadmin:browser:tree:add', _.clone(newNodeData.node),
{'server_group': treeNodeInfo['server_group']}
);
} catch (e) {
console.warn(e.stack || e);
}
onClose();
};
this.showPropertiesDialog(panelId, panelTitle, {
treeNodeInfo: treeNodeInfo,
item: nodeItem,
nodeData: nodeData,
actionType: 'copy',
onSave: onSave,
onClose: onClose,
});
} else {
const panelId = BROWSER_PANELS.EDIT_PROPERTIES+nodeData.id;
const onClose = (force=false)=>pgBrowser.docker.close(panelId, force);

View File

@ -11,6 +11,7 @@ import CloudWizard from './CloudWizard';
import getApiInstance from '../../../../static/js/api_instance';
import { BROWSER_PANELS } from '../../../../browser/static/js/constants';
import pgAdmin from 'sources/pgadmin';
import current_user from 'pgadmin.user_management.current_user';
// Cloud Wizard
define('pgadmin.misc.cloud', [
@ -43,7 +44,7 @@ define('pgadmin.misc.cloud', [
priority: 15,
label: gettext('Deploy Cloud Instance...'),
icon: 'wcTabIcon icon-server',
enable: true,
enable: 'canCreate',
data: {action: 'create'},
category: 'register',
node: 'server_group',
@ -55,7 +56,7 @@ define('pgadmin.misc.cloud', [
priority: 15,
label: gettext('Deploy Cloud Instance...'),
icon: 'wcTabIcon icon-server',
enable: true,
enable: 'canCreate',
data: {action: 'create'},
category: 'register',
node: 'server',
@ -64,6 +65,10 @@ define('pgadmin.misc.cloud', [
pgBrowser.add_menus(menus);
return this;
},
canCreate: function(node){
let serverOwner = node.user_id;
return (serverOwner == current_user.id || _.isUndefined(serverOwner));
},
// Callback to draw Wizard Dialog
start_cloud_wizard: function() {

View File

@ -27,17 +27,29 @@ export default function ObjectNodeProperties({panelId, node, treeNodeInfo, nodeD
let serverInfo = treeNodeInfo && ('server' in treeNodeInfo) &&
pgAdmin.Browser.serverInfo && pgAdmin.Browser.serverInfo[treeNodeInfo.server._id];
let inCatalog = treeNodeInfo && ('catalog' in treeNodeInfo);
let urlBase = generateNodeUrl.call(node, treeNodeInfo, actionType, nodeData, false, node.url_jump_after_node);
let isActionTypeCopy = actionType == 'copy';
// If the actionType is set to 'copy' it is necessary to retrieve the details
// of the existing node. Therefore, specify the actionType as 'edit' to
// facilitate this process.
let urlBase = generateNodeUrl.call(node, treeNodeInfo, isActionTypeCopy ? 'edit' : actionType, nodeData, false, node.url_jump_after_node);
const api = getApiInstance();
// To check node data is updated or not
const staleCounter = useRef(0);
const url = (isNew)=>{
return urlBase + (isNew ? '' : nodeData._id);
};
const isDirty = useRef(false); // usefull for warnings
const isDirty = useRef(false); // useful for warnings
let warnOnCloseFlag = true;
const confirmOnCloseReset = usePreferences().getPreferencesForModule('browser').confirm_on_properties_close;
let updatedData = ['table', 'partition'].includes(nodeType) && !_.isEmpty(nodeData.rows_cnt) ? {rows_cnt: nodeData.rows_cnt} : undefined;
let schema = node.getSchema.call(node, treeNodeInfo, nodeData);
// We only have two actionTypes, 'create' and 'edit' to initiate the dialog,
// so if isActionTypeCopy is true, we should revert back to "create" since
// we are duplicating the node.
if (isActionTypeCopy) {
actionType = 'create';
}
let onError = (err)=> {
if(err.response){
@ -51,7 +63,7 @@ export default function ObjectNodeProperties({panelId, node, treeNodeInfo, nodeD
/* Called when dialog is opened in edit mode, promise required */
let initData = ()=>new Promise((resolve, reject)=>{
if(actionType === 'create') {
if(actionType === 'create' && !isActionTypeCopy) {
resolve({});
} else {
// Do not call the API if tab is not active.
@ -60,7 +72,13 @@ export default function ObjectNodeProperties({panelId, node, treeNodeInfo, nodeD
}
api.get(url(false))
.then((res)=>{
resolve(res.data);
let data = res.data;
if (isActionTypeCopy) {
// Delete the idAttribute while copying the node.
delete data[schema.idAttribute];
data = node.copy(data);
}
resolve(data);
})
.catch((err)=>{
pgAdmin.Browser.notifier.pgNotifier('error', err, gettext('Failed to fetch data'), function(msg) {
@ -192,7 +210,6 @@ export default function ObjectNodeProperties({panelId, node, treeNodeInfo, nodeD
inCatalog: inCatalog,
};
let schema = node.getSchema.call(node, treeNodeInfo, nodeData);
// Show/Hide security group for nodes under the catalog
if('catalog' in treeNodeInfo
&& formType !== 'tab') {

View File

@ -191,7 +191,7 @@ class Server(db.Model):
tunnel_port = db.Column(
db.Integer(),
db.CheckConstraint('port <= 65534'),
nullable=True)
nullable=True, default=22)
tunnel_username = db.Column(db.String(64), nullable=True)
tunnel_authentication = db.Column(
db.Integer(),
@ -201,7 +201,7 @@ class Server(db.Model):
)
tunnel_identity_file = db.Column(db.String(64), nullable=True)
tunnel_password = db.Column(PgAdminDbBinaryString())
tunnel_keep_alive = db.Column(db.Integer(), nullable=True)
tunnel_keep_alive = db.Column(db.Integer(), nullable=True, default=0)
shared = db.Column(db.Boolean(), nullable=False)
shared_username = db.Column(db.String(64), nullable=True)
kerberos_conn = db.Column(db.Boolean(), nullable=False, default=0)

View File

@ -42,6 +42,10 @@ describe('ServerSchema', ()=>{
let state = {};
let setError = jest.fn();
schemaObj.validate(state, setError);
expect(setError).toHaveBeenCalledWith('gid', 'Server group must be specified.');
state.gid = 1;
schemaObj.validate(state, setError);
expect(setError).toHaveBeenCalledWith('host', 'Either Host name or Service must be specified.');