Added support for viewing Log Based Clusters. #7216

Co-authored-by: Akshay Joshi <akshay.joshi@enterprisedb.com>
This commit is contained in:
Aditya Toshniwal 2024-03-28 12:19:34 +05:30 committed by GitHub
parent 5931162556
commit ace73ebb60
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
38 changed files with 1264 additions and 159 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

View File

@ -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

View File

@ -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.

View File

@ -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,

View File

@ -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)

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><defs><style>.cls-1,.cls-3{fill:#f2f2f2;}.cls-2{fill:#aaa;}.cls-3{stroke:#aaa;}.cls-3,.cls-5{stroke-miterlimit:1;stroke-width:0.75px;}.cls-4{fill:#7b7b97;}.cls-5{fill:#def4fd;stroke:#7b7b97;}</style></defs><title>server</title><g id="_3" data-name="3"><rect class="cls-1" x="3.08" y="2.6" width="9.85" height="3.1" rx="1.13" ry="1.13"/><path class="cls-2" d="M11.8,3a.75.75,0,0,1,.75.75v.85a.75.75,0,0,1-.75.75H4.2a.75.75,0,0,1-.75-.75V3.73A.75.75,0,0,1,4.2,3h7.6m0-.75H4.2a1.5,1.5,0,0,0-1.5,1.5v.85a1.5,1.5,0,0,0,1.5,1.5h7.6a1.5,1.5,0,0,0,1.5-1.5V3.73a1.5,1.5,0,0,0-1.5-1.5Z"/><line class="cls-3" x1="6.37" y1="4.15" x2="4.27" y2="4.15"/><path class="cls-1" d="M4.2,6.45h7.6a1.13,1.13,0,0,1,1.13,1.13v.85A1.12,1.12,0,0,1,11.8,9.55H4.2A1.12,1.12,0,0,1,3.08,8.42V7.58A1.13,1.13,0,0,1,4.2,6.45Z"/><path class="cls-4" d="M11.8,6.82a.75.75,0,0,1,.75.75v.85a.75.75,0,0,1-.75.75H4.2a.75.75,0,0,1-.75-.75V7.57a.75.75,0,0,1,.75-.75h7.6m0-.75H4.2a1.5,1.5,0,0,0-1.5,1.5v.85a1.5,1.5,0,0,0,1.5,1.5h7.6a1.5,1.5,0,0,0,1.5-1.5V7.57a1.5,1.5,0,0,0-1.5-1.5Z"/><line class="cls-5" x1="6.37" y1="8" x2="4.27" y2="8"/><path class="cls-1" d="M4.2,10.3h7.6a1.12,1.12,0,0,1,1.12,1.12v.85A1.12,1.12,0,0,1,11.8,13.4H4.2a1.13,1.13,0,0,1-1.13-1.13v-.85A1.12,1.12,0,0,1,4.2,10.3Z"/><path class="cls-2" d="M11.8,10.68a.75.75,0,0,1,.75.75v.85a.75.75,0,0,1-.75.75H4.2a.75.75,0,0,1-.75-.75v-.85a.75.75,0,0,1,.75-.75h7.6m0-.75H4.2a1.5,1.5,0,0,0-1.5,1.5v.85a1.5,1.5,0,0,0,1.5,1.5h7.6a1.5,1.5,0,0,0,1.5-1.5v-.85a1.5,1.5,0,0,0-1.5-1.5Z"/><line class="cls-3" x1="6.37" y1="11.85" x2="4.27" y2="11.85"/></g></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><defs><style>.cls-1,.cls-3{fill:#f2f2f2;}.cls-2{fill:#aaa;}.cls-3{stroke:#aaa;}.cls-3,.cls-5{stroke-miterlimit:1;stroke-width:0.75px;}.cls-4{fill:#7b7b97;}.cls-5{fill:#def4fd;stroke:#7b7b97;}</style></defs><title>server</title><g id="_3" data-name="3"><rect class="cls-1" x="3.08" y="2.6" width="9.85" height="3.1" rx="1.13" ry="1.13"/><path class="cls-2" d="M11.8,3a.75.75,0,0,1,.75.75v.85a.75.75,0,0,1-.75.75H4.2a.75.75,0,0,1-.75-.75V3.73A.75.75,0,0,1,4.2,3h7.6m0-.75H4.2a1.5,1.5,0,0,0-1.5,1.5v.85a1.5,1.5,0,0,0,1.5,1.5h7.6a1.5,1.5,0,0,0,1.5-1.5V3.73a1.5,1.5,0,0,0-1.5-1.5Z"/><line class="cls-3" x1="6.37" y1="4.15" x2="4.27" y2="4.15"/><path class="cls-1" d="M4.2,6.45h7.6a1.13,1.13,0,0,1,1.13,1.13v.85A1.12,1.12,0,0,1,11.8,9.55H4.2A1.12,1.12,0,0,1,3.08,8.42V7.58A1.13,1.13,0,0,1,4.2,6.45Z"/><path class="cls-4" d="M11.8,6.82a.75.75,0,0,1,.75.75v.85a.75.75,0,0,1-.75.75H4.2a.75.75,0,0,1-.75-.75V7.57a.75.75,0,0,1,.75-.75h7.6m0-.75H4.2a1.5,1.5,0,0,0-1.5,1.5v.85a1.5,1.5,0,0,0,1.5,1.5h7.6a1.5,1.5,0,0,0,1.5-1.5V7.57a1.5,1.5,0,0,0-1.5-1.5Z"/><line class="cls-5" x1="6.37" y1="8" x2="4.27" y2="8"/><path class="cls-1" d="M4.2,10.3h7.6a1.12,1.12,0,0,1,1.12,1.12v.85A1.12,1.12,0,0,1,11.8,13.4H4.2a1.13,1.13,0,0,1-1.13-1.13v-.85A1.12,1.12,0,0,1,4.2,10.3Z"/><path class="cls-2" d="M11.8,10.68a.75.75,0,0,1,.75.75v.85a.75.75,0,0,1-.75.75H4.2a.75.75,0,0,1-.75-.75v-.85a.75.75,0,0,1,.75-.75h7.6m0-.75H4.2a1.5,1.5,0,0,0-1.5,1.5v.85a1.5,1.5,0,0,0,1.5,1.5h7.6a1.5,1.5,0,0,0,1.5-1.5v-.85a1.5,1.5,0,0,0-1.5-1.5Z"/><line class="cls-3" x1="6.37" y1="11.85" x2="4.27" y2="11.85"/></g></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@ -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'];
});

View File

@ -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')
},
];
}
}

View File

@ -0,0 +1,2 @@
SELECT count(*)
FROM pg_stat_replication

View File

@ -0,0 +1,3 @@
SELECT pid, 'Standby ['||COALESCE(client_addr::text, client_hostname,'Socket')||']' as name
FROM pg_stat_replication
ORDER BY pid

View File

@ -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

View File

@ -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;

View File

@ -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,

View File

@ -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']

View File

@ -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 = {

View File

@ -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/<int:sid>',
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/<int:sid>',
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
)

View File

@ -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 (
<PgIconButton
size="xs"
className={row.isExpanded ?classes.buttonClick : ''}
icon={
row.isExpanded ? (
<ArrowDropDownOutlinedIcon className={classes.arrowButton}/>
) : (
<ArrowRightOutlinedIcon className={classes.arrowButton}/>
)
}
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(
<PgIconButton
size="xs"
noBorder
className={classes.refreshButton}
icon={<CachedOutlinedIcon />}
onClick={(e) => {
e.preventDefault();
setRefresh(!refresh);
}}
color="default"
aria-label="Refresh"
title={gettext('Refresh')}
></PgIconButton>
);
};
const showDefaultContents = () => {
return (
sid && !serverConnected ? (
@ -915,57 +871,52 @@ function Dashboard({
></Graphs>
)}
{!_.isUndefined(preferences) && preferences.show_activity && (
<Box className={classes.panelContent}>
<Box
className={classes.cardHeader}
title={dbConnected ? gettext('Database activity') : gettext('Server activity')}
>
{dbConnected ? gettext('Database activity') : gettext('Server activity')}{' '}
<SectionContainer title={dbConnected ? gettext('Database activity') : gettext('Server activity')}>
<Box>
<Tabs
value={tabVal}
onChange={tabChanged}
>
{tabs.map((tabValue) => {
return <Tab key={tabValue} label={tabValue} />;
})}
<RefreshButton onClick={(e) => {
e.preventDefault();
setRefresh(!refresh);
}}/>
</Tabs>
</Box>
<Box height="100%" display="flex" flexDirection="column">
<Box>
<Tabs
value={tabVal}
onChange={tabChanged}
>
{tabs.map((tabValue) => {
return <Tab key={tabValue} label={tabValue} />;
})}
<RefreshButton/>
</Tabs>
</Box>
<TabPanel value={tabVal} index={0} classNameRoot={classes.tabPanel}>
<PgTable
caveTable={false}
CustomHeader={CustomActiveOnlyHeader}
columns={activityColumns}
data={filteredDashData}
schema={schemaDict}
></PgTable>
</TabPanel>
<TabPanel value={tabVal} index={1} classNameRoot={classes.tabPanel}>
<PgTable
caveTable={false}
columns={databaseLocksColumns}
data={dashData}
></PgTable>
</TabPanel>
<TabPanel value={tabVal} index={2} classNameRoot={classes.tabPanel}>
<PgTable
caveTable={false}
columns={databasePreparedColumns}
data={dashData}
></PgTable>
</TabPanel>
<TabPanel value={tabVal} index={3} classNameRoot={classes.tabPanel}>
<PgTable
caveTable={false}
columns={serverConfigColumns}
data={dashData}
></PgTable>
</TabPanel>
</Box>
</Box>
<TabPanel value={tabVal} index={0} classNameRoot={classes.tabPanel}>
<PgTable
caveTable={false}
CustomHeader={CustomActiveOnlyHeader}
columns={activityColumns}
data={filteredDashData}
schema={schemaDict}
></PgTable>
</TabPanel>
<TabPanel value={tabVal} index={1} classNameRoot={classes.tabPanel}>
<PgTable
caveTable={false}
columns={databaseLocksColumns}
data={dashData}
></PgTable>
</TabPanel>
<TabPanel value={tabVal} index={2} classNameRoot={classes.tabPanel}>
<PgTable
caveTable={false}
columns={databasePreparedColumns}
data={dashData}
></PgTable>
</TabPanel>
<TabPanel value={tabVal} index={3} classNameRoot={classes.tabPanel}>
<PgTable
caveTable={false}
columns={serverConfigColumns}
data={dashData}
></PgTable>
</TabPanel>
</SectionContainer>
)}
</TabPanel>
{/* System Statistics */}
@ -1031,6 +982,10 @@ function Dashboard({
}
</Box>
</TabPanel>
{/* Replication */}
<TabPanel value={mainTabVal} index={2} classNameRoot={classes.tabPanel}>
<Replication key={mainTabVal} sid={sid} node={node} treeNodeInfo={treeNodeInfo} nodeData={nodeData} pageVisible={props.isActive} />
</TabPanel>
</Box>
</Box>
</Box>

View File

@ -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';

View File

@ -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 (
<Box height="100%" display="flex" flexDirection="column">
<SectionContainer
titleExtras={<RefreshButton onClick={()=>{
getReplicationData('replication_stats', setReplicationStats);
}}/>}
title={gettext('Replication Stats')} style={{minHeight: '300px'}}>
<PgTable
caveTable={false}
columns={replicationStatsColumns}
data={replicationStats}
schema={replStatObj}
></PgTable>
</SectionContainer>
<SectionContainer
titleExtras={<RefreshButton onClick={()=>{
getReplicationData('replication_slots', setReplicationSlots);
}}/>}
title={gettext('Replication Slots')} style={{minHeight: '300px', marginTop: '4px'}}>
<PgTable
caveTable={false}
columns={replicationSlotsColumns}
data={replicationSlots}
schema={replSchemaObj}
></PgTable>
</SectionContainer>
</Box>
);
}
Replication.propTypes = {
treeNodeInfo: PropTypes.object.isRequired,
pageVisible: PropTypes.bool,
};

View File

@ -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')
},
];
}
}

View File

@ -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')
},
];
}
}

View File

@ -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';

View File

@ -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';

View File

@ -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';

View File

@ -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';

View File

@ -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) => ({

View File

@ -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(
<PgIconButton
size="xs"
noBorder
className={classes.refreshButton}
icon={<CachedOutlinedIcon />}
onClick={onClick}
color="default"
aria-label="Refresh"
title={gettext('Refresh')}
></PgIconButton>
);
}
RefreshButton.propTypes = {
onClick: PropTypes.func
};

View File

@ -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 (
<Box className={classes.root} style={style}>
<Box className={classes.cardHeader} title={title}>
<div className={classes.cardTitle}>{title}</div>
<div style={{marginLeft: 'auto'}}>
{titleExtras}
</div>
</Box>
<Box height="100%" display="flex" flexDirection="column">
{children}
</Box>
</Box>
);
}
SectionContainer.propTypes = {
title: PropTypes.string.isRequired,
titleExtras: PropTypes.node,
children: PropTypes.node.isRequired,
style: PropTypes.object,
};

View File

@ -0,0 +1 @@
select * from pg_replication_slots

View File

@ -0,0 +1 @@
select * from pg_stat_replication

View File

@ -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

View File

@ -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 <Switch color="primary" checked={value} className={classes.readOnlySwitch} value={value} readOnly title={String(value)} />;
};
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 = {

View File

@ -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 && (
<Box key={row.id} className={classes.expandedForm}>
{schema && <SchemaView
getInitData={()=>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
</div>
),
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 }) => (
<VariableSizeList
ref={tableRef}
ref={windowTableRef}
className={classes.fixedSizeList}
height={isNaN(height) ? 100 : height}
itemCount={rows.length}
@ -574,3 +589,54 @@ PgTable.propTypes = {
tableProps: PropTypes.object,
'data-test': PropTypes.string
};
export function getExpandCell({onClick, ...props}) {
const Cell = ({ row }) => {
const classes = useStyles();
const onClickFinal = (e)=>{
e.preventDefault();
row.toggleRowExpanded(!row.isExpanded);
onClick?.(row, e);
};
return (
<PgIconButton
size="xs"
className={row.isExpanded ? classes.btnExpanded : ''}
icon={
row.isExpanded ? (
<KeyboardArrowDownIcon />
) : (
<ChevronRightIcon />
)
}
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 <Switch color="primary" checked={value} className={classes.readOnlySwitch} value={value} readOnly title={String(value)} />;
};
Cell.displayName = 'SwitchCell';
Cell.propTypes = {
value: PropTypes.any,
};
return Cell;
}

View File

@ -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;
}

View File

@ -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'
],
},
},

View File

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