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 @@
+
\ 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 @@
+
\ 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'),