diff --git a/web/config.py b/web/config.py index 414454195..c694e923f 100644 --- a/web/config.py +++ b/web/config.py @@ -837,6 +837,13 @@ ENABLE_BINARY_PATH_BROWSING = False ############################################################################# AUTO_DISCOVER_SERVERS = True +############################################################################# +# SERVER_HEARTBEAT_TIMEOUT is used to send the server heartbeat to server +# from the client. This will resolve the orphan database issue once +# browser tab is closed. +############################################################################# +SERVER_HEARTBEAT_TIMEOUT = 30 # In seconds + ########################################################################## # Local config settings ########################################################################## diff --git a/web/pgadmin/__init__.py b/web/pgadmin/__init__.py index 8f07abeaf..b1911b9c8 100644 --- a/web/pgadmin/__init__.py +++ b/web/pgadmin/__init__.py @@ -37,7 +37,7 @@ from jinja2 import select_autoescape from pgadmin.model import db, Role, Server, SharedServer, ServerGroup, \ User, Keys, Version, SCHEMA_VERSION as CURRENT_SCHEMA_VERSION -from pgadmin.utils import PgAdminModule, driver, KeyManager +from pgadmin.utils import PgAdminModule, driver, KeyManager, heartbeat from pgadmin.utils.preferences import Preferences from pgadmin.utils.session import create_session_interface, pga_unauthorised from pgadmin.utils.versioned_template_loader import VersionedTemplateLoader @@ -49,6 +49,7 @@ from pgadmin import authenticate from pgadmin.utils.security_headers import SecurityHeaders from pgadmin.utils.constants import KERBEROS, OAUTH2, INTERNAL, LDAP, WEBSERVER + # Explicitly set the mime-types so that a corrupted windows registry will not # affect pgAdmin 4 to be load properly. This will avoid the issues that may # occur due to security fix of X_CONTENT_TYPE_OPTIONS = "nosniff". @@ -541,6 +542,7 @@ def create_app(app_name=None): ########################################################################## driver.init_app(app) authenticate.init_app(app) + heartbeat.init_app(app) ########################################################################## # Register language to the preferences after login diff --git a/web/pgadmin/browser/__init__.py b/web/pgadmin/browser/__init__.py index 37a8ec77f..f61262bd4 100644 --- a/web/pgadmin/browser/__init__.py +++ b/web/pgadmin/browser/__init__.py @@ -626,7 +626,8 @@ def utils(): is_admin=current_user.has_role("Administrator"), login_url=login_url, username=current_user.username, - auth_source=auth_source + auth_source=auth_source, + heartbeat_timeout=config.SERVER_HEARTBEAT_TIMEOUT ), 200, {'Content-Type': MIMETYPE_APP_JS}) diff --git a/web/pgadmin/browser/server_groups/servers/static/js/server.js b/web/pgadmin/browser/server_groups/servers/static/js/server.js index d40555a71..fb206318b 100644 --- a/web/pgadmin/browser/server_groups/servers/static/js/server.js +++ b/web/pgadmin/browser/server_groups/servers/static/js/server.js @@ -786,6 +786,10 @@ define('pgadmin.node.server', [ if(data.errmsg) { Notify.error(data.errmsg); } + // Generate the event that server is connected + pgBrowser.Events.trigger( + 'pgadmin:server:connected', data._id, item, data + ); } }) .fail(function(xhr, status, error) { diff --git a/web/pgadmin/browser/static/js/browser.js b/web/pgadmin/browser/static/js/browser.js index 6410b5bf3..be4fa79c9 100644 --- a/web/pgadmin/browser/static/js/browser.js +++ b/web/pgadmin/browser/static/js/browser.js @@ -15,6 +15,7 @@ import Notify, {initializeModalProvider, initializeNotifier} from '../../../stat import { checkMasterPassword } from '../../../static/js/Dialogs/index'; import { pgHandleItemError } from '../../../static/js/utils'; import { Search } from './quick_search/trigger_search'; +import { send_heartbeat } from './heartbeat'; define('pgadmin.browser', [ 'sources/gettext', 'sources/url_for', 'require', 'jquery', @@ -581,6 +582,10 @@ define('pgadmin.browser', [ .fail(function() {/*This is intentional (SonarQube)*/}); }, 300000); + obj.Events.on( + 'pgadmin:server:connected', send_heartbeat.bind(obj) + ); + obj.set_master_password(''); obj.check_corrupted_db_file(); obj.Events.on('pgadmin:browser:tree:add', obj.onAddTreeNode.bind(obj)); diff --git a/web/pgadmin/browser/static/js/heartbeat.js b/web/pgadmin/browser/static/js/heartbeat.js new file mode 100644 index 000000000..496328014 --- /dev/null +++ b/web/pgadmin/browser/static/js/heartbeat.js @@ -0,0 +1,31 @@ +///////////////////////////////////////////////////////////// +// +// pgAdmin 4 - PostgreSQL Tools +// +// Copyright (C) 2013 - 2023, The pgAdmin Development Team +// This software is released under the PostgreSQL Licence +// +////////////////////////////////////////////////////////////// + +import gettext from 'sources/gettext'; +import url_for from 'sources/url_for'; +import getApiInstance from '../../../static/js/api_instance'; +import Notifier from '../../../static/js/helpers/Notifier'; +import pgAdmin from 'sources/pgadmin'; + +const axiosApi = getApiInstance(); +let HEARTBEAT_TIMEOUT = pgAdmin.heartbeat_timeout * 1000; + +export function send_heartbeat(_server_id) { + // Send heartbeat to the server every 30 seconds + setInterval(function() { + axiosApi.post(url_for('misc.heartbeat'), {'sid': _server_id}) + .then(() => { + // pass + }) + .catch((error) => { + Notifier.error(gettext(`pgAdmin server not responding, try to login again: ${error.message || error.response.data.errormsg}`)); + }); + + }, HEARTBEAT_TIMEOUT); +} diff --git a/web/pgadmin/browser/templates/browser/js/utils.js b/web/pgadmin/browser/templates/browser/js/utils.js index 8d1c97efb..10510f90b 100644 --- a/web/pgadmin/browser/templates/browser/js/utils.js +++ b/web/pgadmin/browser/templates/browser/js/utils.js @@ -66,6 +66,9 @@ define('pgadmin.browser.utils', /* GET the pgadmin server's locale */ pgAdmin['pgadmin_server_locale'] = '{{pgadmin_server_locale}}'; + /* Server Heartbeat Timeout */ + pgAdmin['heartbeat_timeout'] = '{{heartbeat_timeout}}'; + // Define list of nodes on which Query tool option doesn't appears let unsupported_nodes = pgAdmin.unsupported_nodes = [ 'server_group', 'server', 'coll-tablespace', 'tablespace', diff --git a/web/pgadmin/misc/__init__.py b/web/pgadmin/misc/__init__.py index 9ac4f7375..0554d0d14 100644 --- a/web/pgadmin/misc/__init__.py +++ b/web/pgadmin/misc/__init__.py @@ -19,6 +19,7 @@ from pgadmin.utils.session import cleanup_session_files from pgadmin.misc.themes import get_all_themes from pgadmin.utils.constants import MIMETYPE_APP_JS, UTILITIES_ARRAY from pgadmin.utils.ajax import precondition_required, make_json_response +from pgadmin.utils.heartbeat import log_server_heartbeat, get_server_heartbeat import config import subprocess import os @@ -93,7 +94,8 @@ class MiscModule(PgAdminModule): list: a list of url endpoints exposed to the client. """ return ['misc.ping', 'misc.index', 'misc.cleanup', - 'misc.validate_binary_path'] + 'misc.validate_binary_path', 'misc.heartbeat', + 'misc.get_heartbeat'] def register(self, app, options): """ @@ -156,6 +158,29 @@ def cleanup(): return "" +@blueprint.route("/heartbeat", methods=['POST']) +@pgCSRFProtect.exempt +def heartbeat(): + data = None + if hasattr(request.data, 'decode'): + data = request.data.decode('utf-8') + + if data != '': + data = json.loads(data) + + log_server_heartbeat(data) + return make_json_response(data=gettext('Heartbeat logged successfully.'), + status=200) + + +@blueprint.route("/get_heartbeat/", methods=['GET']) +@pgCSRFProtect.exempt +def get_heartbeat(sid): + heartbeat_data = get_server_heartbeat(sid) + return make_json_response(data=heartbeat_data, + status=200) + + @blueprint.route("/explain/explain.js") def explain_js(): """ diff --git a/web/pgadmin/utils/driver/__init__.py b/web/pgadmin/utils/driver/__init__.py index 5a28d3adf..88e92aa1e 100644 --- a/web/pgadmin/utils/driver/__init__.py +++ b/web/pgadmin/utils/driver/__init__.py @@ -30,7 +30,5 @@ def init_app(app): def ping(): - drivers = getattr(current_app, '_pgadmin_server_drivers', None) - - for type in drivers: - drivers[type].gc_timeout() + for type in DriverRegistry._registry: + DriverRegistry._objects[type].gc_timeout() diff --git a/web/pgadmin/utils/heartbeat.py b/web/pgadmin/utils/heartbeat.py new file mode 100644 index 000000000..18cad999c --- /dev/null +++ b/web/pgadmin/utils/heartbeat.py @@ -0,0 +1,107 @@ +########################################################################## +# +# pgAdmin 4 - PostgreSQL Tools +# +# Copyright (C) 2013 - 2023, The pgAdmin Development Team +# This software is released under the PostgreSQL Licence +# +######################################################################### + +"""Server heartbeat manager.""" + + +import threading +import datetime +import config +from flask import session, current_app + + +def log_server_heartbeat(data): + """Log Server Heartbeat.""" + from config import PG_DEFAULT_DRIVER + from pgadmin.utils.driver import get_driver + manager = get_driver(PG_DEFAULT_DRIVER).connection_manager(int(data['sid']) + ) + + _server_heartbeat = getattr(current_app, '_pgadmin_server_heartbeat', {}) + + if session.sid not in _server_heartbeat: + _server_heartbeat[session.sid] = {} + + _server_heartbeat[session.sid][data['sid']] = { + 'timestamp': datetime.datetime.now(), + 'conn': manager.connections + } + + setattr(current_app, '_pgadmin_server_heartbeat', _server_heartbeat) + + current_app.logger.debug( + "Heartbeat logged for the session id##server id: {0}##{1}".format( + session.sid, data['sid'])) + + +def get_server_heartbeat(server_id): + _server_heartbeat = getattr(current_app, '_pgadmin_server_heartbeat', {}) + + if session.sid in _server_heartbeat and server_id in _server_heartbeat[ + session.sid]: + return _server_heartbeat[session.sid][server_id] + else: + return None + + +class ServerHeartbeatTimer(): + def __init__(self, sec, _app): + def func_wrapper(): + self.t = threading.Timer(sec, func_wrapper) + self.t.start() + self.release_server_heartbeat() + self.t = threading.Timer(sec, func_wrapper) + self.t.start() + self._app = _app + + def release_server_heartbeat(self): + with self._app.app_context(): + _server_heartbeat = getattr(self._app, + '_pgadmin_server_heartbeat', {}) + if len(_server_heartbeat) > 0: + for sess_id in list(_server_heartbeat): + for sid in list(_server_heartbeat[sess_id]): + last_heartbeat_time = _server_heartbeat[sess_id][sid][ + 'timestamp'] + current_time = datetime.datetime.now() + diff = current_time - last_heartbeat_time + + # Wait for 4 times then the timeout + if diff.total_seconds() > ( + config.SERVER_HEARTBEAT_TIMEOUT * 4): + self._release_connections( + _server_heartbeat[sess_id][sid]['conn'], + sess_id, sid) + _server_heartbeat[sess_id].pop(sid) + if len(_server_heartbeat[sess_id]) == 0: + _server_heartbeat.pop(sess_id) + setattr(self._app, '_pgadmin_server_heartbeat', + _server_heartbeat) + + @staticmethod + def _release_connections(server_conn, sess_id, sid): + for d in server_conn: + # Release the connection + server_conn[d]._release() + # Reconnect on the reload + server_conn[d].wasConnected = True + current_app.logger.debug( + "Heartbeat not received. Released " + "connection for the session " + "id##server id: {0}##{1}".format( + sess_id, sid)) + + def cancel(self): + self.t.cancel() + + +def init_app(app): + setattr(app, '_pgadmin_server_heartbeat', {}) + ServerHeartbeatTimer(sec=config.SERVER_HEARTBEAT_TIMEOUT, + _app=app)