diff --git a/docs/en_US/images/replica_nodes_general.png b/docs/en_US/images/replica_nodes_general.png new file mode 100644 index 000000000..37da75efd Binary files /dev/null and b/docs/en_US/images/replica_nodes_general.png differ diff --git a/docs/en_US/images/replica_nodes_replication.png b/docs/en_US/images/replica_nodes_replication.png new file mode 100644 index 000000000..2c7b8c7a5 Binary files /dev/null and b/docs/en_US/images/replica_nodes_replication.png differ diff --git a/docs/en_US/managing_cluster_objects.rst b/docs/en_US/managing_cluster_objects.rst index f18c063a2..0bbe5c039 100644 --- a/docs/en_US/managing_cluster_objects.rst +++ b/docs/en_US/managing_cluster_objects.rst @@ -18,4 +18,5 @@ database, right-click on the *Databases* node, and select *Create Database...* resource_group_dialog role_dialog tablespace_dialog + replica_nodes_dialog role_reassign_dialog \ No newline at end of file diff --git a/docs/en_US/replica_nodes_dialog.rst b/docs/en_US/replica_nodes_dialog.rst new file mode 100644 index 000000000..0ce5f792b --- /dev/null +++ b/docs/en_US/replica_nodes_dialog.rst @@ -0,0 +1,46 @@ +.. _replica_nodes_dialog: + +**************************** +`Replica Node Dialog`:index: +**************************** + +Use The *Replica Node* dialog to view a standby instance being replicated +using log based streaming replication. Streaming replication allows a standby +server to stay more up-to-date than is possible with file-based log shipping. +The standby connects to the primary, which streams WAL records to the standby as +they're generated, without waiting for the WAL file to be filled. + +The *Replica Node* dialog organizes the information through the following tabs: +*General*, *Replication Slot* + +.. image:: images/replica_nodes_general.png + :alt: Replica Node dialog general tab + :align: center + +* The *PID* field is the process ID of a WAL sender process. +* The *Username* field is the name of the user logged into this WAL sender process. +* The *App Name* field is the name of the application that is connected to this WAL sender. +* The *Client Address* field is the IP address of the client connected to this WAL sender. + If this field is null, it indicates that the client is connected via a Unix socket on the server machine. +* The *Client Hostname* field is the host name of the connected client, as reported by a reverse DNS lookup + of client_addr.This field will only be non-null for IP connections, and only when log_hostname is enabled. +* The *Client Port* field is the TCP port number that the client is using for communication with + this WAL sender, or -1 if a Unix socket is used. +* The *State* field is the current WAL sender state. + +Click the *Replication Slot* tab to continue. + +.. image:: images/replica_nodes_replication.png + :alt: Replica Node dialog replication slot tab + :align: center + +* The *Slot Name* field is a unique, cluster-wide identifier for the replication slot. +* The *Slot Type* field is the slot type - physical or logical +* The *Active* field is True if this slot is currently actively being used. + +Other buttons: + +* Click the *Info* button (i) to access online help. +* Click the *Save* button to save work. +* Click the *Close* button to exit without saving work. +* Click the *Reset* button to restore configuration parameters. diff --git a/web/pgadmin/browser/server_groups/servers/__init__.py b/web/pgadmin/browser/server_groups/servers/__init__.py index e1afde15b..331120338 100644 --- a/web/pgadmin/browser/server_groups/servers/__init__.py +++ b/web/pgadmin/browser/server_groups/servers/__init__.py @@ -15,6 +15,7 @@ from flask import render_template, request, make_response, jsonify, \ from flask_babel import gettext from flask_security import current_user, login_required from psycopg.conninfo import make_conninfo, conninfo_to_dict + from pgadmin.browser.server_groups.servers.types import ServerType from pgadmin.browser.utils import PGChildNodeView from pgadmin.utils.ajax import make_json_response, bad_request, forbidden, \ @@ -30,7 +31,8 @@ from pgadmin.utils.driver import get_driver from pgadmin.utils.master_password import get_crypt_key from pgadmin.utils.exception import CryptKeyMissing from pgadmin.tools.schema_diff.node_registry import SchemaDiffRegistry -from pgadmin.browser.server_groups.servers.utils import is_valid_ipaddress +from pgadmin.browser.server_groups.servers.utils import \ + is_valid_ipaddress, get_replication_type from pgadmin.utils.constants import UNAUTH_REQ, MIMETYPE_APP_JS, \ SERVER_CONNECTION_CLOSED from sqlalchemy import or_ @@ -343,6 +345,9 @@ class ServerModule(sg.ServerGroupPluginModule): from .tablespaces import blueprint as module self.submodules.append(module) + from .replica_nodes import blueprint as module + self.submodules.append(module) + super().register(app, options) # We do not have any preferences for server node. @@ -469,7 +474,7 @@ class ServerNode(PGChildNodeView): }], 'check_pgpass': [{'get': 'check_pgpass'}], 'clear_saved_password': [{'put': 'clear_saved_password'}], - 'clear_sshtunnel_password': [{'put': 'clear_sshtunnel_password'}] + 'clear_sshtunnel_password': [{'put': 'clear_sshtunnel_password'}], }) SSL_MODES = ['prefer', 'require', 'verify-ca', 'verify-full'] @@ -1247,6 +1252,7 @@ class ServerNode(PGChildNodeView): connected = False user = None manager = None + replication_type = None if 'connect_now' in data and data['connect_now']: manager = get_driver(PG_DEFAULT_DRIVER).connection_manager( @@ -1324,6 +1330,8 @@ class ServerNode(PGChildNodeView): server.id), tunnel_password) + replication_type = get_replication_type(conn, + manager.version) user = manager.user_info connected = True @@ -1337,6 +1345,7 @@ class ServerNode(PGChildNodeView): username=server.username, user=user, connected=connected, + replication_type=replication_type, shared=server.shared, server_type=manager.server_type if manager and manager.server_type @@ -1427,6 +1436,7 @@ class ServerNode(PGChildNodeView): in_recovery = None wal_paused = None errmsg = None + replication_type = None if connected: status, result, in_recovery, wal_paused =\ recovery_state(conn, manager.version) @@ -1436,10 +1446,13 @@ class ServerNode(PGChildNodeView): manager.release() errmsg = "{0} : {1}".format(server.name, result) + replication_type = get_replication_type(conn, manager.version) + return make_json_response( data={ 'icon': server_icon_and_background(connected, manager, server), 'connected': connected, + 'replication_type': replication_type, 'in_recovery': in_recovery, 'wal_pause': wal_paused, 'server_type': manager.server_type if connected else "pg", @@ -1709,6 +1722,8 @@ class ServerNode(PGChildNodeView): _, _, in_recovery, wal_paused =\ recovery_state(conn, manager.version) + replication_type = get_replication_type(conn, manager.version) + return make_json_response( success=1, info=gettext("Server connected."), @@ -1716,6 +1731,7 @@ class ServerNode(PGChildNodeView): 'icon': server_icon_and_background(True, manager, server), 'connected': True, 'server_type': manager.server_type, + 'replication_type': replication_type, 'type': manager.server_type, 'version': manager.version, 'db': manager.db, diff --git a/web/pgadmin/browser/server_groups/servers/replica_nodes/__init__.py b/web/pgadmin/browser/server_groups/servers/replica_nodes/__init__.py new file mode 100644 index 000000000..17103c86d --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/replica_nodes/__init__.py @@ -0,0 +1,285 @@ +########################################################################## +# +# pgAdmin 4 - PostgreSQL Tools +# +# Copyright (C) 2013 - 2024, The pgAdmin Development Team +# This software is released under the PostgreSQL Licence +# +########################################################################## + +"""Implements Replication Nodes for PG/PPAS 9.4 and above""" + +from functools import wraps + +from pgadmin.browser.server_groups import servers +from flask import render_template +from flask_babel import gettext +from pgadmin.browser.collection import CollectionNodeModule +from pgadmin.browser.utils import PGChildNodeView +from pgadmin.utils.ajax import make_json_response, \ + make_response as ajax_response, internal_server_error, gone +from pgadmin.utils.ajax import precondition_required +from pgadmin.utils.driver import get_driver +from config import PG_DEFAULT_DRIVER +from pgadmin.browser.server_groups.servers.utils import get_replication_type + + +class ReplicationNodesModule(CollectionNodeModule): + """ + class ReplicationNodesModule(CollectionNodeModule) + + A module class for Replication Nodes node derived from + CollectionNodeModule. + + Methods: + ------- + * __init__(*args, **kwargs) + - Method is used to initialize the ReplicationNodesModule and it's + base module. + + * backend_supported(manager, **kwargs) + - This function is used to check the database server type and version. + Replication Nodes only supported in PG/PPAS 9.4 and above. + + * get_nodes(gid, sid, did) + - Method is used to generate the browser collection node. + + * node_inode() + - Method is overridden from its base class to make the node as leaf node. + + * script_load() + - Load the module script for Replication Nodes, when any of the server + node is initialized. + """ + + _NODE_TYPE = 'replica_nodes' + _COLLECTION_LABEL = gettext("Replica Nodes") + + def __init__(self, *args, **kwargs): + """ + Method is used to initialize the ReplicationNodesModule and + it's base module. + + Args: + *args: + **kwargs: + """ + super().__init__(*args, **kwargs) + + def get_nodes(self, gid, sid): + """ + Method is used to generate the browser collection node + + Args: + gid: Server Group ID + sid: Server ID + """ + yield self.generate_browser_collection_node(sid) + + @property + def node_inode(self): + """ + Override this property to make the node as leaf node. + + Returns: False as this is the leaf node + """ + return False + + @property + def script_load(self): + """ + Load the module script for Replication Nodes, when any of the server + node is initialized. + + Returns: node type of the server module. + """ + return servers.ServerModule.NODE_TYPE + + def backend_supported(self, manager, **kwargs): + """ + Load this module if replication type exists + """ + if super().backend_supported(manager, **kwargs): + conn = manager.connection(sid=kwargs['sid']) + + replication_type = get_replication_type(conn, manager.version) + return bool(replication_type) + + +blueprint = ReplicationNodesModule(__name__) + + +class ReplicationNodesView(PGChildNodeView): + """ + class ReplicationNodesView(NodeView) + + A view class for Replication Nodes node derived from NodeView. + This class is responsible for all the stuff related to view like + showing properties/list of Replication Nodes nodes + + Methods: + ------- + * __init__(**kwargs) + - Method is used to initialize the ReplicationNodesView, + and it's base view. + + * check_precondition() + - This function will behave as a decorator which will checks + database connection before running view, it will also attaches + manager,conn & template_path properties to self + + * list() + - This function is used to list all the Replication Nodes within + that collection. + + * nodes() + - This function will used to create all the child node within that + collection. Here it will create all the Replication Nodes node. + + * properties(gid, sid, did, pid) + - This function will show the properties of the selected node + """ + + node_type = blueprint.node_type + BASE_TEMPLATE_PATH = 'replica_nodes/sql/#{0}#' + + parent_ids = [ + {'type': 'int', 'id': 'gid'}, + {'type': 'int', 'id': 'sid'} + ] + ids = [ + {'type': 'int', 'id': 'pid'} + ] + + operations = dict({ + 'obj': [ + {'get': 'properties'}, + {'get': 'list'} + ], + 'nodes': [{'get': 'nodes'}, {'get': 'nodes'}], + 'replication_slots': [{'get': 'replication_slots'}, + {'get': 'replication_slots'}], + }) + + def __init__(self, **kwargs): + """ + Method is used to initialize the ReplicationNodesView and, + it's base view. + Also initialize all the variables create/used dynamically like conn, + template_path. + + Args: + **kwargs: + """ + self.conn = None + self.template_path = None + + super().__init__(**kwargs) + + def check_precondition(f): + """ + This function will behave as a decorator which will checks + database connection before running view, it will also attaches + manager,conn & template_path properties to self + """ + + @wraps(f) + def wrap(*args, **kwargs): + # Here args[0] will hold self & kwargs will hold gid,sid,did + self = args[0] + self.driver = get_driver(PG_DEFAULT_DRIVER) + self.manager = self.driver.connection_manager(kwargs['sid']) + self.conn = self.manager.connection() + + if not self.conn.connected(): + return precondition_required( + gettext( + "Connection to the server has been lost." + ) + ) + + self.template_path = self.BASE_TEMPLATE_PATH.format( + self.manager.version) + + return f(*args, **kwargs) + + return wrap + + @check_precondition + def list(self, gid, sid): + """ + This function is used to list all the Replication Nodes within + that collection. + + Args: + gid: Server Group ID + sid: Server ID + """ + sql = render_template( + "/".join([self.template_path, self._PROPERTIES_SQL])) + status, res = self.conn.execute_dict(sql) + + if not status: + return internal_server_error(errormsg=res) + return ajax_response( + response=res['rows'], + status=200 + ) + + @check_precondition + def nodes(self, gid, sid): + """ + This function will used to create all the child node within that + collection. Here it will create all the Replication Nodes node. + + Args: + gid: Server Group ID + sid: Server ID + """ + res = [] + sql = render_template("/".join([self.template_path, self._NODES_SQL])) + status, result = self.conn.execute_2darray(sql) + if not status: + return internal_server_error(errormsg=result) + + for row in result['rows']: + res.append( + self.blueprint.generate_browser_node( + row['pid'], + sid, + row['name'], + icon="icon-replica_nodes" + )) + + return make_json_response( + data=res, + status=200 + ) + + @check_precondition + def properties(self, gid, sid, pid): + """ + This function will show the properties of the selected node. + + Args: + gid: Server Group ID + sid: Server ID + pid: Replication Nodes ID + """ + sql = render_template( + "/".join([self.template_path, self._PROPERTIES_SQL]), pid=pid) + status, res = self.conn.execute_dict(sql) + + if not status: + return internal_server_error(errormsg=res) + + if len(res['rows']) == 0: + return gone(gettext("""Could not find the Replication Node.""")) + + return ajax_response( + response=res['rows'][0], + status=200 + ) + + +ReplicationNodesView.register_node_view(blueprint) diff --git a/web/pgadmin/browser/server_groups/servers/replica_nodes/static/img/coll-replica_nodes.svg b/web/pgadmin/browser/server_groups/servers/replica_nodes/static/img/coll-replica_nodes.svg new file mode 100644 index 000000000..176a1517b --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/replica_nodes/static/img/coll-replica_nodes.svg @@ -0,0 +1 @@ +server \ No newline at end of file diff --git a/web/pgadmin/browser/server_groups/servers/replica_nodes/static/img/replica_nodes.svg b/web/pgadmin/browser/server_groups/servers/replica_nodes/static/img/replica_nodes.svg new file mode 100644 index 000000000..176a1517b --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/replica_nodes/static/img/replica_nodes.svg @@ -0,0 +1 @@ +server \ No newline at end of file diff --git a/web/pgadmin/browser/server_groups/servers/replica_nodes/static/js/replica_node.js b/web/pgadmin/browser/server_groups/servers/replica_nodes/static/js/replica_node.js new file mode 100644 index 000000000..820388497 --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/replica_nodes/static/js/replica_node.js @@ -0,0 +1,61 @@ +///////////////////////////////////////////////////////////// +// +// pgAdmin 4 - PostgreSQL Tools +// +// Copyright (C) 2013 - 2024, The pgAdmin Development Team +// This software is released under the PostgreSQL Licence +// +////////////////////////////////////////////////////////////// + +import ReplicaNodeSchema from './replica_node.ui'; + +define('pgadmin.node.replica_nodes', [ + 'sources/gettext', 'sources/url_for', 'pgadmin.browser', + 'pgadmin.browser.collection', +], function(gettext, url_for, pgBrowser) { + + // Extend the browser's collection class for replica nodes collection + if (!pgBrowser.Nodes['coll-replica_nodes']) { + pgBrowser.Nodes['coll-replica_nodes'] = + pgBrowser.Collection.extend({ + node: 'replica_nodes', + label: gettext('Replica Nodes'), + type: 'coll-replica_nodes', + columns: ['pid', 'name', 'usename', 'state'], + canEdit: false, + canDrop: false, + canDropCascade: false, + }); + } + + // Extend the browser's node class for replica nodes node + if (!pgBrowser.Nodes['replica_nodes']) { + pgBrowser.Nodes['replica_nodes'] = pgBrowser.Node.extend({ + parent_type: 'server', + type: 'replica_nodes', + epasHelp: false, + sqlAlterHelp: '', + sqlCreateHelp: '', + dialogHelp: url_for('help.static', {'filename': 'replica_nodes_dialog.html'}), + label: gettext('Replica Nodes'), + hasSQL: false, + hasScriptTypes: false, + canDrop: false, + Init: function() { + + // Avoid multiple registration of menus + if (this.initialized) { + return; + } + + this.initialized = true; + }, + + getSchema: ()=>{ + return new ReplicaNodeSchema(); + }, + }); + } + + return pgBrowser.Nodes['coll-replica_nodes']; +}); diff --git a/web/pgadmin/browser/server_groups/servers/replica_nodes/static/js/replica_node.ui.js b/web/pgadmin/browser/server_groups/servers/replica_nodes/static/js/replica_node.ui.js new file mode 100644 index 000000000..ab936738d --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/replica_nodes/static/js/replica_node.ui.js @@ -0,0 +1,83 @@ +///////////////////////////////////////////////////////////// +// +// pgAdmin 4 - PostgreSQL Tools +// +// Copyright (C) 2013 - 2024, The pgAdmin Development Team +// This software is released under the PostgreSQL Licence +// +////////////////////////////////////////////////////////////// + +import gettext from 'sources/gettext'; +import BaseUISchema from 'sources/SchemaView/base_schema.ui'; + +export default class ReplicaNodeSchema extends BaseUISchema { + get idAttribute() { + return 'pid'; + } + + get baseFields() { + return [ + { + id: 'pid', label: gettext('PID'), type: 'text', mode:['properties', 'edit'], readonly: true, + }, + { + id: 'usename', label: gettext('Username'), type: 'text', mode:['properties', 'edit'], readonly: true, + }, + { + id: 'application_name', label: gettext('App Name'), type: 'text', mode:['properties', 'edit'], readonly: true, + }, + { + id: 'client_addr', label: gettext('Client Address'), type: 'text', mode:['properties', 'edit'], readonly: true, + }, + { + id: 'client_hostname', label: gettext('Client Hostname'), type: 'text', mode:['properties', 'edit'], readonly: true, + }, + { + id: 'client_port', label: gettext('Client Port'), type: 'text', mode:['properties', 'edit'], readonly: true, + }, + { + id: 'state', label: gettext('State'), type: 'text', mode:['properties', 'edit'], readonly: true, + }, + { + id: 'sent_lsn', label: gettext('Sent LSN'), type: 'text', mode:['properties'], readonly: true, + group: gettext('WAL Details') + }, + { + id: 'write_lsn', label: gettext('Write LSN'), type: 'text', mode:['properties'], readonly: true, + group: gettext('WAL Details') + }, + { + id: 'flush_lsn', label: gettext('Flush LSN'), type: 'text', mode:['properties'], readonly: true, + group: gettext('WAL Details') + }, + { + id: 'replay_lsn', label: gettext('Replay LSN'), type: 'text', mode:['properties'], readonly: true, + group: gettext('WAL Details') + }, + { + id: 'write_lag', label: gettext('Write Lag'), type: 'text', mode:['properties'], readonly: true, + group: gettext('WAL Details') + }, + { + id: 'flush_lag', label: gettext('Flush Lag'), type: 'text', mode:['properties'], readonly: true, + group: gettext('WAL Details') + }, + { + id: 'replay_lag', label: gettext('Replay Lag'), type: 'text', mode:['properties'], readonly: true, + group: gettext('WAL Details') + }, + { + id: 'slot_name', label: gettext('Slot Name'), type: 'text', mode:['properties', 'edit'], readonly: true, + group: gettext('Replication Slot') + }, + { + id: 'slot_type', label: gettext('Slot Type'), type: 'text', mode:['properties', 'edit'], readonly: true, + group: gettext('Replication Slot') + }, + { + id: 'active', label: gettext('Active'), type: 'switch', mode:['properties', 'edit'], readonly: true, + group: gettext('Replication Slot') + }, + ]; + } +} diff --git a/web/pgadmin/browser/server_groups/servers/replica_nodes/templates/replica_nodes/sql/default/count.sql b/web/pgadmin/browser/server_groups/servers/replica_nodes/templates/replica_nodes/sql/default/count.sql new file mode 100644 index 000000000..3d751e379 --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/replica_nodes/templates/replica_nodes/sql/default/count.sql @@ -0,0 +1,2 @@ +SELECT count(*) +FROM pg_stat_replication diff --git a/web/pgadmin/browser/server_groups/servers/replica_nodes/templates/replica_nodes/sql/default/nodes.sql b/web/pgadmin/browser/server_groups/servers/replica_nodes/templates/replica_nodes/sql/default/nodes.sql new file mode 100644 index 000000000..0c658bf82 --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/replica_nodes/templates/replica_nodes/sql/default/nodes.sql @@ -0,0 +1,3 @@ +SELECT pid, 'Standby ['||COALESCE(client_addr::text, client_hostname,'Socket')||']' as name +FROM pg_stat_replication +ORDER BY pid diff --git a/web/pgadmin/browser/server_groups/servers/replica_nodes/templates/replica_nodes/sql/default/properties.sql b/web/pgadmin/browser/server_groups/servers/replica_nodes/templates/replica_nodes/sql/default/properties.sql new file mode 100644 index 000000000..c9a533a01 --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/replica_nodes/templates/replica_nodes/sql/default/properties.sql @@ -0,0 +1,8 @@ +SELECT st.*, 'Standby ['||COALESCE(client_addr::text, client_hostname,'Socket')||']' as name, + sl.slot_name, sl.slot_type, sl.active +FROM pg_stat_replication st JOIN pg_replication_slots sl +ON st.pid = sl.active_pid +{% if pid %} +WHERE st.pid={{pid}} +{% endif %} +ORDER BY st.pid diff --git a/web/pgadmin/browser/server_groups/servers/templates/servers/sql/default/replication_type.sql b/web/pgadmin/browser/server_groups/servers/templates/servers/sql/default/replication_type.sql new file mode 100644 index 000000000..4c644e4cc --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/templates/servers/sql/default/replication_type.sql @@ -0,0 +1,7 @@ +SELECT CASE + WHEN (SELECT count(extname) FROM pg_catalog.pg_extension WHERE extname='bdr') > 0 + THEN 'pgd' + WHEN (SELECT COUNT(*) FROM pg_stat_replication) > 0 + THEN 'log' + ELSE NULL +END as type; diff --git a/web/pgadmin/browser/server_groups/servers/tests/test_check_connect.py b/web/pgadmin/browser/server_groups/servers/tests/test_check_connect.py index 6b0772502..225ce31d9 100644 --- a/web/pgadmin/browser/server_groups/servers/tests/test_check_connect.py +++ b/web/pgadmin/browser/server_groups/servers/tests/test_check_connect.py @@ -158,7 +158,9 @@ class ServersConnectTestCase(BaseTestGenerator): self.manager.connection.connected.side_effect = True connection_mock_result.execute_dict.side_effect = \ - [eval(self.mock_data["return_value"])] + [eval(self.mock_data["return_value"]), + # replication type mock + (True, {'rows': [{'type': None}]})] response = self.get_server_connection(server_id) self.assertEqual(response.status_code, diff --git a/web/pgadmin/browser/server_groups/servers/utils.py b/web/pgadmin/browser/server_groups/servers/utils.py index 4f787f454..d5c869b55 100644 --- a/web/pgadmin/browser/server_groups/servers/utils.py +++ b/web/pgadmin/browser/server_groups/servers/utils.py @@ -9,6 +9,8 @@ """Server helper utilities""" from ipaddress import ip_address +from werkzeug.exceptions import InternalServerError +from flask import render_template from pgadmin.utils.crypto import encrypt, decrypt import config @@ -277,3 +279,14 @@ def remove_saved_passwords(user_id): except Exception: db.session.rollback() raise + + +def get_replication_type(conn, sversion): + status, res = conn.execute_dict(render_template( + "/servers/sql/#{0}#/replication_type.sql".format(sversion) + )) + + if not status: + raise InternalServerError(res) + + return res['rows'][0]['type'] diff --git a/web/pgadmin/browser/templates/browser/js/utils.js b/web/pgadmin/browser/templates/browser/js/utils.js index e0b338c83..1f8ab72f8 100644 --- a/web/pgadmin/browser/templates/browser/js/utils.js +++ b/web/pgadmin/browser/templates/browser/js/utils.js @@ -79,7 +79,8 @@ define('pgadmin.browser.utils', 'server_group', 'server', 'coll-tablespace', 'tablespace', 'coll-role', 'role', 'coll-resource_group', 'resource_group', 'coll-database', 'coll-pga_job', 'coll-pga_schedule', 'coll-pga_jobstep', - 'pga_job', 'pga_schedule', 'pga_jobstep' + 'pga_job', 'pga_schedule', 'pga_jobstep', + 'coll-replica_nodes', 'replica_nodes' ]; pgBrowser.utils = { diff --git a/web/pgadmin/dashboard/__init__.py b/web/pgadmin/dashboard/__init__.py index 507094dd8..2cf92ae72 100644 --- a/web/pgadmin/dashboard/__init__.py +++ b/web/pgadmin/dashboard/__init__.py @@ -245,6 +245,8 @@ class DashboardModule(PgAdminModule): 'dashboard.system_statistics', 'dashboard.system_statistics_sid', 'dashboard.system_statistics_did', + 'dashboard.replication_slots', + 'dashboard.replication_stats', ] @@ -646,3 +648,51 @@ def system_statistics(sid=None, did=None): response=resp_data, status=200 ) + + +@blueprint.route('/replication_stats/', + endpoint='replication_stats', methods=['GET']) +@login_required +@check_precondition +def replication_stats(sid=None): + """ + This function is used to list all the Replication slots of the cluster + """ + + if not sid: + return internal_server_error(errormsg='Server ID not specified.') + + sql = render_template("/".join([g.template_path, 'replication_stats.sql'])) + status, res = g.conn.execute_dict(sql) + + if not status: + return internal_server_error(errormsg=str(res)) + + return ajax_response( + response=res['rows'], + status=200 + ) + + +@blueprint.route('/replication_slots/', + endpoint='replication_slots', methods=['GET']) +@login_required +@check_precondition +def replication_slots(sid=None): + """ + This function is used to list all the Replication slots of the cluster + """ + + if not sid: + return internal_server_error(errormsg='Server ID not specified.') + + sql = render_template("/".join([g.template_path, 'replication_slots.sql'])) + status, res = g.conn.execute_dict(sql) + + if not status: + return internal_server_error(errormsg=str(res)) + + return ajax_response( + response=res['rows'], + status=200 + ) diff --git a/web/pgadmin/dashboard/static/js/Dashboard.jsx b/web/pgadmin/dashboard/static/js/Dashboard.jsx index 63c931f73..b3055dda8 100644 --- a/web/pgadmin/dashboard/static/js/Dashboard.jsx +++ b/web/pgadmin/dashboard/static/js/Dashboard.jsx @@ -19,12 +19,9 @@ import { Box, Tab, Tabs } from '@material-ui/core'; import { PgIconButton } from '../../../static/js/components/Buttons'; import CancelIcon from '@material-ui/icons/Cancel'; import StopSharpIcon from '@material-ui/icons/StopSharp'; -import ArrowRightOutlinedIcon from '@material-ui/icons/ArrowRightOutlined'; -import ArrowDropDownOutlinedIcon from '@material-ui/icons/ArrowDropDownOutlined'; import WelcomeDashboard from './WelcomeDashboard'; import ActiveQuery from './ActiveQuery.ui'; import _ from 'lodash'; -import CachedOutlinedIcon from '@material-ui/icons/CachedOutlined'; import EmptyPanelMessage from '../../../static/js/components/EmptyPanelMessage'; import TabPanel from '../../../static/js/components/TabPanel'; import Summary from './SystemStats/Summary'; @@ -37,6 +34,10 @@ import { usePgAdmin } from '../../../static/js/BrowserComponent'; import usePreferences from '../../../preferences/static/js/store'; import ErrorBoundary from '../../../static/js/helpers/ErrorBoundary'; import { parseApiError } from '../../../static/js/api_instance'; +import SectionContainer from './components/SectionContainer'; +import Replication from './Replication'; +import RefreshButton from './components/RefreshButtons'; +import {getExpandCell } from '../../../static/js/components/PgTable'; function parseData(data) { let res = []; @@ -55,11 +56,6 @@ const useStyles = makeStyles((theme) => ({ padding: '8px', display: 'flex', }, - fixedSizeList: { - overflowX: 'hidden !important', - overflow: 'overlay !important', - height: 'auto !important', - }, dashboardPanel: { height: '100%', background: theme.palette.grey[400], @@ -108,22 +104,9 @@ const useStyles = makeStyles((theme) => ({ display: 'flex', flexDirection: 'column' }, - arrowButton: { - fontSize: '2rem !important', - margin: '-7px' - }, terminateButton: { color: theme.palette.error.main }, - buttonClick: { - backgroundColor: theme.palette.grey[400] - }, - refreshButton: { - marginLeft: 'auto', - height: '1.9rem', - width: '2.2rem', - ...theme.mixins.panelBorder, - }, chartCard: { border: '1px solid '+theme.otherVars.borderColor, }, @@ -156,6 +139,9 @@ function Dashboard({ const classes = useStyles(); let tabs = [gettext('Sessions'), gettext('Locks'), gettext('Prepared Transactions')]; let mainTabs = [gettext('General'), gettext('System Statistics')]; + if(treeNodeInfo?.server?.replication_type) { + mainTabs.push(gettext('Replication')); + } let systemStatsTabs = [gettext('Summary'), gettext('CPU'), gettext('Memory'), gettext('Storage')]; const [dashData, setdashData] = useState([]); const [msg, setMsg] = useState(''); @@ -247,8 +233,10 @@ function Dashboard({ sortable: true, resizable: false, disableGlobalFilter: false, + disableResizing: true, width: 35, - minWidth: 0, + maxWidth: 35, + minWidth: 35, id: 'btn-terminate', // eslint-disable-next-line react/display-name Cell: ({ row }) => { @@ -391,40 +379,21 @@ function Dashboard({ width: 35, minWidth: 0, id: 'btn-edit', - Cell: ({ row }) => { - let canEditRow = true; - return ( - - ) : ( - - ) - } - noBorder - onClick={(e) => { - e.preventDefault(); - row.toggleRowExpanded(!row.isExpanded); - let schema = new ActiveQuery({ - query: row.original.query, - backend_type: row.original.backend_type, - state_change: row.original.state_change, - query_start: row.original.query_start, - }); - setSchemaDict(prevState => ({ - ...prevState, - [row.id]: schema - })); - }} - disabled={!canEditRow} - aria-label="View the active session details" - title={gettext('View the active session details')} - /> - ); - }, + Cell: getExpandCell({ + onClick: (row) => { + let schema = new ActiveQuery({ + query: row.original.query, + backend_type: row.original.backend_type, + state_change: row.original.state_change, + query_start: row.original.query_start, + }); + setSchemaDict(prevState => ({ + ...prevState, + [row.id]: schema + })); + }, + title: gettext('View the active session details') + }), }, { accessor: 'pid', @@ -740,6 +709,11 @@ function Dashboard({ },[nodeData]); useEffect(() => { + // disable replication tab + if(!treeNodeInfo?.server?.replication_type && mainTabVal == 2) { + setMainTabVal(0); + } + let url, ssExtensionCheckUrl = url_for('dashboard.check_system_statistics'), message = gettext( @@ -829,24 +803,6 @@ function Dashboard({ return dashData; }, [dashData, activeOnly, tabVal]); - const RefreshButton = () =>{ - return( - } - onClick={(e) => { - e.preventDefault(); - setRefresh(!refresh); - }} - color="default" - aria-label="Refresh" - title={gettext('Refresh')} - > - ); - }; - const showDefaultContents = () => { return ( sid && !serverConnected ? ( @@ -915,57 +871,52 @@ function Dashboard({ > )} {!_.isUndefined(preferences) && preferences.show_activity && ( - - - {dbConnected ? gettext('Database activity') : gettext('Server activity')}{' '} + + + + {tabs.map((tabValue) => { + return ; + })} + { + e.preventDefault(); + setRefresh(!refresh); + }}/> + - - - - {tabs.map((tabValue) => { - return ; - })} - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + )} {/* System Statistics */} @@ -1031,6 +982,10 @@ function Dashboard({ } + {/* Replication */} + + + diff --git a/web/pgadmin/dashboard/static/js/Graphs.jsx b/web/pgadmin/dashboard/static/js/Graphs.jsx index eb444c14d..17c6236c5 100644 --- a/web/pgadmin/dashboard/static/js/Graphs.jsx +++ b/web/pgadmin/dashboard/static/js/Graphs.jsx @@ -8,7 +8,7 @@ ////////////////////////////////////////////////////////////// import React, { useEffect, useRef, useState, useReducer, useMemo } from 'react'; import { DATA_POINT_SIZE } from 'sources/chartjs'; -import ChartContainer from './ChartContainer'; +import ChartContainer from './components/ChartContainer'; import url_for from 'sources/url_for'; import axios from 'axios'; import gettext from 'sources/gettext'; diff --git a/web/pgadmin/dashboard/static/js/Replication/index.jsx b/web/pgadmin/dashboard/static/js/Replication/index.jsx new file mode 100644 index 000000000..63459040d --- /dev/null +++ b/web/pgadmin/dashboard/static/js/Replication/index.jsx @@ -0,0 +1,214 @@ +///////////////////////////////////////////////////////////// +// +// pgAdmin 4 - PostgreSQL Tools +// +// Copyright (C) 2013 - 2024, The pgAdmin Development Team +// This software is released under the PostgreSQL Licence +// +////////////////////////////////////////////////////////////// + +import { Box } from '@material-ui/core'; +import React, { useEffect, useState } from 'react'; + +import gettext from 'sources/gettext'; +import ReplicationSlotsSchema from './replication_slots.ui'; +import PgTable from 'sources/components/PgTable'; +import getApiInstance, { parseApiError } from '../../../../static/js/api_instance'; +import SectionContainer from '../components/SectionContainer'; +import ReplicationStatsSchema from './replication_stats.ui'; +import RefreshButton from '../components/RefreshButtons'; +import { getExpandCell, getSwitchCell } from '../../../../static/js/components/PgTable'; +import { usePgAdmin } from '../../../../static/js/BrowserComponent'; +import url_for from 'sources/url_for'; +import PropTypes from 'prop-types'; + + +const replicationStatsColumns = [{ + accessor: 'view_details', + Header: () => null, + sortable: false, + resizable: false, + disableGlobalFilter: false, + disableResizing: true, + width: 35, + maxWidth: 35, + minWidth: 35, + id: 'btn-edit', + Cell: getExpandCell({ + title: gettext('View details') + }), +}, +{ + accessor: 'pid', + Header: gettext('PID'), + sortable: true, + resizable: true, + disableGlobalFilter: false, + minWidth: 26, + width: 40, +}, +{ + accessor: 'client_addr', + Header: gettext('Client Addr'), + sortable: true, + resizable: true, + disableGlobalFilter: false, + minWidth: 26, + width: 60, +}, +{ + accessor:'state', + Header: gettext('State'), + sortable: true, + resizable: true, + disableGlobalFilter: false, + minWidth: 26, + width: 60 +}, +{ + accessor:'write_lag', + Header: gettext('Write Lag'), + sortable: true, + resizable: true, + disableGlobalFilter: false, + minWidth: 26, + width: 60 +}, +{ + accessor:'flush_lag', + Header: gettext('Flush Lag'), + sortable: true, + resizable: true, + disableGlobalFilter: false, + minWidth: 26, + width: 60 +}, +{ + accessor:'replay_lag', + Header: gettext('Replay Lag'), + sortable: true, + resizable: true, + disableGlobalFilter: false, + minWidth: 26, + width: 60 +}, +{ + accessor:'reply_time', + Header: gettext('Reply Time'), + sortable: true, + resizable: true, + disableGlobalFilter: false, + minWidth: 26, + width: 80 +} +]; + +const replicationSlotsColumns = [{ + accessor: 'view_details', + Header: () => null, + sortable: false, + resizable: false, + disableGlobalFilter: false, + disableResizing: true, + width: 35, + maxWidth: 35, + minWidth: 35, + id: 'btn-details', + Cell: getExpandCell({ + title: gettext('View details') + }), +}, +{ + accessor: 'active_pid', + Header: gettext('Active PID'), + sortable: true, + resizable: true, + disableGlobalFilter: false, + minWidth: 26, + width: 50, +}, +{ + accessor: 'slot_name', + Header: gettext('Slot Name'), + sortable: true, + resizable: true, + disableGlobalFilter: false, + minWidth: 26, + width: 200, +}, +{ + accessor:'active', + Header: gettext('Active'), + sortable: true, + resizable: true, + disableGlobalFilter: false, + minWidth: 26, + width: 60, + Cell: getSwitchCell(), +} +]; + +const replSchemaObj = new ReplicationSlotsSchema(); +const replStatObj = new ReplicationStatsSchema(); + +export default function Replication({treeNodeInfo, pageVisible}) { + const [replicationSlots, setReplicationSlots] = useState([{ + }]); + const [replicationStats, setReplicationStats] = useState([{ + }]); + const pgAdmin = usePgAdmin(); + + const getReplicationData = (endpoint, setter)=>{ + const api = getApiInstance(); + const url = url_for(`dashboard.${endpoint}`, {sid: treeNodeInfo.server._id}); + api.get(url) + .then((res)=>{ + setter(res.data); + }) + .catch((error)=>{ + console.error(error); + pgAdmin.Browser.notifier.error(parseApiError(error)); + }); + }; + + useEffect(()=>{ + if(pageVisible) { + getReplicationData('replication_stats', setReplicationStats); + getReplicationData('replication_slots', setReplicationSlots); + } + }, [pageVisible ]); + + return ( + + { + getReplicationData('replication_stats', setReplicationStats); + }}/>} + title={gettext('Replication Stats')} style={{minHeight: '300px'}}> + + + { + getReplicationData('replication_slots', setReplicationSlots); + }}/>} + title={gettext('Replication Slots')} style={{minHeight: '300px', marginTop: '4px'}}> + + + + ); +} + +Replication.propTypes = { + treeNodeInfo: PropTypes.object.isRequired, + pageVisible: PropTypes.bool, +}; diff --git a/web/pgadmin/dashboard/static/js/Replication/replication_slots.ui.js b/web/pgadmin/dashboard/static/js/Replication/replication_slots.ui.js new file mode 100644 index 000000000..0f5a2a043 --- /dev/null +++ b/web/pgadmin/dashboard/static/js/Replication/replication_slots.ui.js @@ -0,0 +1,52 @@ +///////////////////////////////////////////////////////////// +// +// pgAdmin 4 - PostgreSQL Tools +// +// Copyright (C) 2013 - 2024, The pgAdmin Development Team +// This software is released under the PostgreSQL Licence +// +////////////////////////////////////////////////////////////// + +import gettext from 'sources/gettext'; +import BaseUISchema from 'sources/SchemaView/base_schema.ui'; + +export default class ReplicationSlotsSchema extends BaseUISchema { + constructor(initValues) { + super({ + ...initValues, + }); + } + + get baseFields() { + return [ + { + id: 'slot_name', label: gettext('Slot Name'), type: 'text', mode:['properties'], readonly: true, + group: gettext('Details') + }, + { + id: 'slot_type', label: gettext('Slot Type'), type: 'text', mode:['properties'], readonly: true, + group: gettext('Details') + }, + { + id: 'active', label: gettext('Active'), type: 'switch', mode:['properties'], readonly: true, + group: gettext('Details') + }, + { + id: 'active_pid', label: gettext('Active PID'), type: 'text', mode:['properties'], readonly: true, + group: gettext('Details') + }, + { + id: 'restart_lsn', label: gettext('Restart LSN'), type: 'text', mode:['properties'], readonly: true, + group: gettext('Details') + }, + { + id: 'confirmed_flush_lsn', label: gettext('Confirmed Flush LSN'), type: 'text', mode:['properties'], readonly: true, + group: gettext('Details') + }, + { + id: 'wal_status', label: gettext('WAL Status'), type: 'text', mode:['properties'], readonly: true, + group: gettext('Details') + }, + ]; + } +} diff --git a/web/pgadmin/dashboard/static/js/Replication/replication_stats.ui.js b/web/pgadmin/dashboard/static/js/Replication/replication_stats.ui.js new file mode 100644 index 000000000..ca32d8ec5 --- /dev/null +++ b/web/pgadmin/dashboard/static/js/Replication/replication_stats.ui.js @@ -0,0 +1,80 @@ +///////////////////////////////////////////////////////////// +// +// pgAdmin 4 - PostgreSQL Tools +// +// Copyright (C) 2013 - 2024, The pgAdmin Development Team +// This software is released under the PostgreSQL Licence +// +////////////////////////////////////////////////////////////// + +import gettext from 'sources/gettext'; +import BaseUISchema from 'sources/SchemaView/base_schema.ui'; + +export default class ReplicationStatsSchema extends BaseUISchema { + constructor(initValues) { + super({ + ...initValues, + }); + } + + get baseFields() { + return [ + { + id: 'pid', label: gettext('PID'), type: 'text', mode:['properties'], readonly: true, + group: gettext('Details') + }, + { + id: 'usename', label: gettext('Usename'), type: 'text', mode:['properties'], readonly: true, + group: gettext('Details') + }, + { + id: 'application_name', label: gettext('App Name'), type: 'text', mode:['properties'], readonly: true, + group: gettext('Details') + }, + { + id: 'client_addr', label: gettext('Client Addr'), type: 'text', mode:['properties'], readonly: true, + group: gettext('Details') + }, + { + id: 'client_port', label: gettext('Client Port'), type: 'text', mode:['properties'], readonly: true, + group: gettext('Details') + }, + { + id: 'state', label: gettext('State'), type: 'text', mode:['properties'], readonly: true, + group: gettext('Details') + }, + { + id: 'sent_lsn', label: gettext('Sent LSN'), type: 'text', mode:['properties'], readonly: true, + group: gettext('Details') + }, + { + id: 'write_lsn', label: gettext('Write LSN'), type: 'text', mode:['properties'], readonly: true, + group: gettext('Details') + }, + { + id: 'flush_lsn', label: gettext('Flush LSN'), type: 'text', mode:['properties'], readonly: true, + group: gettext('Details') + }, + { + id: 'replay_lsn', label: gettext('Replay LSN'), type: 'text', mode:['properties'], readonly: true, + group: gettext('Details') + }, + { + id: 'write_lag', label: gettext('Write Lag'), type: 'text', mode:['properties'], readonly: true, + group: gettext('Details') + }, + { + id: 'flush_lag', label: gettext('Flush Lag'), type: 'text', mode:['properties'], readonly: true, + group: gettext('Details') + }, + { + id: 'replay_lag', label: gettext('Replay Lag'), type: 'text', mode:['properties'], readonly: true, + group: gettext('Details') + }, + { + id: 'reply_time', label: gettext('Reply Time'), type: 'text', mode:['properties'], readonly: true, + group: gettext('Details') + }, + ]; + } +} diff --git a/web/pgadmin/dashboard/static/js/SystemStats/CPU.jsx b/web/pgadmin/dashboard/static/js/SystemStats/CPU.jsx index f71bdff0c..40fc761a8 100644 --- a/web/pgadmin/dashboard/static/js/SystemStats/CPU.jsx +++ b/web/pgadmin/dashboard/static/js/SystemStats/CPU.jsx @@ -13,7 +13,7 @@ import gettext from 'sources/gettext'; import PropTypes from 'prop-types'; import { makeStyles } from '@material-ui/core/styles'; import {getGCD, getEpoch} from 'sources/utils'; -import ChartContainer from '../ChartContainer'; +import ChartContainer from '../components/ChartContainer'; import { Box, Grid } from '@material-ui/core'; import { DATA_POINT_SIZE } from 'sources/chartjs'; import StreamingChart from '../../../../static/js/components/PgChart/StreamingChart'; diff --git a/web/pgadmin/dashboard/static/js/SystemStats/Memory.jsx b/web/pgadmin/dashboard/static/js/SystemStats/Memory.jsx index 9091826c8..fe3a4d954 100644 --- a/web/pgadmin/dashboard/static/js/SystemStats/Memory.jsx +++ b/web/pgadmin/dashboard/static/js/SystemStats/Memory.jsx @@ -12,7 +12,7 @@ import gettext from 'sources/gettext'; import PropTypes from 'prop-types'; import { makeStyles } from '@material-ui/core/styles'; import {getGCD, getEpoch} from 'sources/utils'; -import ChartContainer from '../ChartContainer'; +import ChartContainer from '../components/ChartContainer'; import { Box, Grid } from '@material-ui/core'; import { DATA_POINT_SIZE } from 'sources/chartjs'; import StreamingChart from '../../../../static/js/components/PgChart/StreamingChart'; diff --git a/web/pgadmin/dashboard/static/js/SystemStats/Storage.jsx b/web/pgadmin/dashboard/static/js/SystemStats/Storage.jsx index fe57a8ce8..fa0d0759e 100644 --- a/web/pgadmin/dashboard/static/js/SystemStats/Storage.jsx +++ b/web/pgadmin/dashboard/static/js/SystemStats/Storage.jsx @@ -12,7 +12,7 @@ import PropTypes from 'prop-types'; import { makeStyles } from '@material-ui/core/styles'; import url_for from 'sources/url_for'; import {getGCD, getEpoch} from 'sources/utils'; -import ChartContainer from '../ChartContainer'; +import ChartContainer from '../components/ChartContainer'; import { Grid } from '@material-ui/core'; import { DATA_POINT_SIZE } from 'sources/chartjs'; import StreamingChart from '../../../../static/js/components/PgChart/StreamingChart'; diff --git a/web/pgadmin/dashboard/static/js/SystemStats/Summary.jsx b/web/pgadmin/dashboard/static/js/SystemStats/Summary.jsx index 1edfffba1..874cd8d03 100644 --- a/web/pgadmin/dashboard/static/js/SystemStats/Summary.jsx +++ b/web/pgadmin/dashboard/static/js/SystemStats/Summary.jsx @@ -13,7 +13,7 @@ import { makeStyles } from '@material-ui/core/styles'; import url_for from 'sources/url_for'; import getApiInstance from 'sources/api_instance'; import {getGCD, getEpoch} from 'sources/utils'; -import ChartContainer from '../ChartContainer'; +import ChartContainer from '../components/ChartContainer'; import { Grid } from '@material-ui/core'; import { DATA_POINT_SIZE } from 'sources/chartjs'; import StreamingChart from '../../../../static/js/components/PgChart/StreamingChart'; diff --git a/web/pgadmin/dashboard/static/js/ChartContainer.jsx b/web/pgadmin/dashboard/static/js/components/ChartContainer.jsx similarity index 96% rename from web/pgadmin/dashboard/static/js/ChartContainer.jsx rename to web/pgadmin/dashboard/static/js/components/ChartContainer.jsx index c090d71a8..587d545c1 100644 --- a/web/pgadmin/dashboard/static/js/ChartContainer.jsx +++ b/web/pgadmin/dashboard/static/js/components/ChartContainer.jsx @@ -10,7 +10,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import { Box, Card, CardContent, CardHeader, makeStyles } from '@material-ui/core'; -import EmptyPanelMessage from '../../../static/js/components/EmptyPanelMessage'; +import EmptyPanelMessage from '../../../../static/js/components/EmptyPanelMessage'; const useStyles = makeStyles((theme) => ({ diff --git a/web/pgadmin/dashboard/static/js/components/RefreshButtons.jsx b/web/pgadmin/dashboard/static/js/components/RefreshButtons.jsx new file mode 100644 index 000000000..4350d814b --- /dev/null +++ b/web/pgadmin/dashboard/static/js/components/RefreshButtons.jsx @@ -0,0 +1,46 @@ +///////////////////////////////////////////////////////////// +// +// pgAdmin 4 - PostgreSQL Tools +// +// Copyright (C) 2013 - 2024, The pgAdmin Development Team +// This software is released under the PostgreSQL Licence +// +////////////////////////////////////////////////////////////// + +import React from 'react'; +import gettext from 'sources/gettext'; +import CachedOutlinedIcon from '@material-ui/icons/CachedOutlined'; +import { PgIconButton } from '../../../../static/js/components/Buttons'; +import { makeStyles } from '@material-ui/core'; +import PropTypes from 'prop-types'; + +const useStyles = makeStyles((theme) => ({ + refreshButton: { + marginLeft: 'auto', + height: '1.9rem', + width: '2.2rem', + ...theme.mixins.panelBorder, + }, +})); + + +export default function RefreshButton({onClick}) { + const classes = useStyles(); + + return( + } + onClick={onClick} + color="default" + aria-label="Refresh" + title={gettext('Refresh')} + > + ); +} + +RefreshButton.propTypes = { + onClick: PropTypes.func +}; diff --git a/web/pgadmin/dashboard/static/js/components/SectionContainer.jsx b/web/pgadmin/dashboard/static/js/components/SectionContainer.jsx new file mode 100644 index 000000000..5adf17851 --- /dev/null +++ b/web/pgadmin/dashboard/static/js/components/SectionContainer.jsx @@ -0,0 +1,60 @@ +///////////////////////////////////////////////////////////// +// +// pgAdmin 4 - PostgreSQL Tools +// +// Copyright (C) 2013 - 2024, The pgAdmin Development Team +// This software is released under the PostgreSQL Licence +// +////////////////////////////////////////////////////////////// +import React from 'react'; +import PropTypes from 'prop-types'; +import { Box, makeStyles } from '@material-ui/core'; + + +const useStyles = makeStyles((theme) => ({ + root: { + ...theme.mixins.panelBorder.all, + display: 'flex', + flexDirection: 'column', + overflow: 'hidden !important', + height: '100%', + width: '100%', + minHeight: '400px', + }, + cardHeader: { + backgroundColor: theme.otherVars.tableBg, + borderBottom: '1px solid', + borderBottomColor: theme.otherVars.borderColor, + display: 'flex', + alignItems: 'center', + }, + cardTitle: { + padding: '0.25rem 0.5rem', + fontWeight: 'bold', + } +})); + +export default function SectionContainer({title, titleExtras, children, style}) { + const classes = useStyles(); + + return ( + + +
{title}
+
+ {titleExtras} +
+
+ + {children} + +
+ ); +} + +SectionContainer.propTypes = { + title: PropTypes.string.isRequired, + titleExtras: PropTypes.node, + children: PropTypes.node.isRequired, + style: PropTypes.object, +}; diff --git a/web/pgadmin/dashboard/templates/dashboard/sql/default/replication_slots.sql b/web/pgadmin/dashboard/templates/dashboard/sql/default/replication_slots.sql new file mode 100644 index 000000000..f156a6a4c --- /dev/null +++ b/web/pgadmin/dashboard/templates/dashboard/sql/default/replication_slots.sql @@ -0,0 +1 @@ +select * from pg_replication_slots diff --git a/web/pgadmin/dashboard/templates/dashboard/sql/default/replication_stats.sql b/web/pgadmin/dashboard/templates/dashboard/sql/default/replication_stats.sql new file mode 100644 index 000000000..e7e58e98f --- /dev/null +++ b/web/pgadmin/dashboard/templates/dashboard/sql/default/replication_stats.sql @@ -0,0 +1 @@ +select * from pg_stat_replication diff --git a/web/pgadmin/dashboard/tests/test_replication.py b/web/pgadmin/dashboard/tests/test_replication.py new file mode 100644 index 000000000..3ab065f11 --- /dev/null +++ b/web/pgadmin/dashboard/tests/test_replication.py @@ -0,0 +1,50 @@ +########################################################################## +# +# pgAdmin 4 - PostgreSQL Tools +# +# Copyright (C) 2013 - 2024, The pgAdmin Development Team +# This software is released under the PostgreSQL Licence +# +########################################################################## + +from pgadmin.utils.route import BaseTestGenerator +from pgadmin.utils import server_utils +from regression import parent_node_dict +import json + + +class DashboardReplicationTestCase(BaseTestGenerator): + """ + This class validates the version in range functionality + by defining different version scenarios; where dict of + parameters describes the scenario appended by test name. + """ + + scenarios = [( + 'TestCase for replication slots', dict( + endpoint='/dashboard/replication_slots', + data=[], + )), ( + 'TestCase for replication stats', dict( + endpoint='/dashboard/replication_stats', + data=[], + )), + ] + + def setUp(self): + pass + + def runTest(self): + self.server_id = parent_node_dict["server"][-1]["server_id"] + server_response = server_utils.connect_server(self, self.server_id) + if server_response["info"] == "Server connected.": + + url = self.endpoint + '/{0}'.format(self.server_id) + response = self.tester.get(url) + self.assertEqual(response.status_code, 200) + else: + raise Exception("Error while connecting server to add the" + " database.") + + def tearDown(self): + pass diff --git a/web/pgadmin/misc/properties/CollectionNodeProperties.jsx b/web/pgadmin/misc/properties/CollectionNodeProperties.jsx index f8645735f..71636732b 100644 --- a/web/pgadmin/misc/properties/CollectionNodeProperties.jsx +++ b/web/pgadmin/misc/properties/CollectionNodeProperties.jsx @@ -9,7 +9,7 @@ import React from 'react'; import getApiInstance from 'sources/api_instance'; import { makeStyles } from '@material-ui/core/styles'; -import { Box, Switch } from '@material-ui/core'; +import { Box } from '@material-ui/core'; import { generateCollectionURL } from '../../browser/static/js/node_ajax'; import gettext from 'sources/gettext'; import PgTable from 'sources/components/PgTable'; @@ -23,6 +23,7 @@ import EmptyPanelMessage from '../../static/js/components/EmptyPanelMessage'; import Loader from 'sources/components/Loader'; import { evalFunc } from '../../static/js/utils'; import { usePgAdmin } from '../../static/js/BrowserComponent'; +import { getSwitchCell } from '../../static/js/components/PgTable'; const useStyles = makeStyles((theme) => ({ emptyPanel: { @@ -64,12 +65,6 @@ const useStyles = makeStyles((theme) => ({ overflow: 'hidden !important', overflowX: 'auto !important' }, - readOnlySwitch: { - opacity: 0.75, - '& .MuiSwitch-track': { - opacity: theme.palette.action.disabledOpacity, - } - } })); export default function CollectionNodeProperties({ @@ -215,14 +210,6 @@ export default function CollectionNodeProperties({ schemaRef.current?.fields.forEach((field) => { if (node.columns.indexOf(field.id) > -1) { if (field.label.indexOf('?') > -1) { - const Cell = ({value})=>{ - return ; - }; - Cell.displayName = 'StatusCell'; - Cell.propTypes = { - value: PropTypes.any, - }; - column = { Header: field.label, accessor: field.id, @@ -230,7 +217,7 @@ export default function CollectionNodeProperties({ resizable: true, disableGlobalFilter: false, minWidth: 0, - Cell: Cell + Cell: getSwitchCell() }; } else { column = { diff --git a/web/pgadmin/static/js/components/PgTable.jsx b/web/pgadmin/static/js/components/PgTable.jsx index 81bc19ed6..42b144145 100644 --- a/web/pgadmin/static/js/components/PgTable.jsx +++ b/web/pgadmin/static/js/components/PgTable.jsx @@ -22,7 +22,7 @@ import { makeStyles } from '@material-ui/core/styles'; import clsx from 'clsx'; import PropTypes from 'prop-types'; import AutoSizer from 'react-virtualized-auto-sizer'; -import { Checkbox, Box } from '@material-ui/core'; +import { Checkbox, Box, Switch } from '@material-ui/core'; import { InputText } from './FormComponents'; import _ from 'lodash'; import gettext from 'sources/gettext'; @@ -30,6 +30,8 @@ import SchemaView from '../SchemaView'; import EmptyPanelMessage from './EmptyPanelMessage'; import KeyboardArrowUpIcon from '@material-ui/icons/KeyboardArrowUp'; import KeyboardArrowDownIcon from '@material-ui/icons/KeyboardArrowDown'; +import ChevronRightIcon from '@material-ui/icons/ChevronRight'; +import { PgIconButton } from './Buttons'; /* eslint-disable react/display-name */ const useStyles = makeStyles((theme) => ({ @@ -123,6 +125,9 @@ const useStyles = makeStyles((theme) => ({ textAlign: 'center', minWidth: 20 }, + tableHeader: { + backgroundColor: theme.otherVars.tableBg, + }, tableCellHeader: { fontWeight: theme.typography.fontWeightBold, padding: theme.spacing(1, 0.5), @@ -182,6 +187,15 @@ const useStyles = makeStyles((theme) => ({ padding: theme.spacing(0.5, 0), textAlign: 'center', }, + btnExpanded: { + backgroundColor: theme.palette.grey[400] + }, + readOnlySwitch: { + opacity: 0.75, + '& .MuiSwitch-track': { + opacity: theme.palette.action.disabledOpacity, + } + } })); const IndeterminateCheckbox = React.forwardRef( @@ -280,9 +294,9 @@ function RenderRow({ index, style, schema, row, prepareRow, setRowHeight, Expand {!_.isUndefined(row) && row.isExpanded && ( {schema && Promise.resolve({})} + getInitData={()=>Promise.resolve(row.original)} viewHelperProps={{ mode: 'properties' }} - schema={schema[row.id]} + schema={schema[row.id]??schema} showFooter={false} onDataChange={()=>{setExpandComplete(true);}} />} @@ -307,7 +321,7 @@ export default function PgTable({ columns, data, isSelectRow, caveTable=true, sc // Use the state and functions returned from useTable to build your UI const classes = useStyles(); const [searchVal, setSearchVal] = React.useState(''); - const tableRef = React.useRef(); + const windowTableRef = React.useRef(); const rowHeights = React.useRef({}); // Reset Search value on tab changes. @@ -316,7 +330,7 @@ export default function PgTable({ columns, data, isSelectRow, caveTable=true, sc setSearchVal(prevState => (prevState)); setGlobalFilter(searchVal || undefined); rowHeights.current = {}; - tableRef.current?.resetAfterIndex(0); + windowTableRef.current?.resetAfterIndex(0); }, [data]); function getRowHeight(index) { @@ -324,13 +338,13 @@ export default function PgTable({ columns, data, isSelectRow, caveTable=true, sc } const setRowHeight = (index, size) => { - if(tableRef.current) { + if(windowTableRef.current) { if(size == ROW_HEIGHT) { delete rowHeights.current[index]; } else { rowHeights.current[index] = size; } - tableRef.current.resetAfterIndex(index); + windowTableRef.current.resetAfterIndex(index); } }; @@ -422,9 +436,10 @@ export default function PgTable({ columns, data, isSelectRow, caveTable=true, sc ), sortable: false, + disableResizing: true, width: 35, maxWidth: 35, - minWidth: 0 + minWidth: 35 }, ...CLOUMNS, ]; @@ -522,7 +537,7 @@ export default function PgTable({ columns, data, isSelectRow, caveTable=true, sc > {({ height }) => ( { + const classes = useStyles(); + const onClickFinal = (e)=>{ + e.preventDefault(); + row.toggleRowExpanded(!row.isExpanded); + onClick?.(row, e); + }; + return ( + + ) : ( + + ) + } + noBorder + {...props} + onClick={onClickFinal} + aria-label={props.title} + /> + ); + }; + + Cell.displayName = 'ExpandCell'; + Cell.propTypes = { + title: PropTypes.string, + row: PropTypes.any, + }; + + return Cell; +} + +export function getSwitchCell() { + const Cell = ({value})=>{ + const classes = useStyles(); + return ; + }; + + Cell.displayName = 'SwitchCell'; + Cell.propTypes = { + value: PropTypes.any, + }; + + return Cell; +} \ No newline at end of file diff --git a/web/pgadmin/static/js/utils.js b/web/pgadmin/static/js/utils.js index a56eae8f4..160803121 100644 --- a/web/pgadmin/static/js/utils.js +++ b/web/pgadmin/static/js/utils.js @@ -633,3 +633,14 @@ export function requestAnimationAndFocus(ele) { cancelAnimationFrame(animateId); }); } + + +export function scrollbarWidth() { + // thanks too https://davidwalsh.name/detect-scrollbar-width + const scrollDiv = document.createElement('div'); + scrollDiv.setAttribute('style', 'width: 100px; height: 100px; overflow: scroll; position:absolute; top:-9999px;'); + document.body.appendChild(scrollDiv); + const scrollbarWidth = scrollDiv.offsetWidth - scrollDiv.clientWidth; + document.body.removeChild(scrollDiv); + return scrollbarWidth; +} \ No newline at end of file diff --git a/web/webpack.config.js b/web/webpack.config.js index 265732808..08df04708 100644 --- a/web/webpack.config.js +++ b/web/webpack.config.js @@ -503,6 +503,7 @@ module.exports = [{ 'pure|pgadmin.node.aggregate', 'pure|pgadmin.node.operator', 'pure|pgadmin.node.dbms_job_scheduler', + 'pure|pgadmin.node.replica_node' ], }, }, diff --git a/web/webpack.shim.js b/web/webpack.shim.js index b3035f4ef..a4f7e2e4c 100644 --- a/web/webpack.shim.js +++ b/web/webpack.shim.js @@ -121,6 +121,7 @@ let webpackShimConfig = { 'pgadmin.node.primary_key': path.join(__dirname, './pgadmin/browser/server_groups/servers/databases/schemas/tables/constraints/index_constraint/static/js/primary_key'), 'pgadmin.node.procedure': path.join(__dirname, './pgadmin/browser/server_groups/servers/databases/schemas/functions/static/js/procedure'), 'pgadmin.node.resource_group': path.join(__dirname, './pgadmin/browser/server_groups/servers/resource_groups/static/js/resource_group'), + 'pgadmin.node.replica_node': path.join(__dirname, './pgadmin/browser/server_groups/servers/replica_nodes/static/js/replica_node'), 'pgadmin.node.role': path.join(__dirname, './pgadmin/browser/server_groups/servers/roles/static/js/role'), 'pgadmin.node.rule': path.join(__dirname, './pgadmin/browser/server_groups/servers/databases/schemas/tables/rules/static/js/rule'), 'pgadmin.node.schema': path.join(__dirname, './pgadmin/browser/server_groups/servers/databases/schemas/static/js/schema'),