diff --git a/docs/en_US/images/file_menu.png b/docs/en_US/images/file_menu.png index a81992f1a..f00427e7f 100644 Binary files a/docs/en_US/images/file_menu.png and b/docs/en_US/images/file_menu.png differ diff --git a/docs/en_US/images/object_menu.png b/docs/en_US/images/object_menu.png index e7b2990e1..32d4c3460 100644 Binary files a/docs/en_US/images/object_menu.png and b/docs/en_US/images/object_menu.png differ diff --git a/docs/en_US/images/runtime_menu.png b/docs/en_US/images/runtime_menu.png index 427948d99..be943db92 100644 Binary files a/docs/en_US/images/runtime_menu.png and b/docs/en_US/images/runtime_menu.png differ diff --git a/docs/en_US/menu_bar.rst b/docs/en_US/menu_bar.rst index 2f4b40592..55d78837b 100644 --- a/docs/en_US/menu_bar.rst +++ b/docs/en_US/menu_bar.rst @@ -42,6 +42,12 @@ following options (in alphabetical order): +-----------------------------+--------------------------------------------------------------------------------------------------------------------------+ | Option | Action | +=============================+==========================================================================================================================+ +| *Register* | | +| | | +| 1) *Server* | Click to open the :ref:`Server ` dialog to register a server. | +| | | +| 2) *Deploy Cloud Instance*| Click to open the :ref:`Cloud Deployment ` dialog to deploy an cloud instance. | ++-----------------------------+--------------------------------------------------------------------------------------------------------------------------+ | *Change Password...* | Click to open the :ref:`Change Password... ` 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 ` 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. | +-----------------------------+--------------------------------------------------------------------------------------------------------------------------+ diff --git a/docs/en_US/release_notes_8_2.rst b/docs/en_US/release_notes_8_2.rst index 861b1c8ca..079020816 100644 --- a/docs/en_US/release_notes_8_2.rst +++ b/docs/en_US/release_notes_8_2.rst @@ -22,6 +22,7 @@ New features | `Issue #2483 `_ - Administer pgAdmin Users and Preferences Using the Command Line Interface (CLI). | `Issue #5908 `_ - Allow users to convert View/Edit table into a Query tool to enable editing the SQL generated. + | `Issue #6085 `_ - Added copy server support, allowing the duplication of existing servers with the option to make certain modifications. | `Issue #7016 `_ - Added keep-alive support for SSH sessions when connecting to a PostgreSQL server via an SSH tunnel. Housekeeping diff --git a/web/pgadmin/browser/server_groups/__init__.py b/web/pgadmin/browser/server_groups/__init__.py index d9cd991e4..0a26f18cf 100644 --- a/web/pgadmin/browser/server_groups/__init__.py +++ b/web/pgadmin/browser/server_groups/__init__.py @@ -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) diff --git a/web/pgadmin/browser/server_groups/servers/__init__.py b/web/pgadmin/browser/server_groups/servers/__init__.py index 76937948a..03905352a 100644 --- a/web/pgadmin/browser/server_groups/servers/__init__.py +++ b/web/pgadmin/browser/server_groups/servers/__init__.py @@ -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 diff --git a/web/pgadmin/browser/server_groups/servers/static/js/server.js b/web/pgadmin/browser/server_groups/servers/static/js/server.js index 6694c855b..1e8d453aa 100644 --- a/web/pgadmin/browser/server_groups/servers/static/js/server.js +++ b/web/pgadmin/browser/server_groups/servers/static/js/server.js @@ -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 " + 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, diff --git a/web/pgadmin/browser/server_groups/servers/static/js/server.ui.js b/web/pgadmin/browser/server_groups/servers/static/js/server.ui.js index 5e31fb36a..ecbf7cfbf 100644 --- a/web/pgadmin/browser/server_groups/servers/static/js/server.ui.js +++ b/web/pgadmin/browser/server_groups/servers/static/js/server.ui.js @@ -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)) { diff --git a/web/pgadmin/browser/static/js/node.js b/web/pgadmin/browser/static/js/node.js index cfbd0f390..c1b29f0c5 100644 --- a/web/pgadmin/browser/static/js/node.js +++ b/web/pgadmin/browser/static/js/node.js @@ -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); diff --git a/web/pgadmin/misc/cloud/static/js/cloud.js b/web/pgadmin/misc/cloud/static/js/cloud.js index 2dedad4a0..5ed62516a 100644 --- a/web/pgadmin/misc/cloud/static/js/cloud.js +++ b/web/pgadmin/misc/cloud/static/js/cloud.js @@ -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() { diff --git a/web/pgadmin/misc/properties/ObjectNodeProperties.jsx b/web/pgadmin/misc/properties/ObjectNodeProperties.jsx index 9812ce14c..bbcd17404 100644 --- a/web/pgadmin/misc/properties/ObjectNodeProperties.jsx +++ b/web/pgadmin/misc/properties/ObjectNodeProperties.jsx @@ -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') { diff --git a/web/pgadmin/model/__init__.py b/web/pgadmin/model/__init__.py index 2555c0f34..04c2ce00e 100644 --- a/web/pgadmin/model/__init__.py +++ b/web/pgadmin/model/__init__.py @@ -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) diff --git a/web/regression/javascript/schema_ui_files/server.ui.spec.js b/web/regression/javascript/schema_ui_files/server.ui.spec.js index 218811c91..47a5d1727 100644 --- a/web/regression/javascript/schema_ui_files/server.ui.spec.js +++ b/web/regression/javascript/schema_ui_files/server.ui.spec.js @@ -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.');