diff --git a/docs/en_US/images/pgtimetable_chain_general.png b/docs/en_US/images/pgtimetable_chain_general.png
new file mode 100644
index 00000000000..4cde1249072
Binary files /dev/null and b/docs/en_US/images/pgtimetable_chain_general.png differ
diff --git a/docs/en_US/images/pgtimetable_chain_properties.png b/docs/en_US/images/pgtimetable_chain_properties.png
new file mode 100644
index 00000000000..3ca9b619181
Binary files /dev/null and b/docs/en_US/images/pgtimetable_chain_properties.png differ
diff --git a/docs/en_US/images/pgtimetable_task_code.png b/docs/en_US/images/pgtimetable_task_code.png
new file mode 100644
index 00000000000..928c276e3dd
Binary files /dev/null and b/docs/en_US/images/pgtimetable_task_code.png differ
diff --git a/docs/en_US/images/pgtimetable_task_general.png b/docs/en_US/images/pgtimetable_task_general.png
new file mode 100644
index 00000000000..e5a5f9e4949
Binary files /dev/null and b/docs/en_US/images/pgtimetable_task_general.png differ
diff --git a/docs/en_US/images/pgtimetable_task_parameters.png b/docs/en_US/images/pgtimetable_task_parameters.png
new file mode 100644
index 00000000000..1dfe48460c1
Binary files /dev/null and b/docs/en_US/images/pgtimetable_task_parameters.png differ
diff --git a/docs/en_US/images/pgtimetable_task_statistics.png b/docs/en_US/images/pgtimetable_task_statistics.png
new file mode 100644
index 00000000000..2e35ee8972d
Binary files /dev/null and b/docs/en_US/images/pgtimetable_task_statistics.png differ
diff --git a/docs/en_US/index.rst b/docs/en_US/index.rst
index 8b21d9b169a..627905f0c78 100644
--- a/docs/en_US/index.rst
+++ b/docs/en_US/index.rst
@@ -26,6 +26,7 @@ of database objects.
developer_tools
processes
pgagent
+ pgtimetable_chains
contributions
release_notes
licence
diff --git a/docs/en_US/pgtimetable_chains.rst b/docs/en_US/pgtimetable_chains.rst
new file mode 100644
index 00000000000..c2e857a2dd6
--- /dev/null
+++ b/docs/en_US/pgtimetable_chains.rst
@@ -0,0 +1,241 @@
+.. _pgtimetable_chains:
+
+
+***************************************
+`pgTimeTable Chains`:index:
+***************************************
+
+pgTimeTable is a PostgreSQL-based scheduling agent that runs and manages what are called `Chains`
+each chain is composed of a set of Tasks to run order by the sequence they should run.
+A chain is assigned a single schedule using an extended cron-style format.
+
+This UI has been tested with pgTimeTable 6.3.0 and above.
+
+A task may be a series of *SQL* statements, a *BUILTIN* pgTimeTable command (such as sending email)
+or an operating system *PROGRAM* program/batch/shell script.
+
+Switches on the *pgTimeTable Chain* dialog (accessed
+through the *Properties* context menu) allow you to modify a chain or disable the whole chain
+as well as modifying tasks within the chain.
+
+When you highlight the name of a defined chain in the pgAdmin tree control, the
+*Properties* tab of the main pgAdmin window will display details about the chain,
+and the *Statistics* tab will display details about the chains's execution.
+There are also statistics about each task.
+
+pgTimeTable supports ability to have more than one job agent running at a time.
+Job agents can be deployed on various platforms and binaries are readily available to
+deploy on Linux, Windows, and Mac.
+
+For complete installation instructions, configuration options, and command-line
+usage, please refer to the
+`pgTimeTable documentation `_.
+
+If you are migrating from pgAgent or another PostgreSQL based scheduler, pgTimeTable provides
+migration SQL queries to help in that migration. See the
+`migration tools `_
+on GitHub for details.
+
+Viewing pgTimeTable
+********************
+To enable viewing of the pgTimeTable node, enable it on the File->Preferences->Nodes.
+It will only show if the schema *timetable* and associated tables exists in your maintenance database.
+
+Creating a Chain
+****************
+
+Note that pgTimeTable is not installed via `CREATE EXTENSION`.
+Instead you use a pgTimeTable agent to install the necessary tables.
+To create or manage a chain, use the pgAdmin tree control to browse to the
+server on which the timetable schema exists. The tree control will
+display a *pgTimeTable Chains* node, under which currently defined chains are
+displayed.
+
+To add a new chain, right click on the *pgTimeTable Chains* node,
+and select *Create pgTimeTable Chain...* from the context menu.
+
+When the chain dialog opens, use the tabs on the dialog to define the chain
+and its tasks.
+
+.. image:: images/pgtimetable_chain_general.png
+ :alt: pgTimeTable chain dialog general tab
+ :align: center
+
+Use the fields on the *General* tab to provide general information about the
+chain:
+
+* Provide a name for the chain in the *Name* field.
+* Move the *Enabled* switch to the *Yes* position to enable the chain, or
+ *No* to disable it.
+* Use the *Max instances* field to specify the number of parallel instances
+ (clients) that may execute this chain simultaneously. Leave the field blank
+ to allow unlimited parallel execution.
+* Use the *Timeout (ms)* field to specify the maximum number of milliseconds
+ the chain may run before it is aborted. A value of *0* means no timeout.
+* Move the *Self-destruct* switch to *Yes* to have the chain delete itself
+ after a successful run.
+* Move the *Exclusive execution* switch to *Yes* to pause all other parallel
+ chains while this chain is executing.
+* Use the *Host agent* field to specify the client name of the agent running
+ pgTimeTable that should execute this chain. Leave the field blank to allow
+ any available host to run the chain.
+
+ .. note:: You can query the ``timetable.active_session`` view to see the
+ client names reported by running pgTimeTable agents:
+
+ .. code-block:: sql
+
+ SELECT DISTINCT client_name FROM timetable.active_session
+
+ Use the client name exactly as reported by the query in the *Host agent*
+ field.
+
+* Use the *On error* field to specify the behavior when an error occurs.
+ Accepted values are:
+
+ * *NULL* - Use the default error handling.
+ * *LOG* - Log the error and continue execution.
+ * *ABORT* - Abort the chain on error.
+
+* Use the *Schedule* field to specify a cron-style schedule for the chain.
+ The format consists of a single field with 5 slots. As with regular cron
+ you can use a , to run multiple and wildcard for all.
+ E.g. `20 1,5 * * *` would run at 1:20 and 5:20 AM.
+ The timezone is always the timezone of the server pgTimetable database resides on.
+
+ .. code-block:: text
+
+ * * * * *
+ ┬ ┬ ┬ ┬ ┬
+ │ │ │ │ │
+ │ │ │ │ └──── day of the week (0 - 7) (0 and 7 = Sunday)
+ │ │ │ └────── month (1 - 12)
+ │ │ └──────── day of the month (1 - 31)
+ │ └────────── hour (0 - 23)
+ └──────────── minute (0 - 59)
+
+ Leave the field blank to disable automatic scheduling. The chain can still
+ be started manually using the *Run now* option.
+
+In addition to cron syntax, pgTimeTable supports additional special case formats.
+Namely `@after`, `@every`, `@reboot`.
+
+The `@every` and `@after` should be followed with additional arguments.
+For example `@every 2 hours`.
+
+
+.. image:: images/pgtimetable_chain_tasks.png
+ :alt: pgTimeTable chain dialog tasks tab
+ :align: center
+
+Use the *Tasks* tab to define and manage the tasks that the chain will
+execute. Click the Add icon (+) to add a new task. Tasks are executed in
+order of their *Order* value.
+
+Click the compose icon (located at the left side of the header) to open the
+task definition dialog. See `Chain Task Dialog`_ below for details on
+defining a task.
+
+When you have finished defining the chain, you can use the *SQL* tab to review
+the SQL that will create or modify your chain.
+
+Click the *Save* button to save the chain, or *Close* to exit without saving.
+Use the *Reset* button to remove your unsaved entries from the dialog.
+
+After saving a chain, the chain will be listed under the *pgTimeTable Chains*
+node of the pgAdmin tree control. The *Properties* tab in the main pgAdmin
+window will display a high-level overview of the selected chain, and the
+*Statistics* tab will show the details of each execution run.
+
+.. image:: images/pgtimetable_chain_properties.png
+ :alt: pgTimeTable chain properties
+ :align: center
+
+To modify an existing chain or to review detailed information about a chain,
+right-click on a chain name, and select *Properties* from the context menu.
+
+
+Chain Task Dialog
+*****************
+
+A chain task defines a single unit of work to be performed as part of a chain.
+Each chain can contain one or more tasks, which are executed sequentially in
+order.
+
+.. image:: images/pgtimetable_task_general.png
+ :alt: pgTimeTable task dialog general tab
+ :align: center
+
+Use the fields on the *General* tab to define the task:
+
+* Provide a name for the task in the *Name* field.
+* Use the *Order* field to specify the execution order of the task within the
+ chain. Tasks are executed in ascending order by this value.
+* Use the *Kind* drop-down to select the type of task:
+
+ * *SQL* - The task executes SQL statements.
+ * *PROGRAM* - The task executes an operating system command.
+ * *BUILTIN* - The task executes one of the built-in pgTimeTable commands.
+
+* When *Kind* is set to *SQL*, use the *Connection string* field to optionally
+ specify a libpq-style connection string for a remote database. Leave the
+ field blank to execute against the pgTimeTable database.
+
+ .. note:: The *Connection string* field is only available when the
+ pgTimeTable extension version supports it (version 5.x or later).
+ It is ignored for *PROGRAM* and *BUILTIN* task kinds.
+
+* Move the *Ignore error* switch to *Yes* to continue chain execution even if
+ this task fails. Set to *No* to stop the chain when an error occurs.
+
+.. image:: images/pgtimetable_task_code.png
+ :alt: pgTimeTable task dialog code tab
+ :align: center
+
+Use the *Code* tab to provide the code or command for the task. The content
+of the *Code* tab depends on the selected *Kind*:
+
+* If *Kind* is *SQL*, provide one or more SQL statements in the *SQL* field.
+ Use ``$1``, ``$2``, etc. as parameter placeholders that will be substituted
+ from the *Parameters* collection.
+* If *Kind* is *PROGRAM*, provide the full path and arguments of the program
+ to execute in the *Program* field.
+* If *Kind* is *BUILTIN*, select one of the following internal commands from
+ the *Internal Command* drop-down:
+
+ * *NoOp* - No operation; the task completes immediately.
+ * *Sleep* - Pause execution for a duration specified by a parameter.
+ * *Log* - Write a message to the pgTimeTable log.
+ * *SendMail* - Send an email (requires parameters for recipient, subject,
+ and body).
+ * *Download* - Download a file from a URL.
+ * *CopyFromFile* - Copy data from a file into a database table.
+ * *CopyToFile* - Copy data from a database table to a file.
+ * *Shutdown* - Shut down the pgTimeTable service.
+
+.. image:: images/pgtimetable_task_parameters.png
+ :alt: pgTimeTable task dialog parameters
+ :align: center
+
+Use the *Parameters* sub-collection to define parameter values that will be
+substituted into the task command. Click the Add icon (+) to add a new
+parameter row:
+
+* Use the *Order* column to specify the parameter position (``$1``, ``$2``,
+ etc.).
+* Use the *Value* column to provide the parameter value. The value may be a
+ plain string or a JSON value.
+
+Click the compose icon to close the task definition dialog, then click *Save*
+on the chain dialog to save all changes.
+
+Refer to `pgTimeTable samples `_
+for kind of parameters expected for each type.
+
+
+Both the Chain and Tasks have a statistics tab, which shows results of past run of tasks.
+
+
+.. image:: images/pgtimetable_task_statistics.png
+ :alt: pgTimeTable task statistics
+ :align: center
diff --git a/web/pgadmin/browser/register_browser_preferences.py b/web/pgadmin/browser/register_browser_preferences.py
index d2fe51dc5fa..1a83fc71312 100644
--- a/web/pgadmin/browser/register_browser_preferences.py
+++ b/web/pgadmin/browser/register_browser_preferences.py
@@ -130,6 +130,17 @@ def register_browser_preferences(self):
)
)
+ self.pgtimetable_row_threshold = self.preference.register(
+ 'properties', 'pgtimetable_row_threshold',
+ gettext("Maximum pgTimeTable history rows"), 'integer', 250,
+ category_label=gettext('Properties'),
+ min_val=1, max_val=9999,
+ help_str=gettext(
+ 'The maximum number of history rows to show on '
+ 'the Statistics tab for pgTimeTable chains and tasks.'
+ )
+ )
+
self.table_row_count_threshold = self.preference.register(
'processes', 'process_retain_days',
gettext("Process details/logs retention days"), 'integer', 5,
diff --git a/web/pgadmin/browser/server_groups/servers/__init__.py b/web/pgadmin/browser/server_groups/servers/__init__.py
index cfcb324c4d8..237f107d9dc 100644
--- a/web/pgadmin/browser/server_groups/servers/__init__.py
+++ b/web/pgadmin/browser/server_groups/servers/__init__.py
@@ -389,6 +389,10 @@ def register(self, app, options):
self.submodules.append(module)
from .pgagent import blueprint as module
+ self.submodules.append(module)
+
+ # Explicitly pull in your new testme package route
+ from .pg_timetable import blueprint as module
self.submodules.append(module)
from .resource_groups import blueprint as module
diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/__init__.py b/web/pgadmin/browser/server_groups/servers/pg_timetable/__init__.py
new file mode 100644
index 00000000000..9b613aabc7b
--- /dev/null
+++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/__init__.py
@@ -0,0 +1,693 @@
+##########################################################################
+#
+# pgAdmin 4 - PostgreSQL Tools
+#
+# Copyright (C) 2013 - 2026, The pgAdmin Development Team
+# This software is released under the PostgreSQL Licence
+#
+##########################################################################
+
+"""Implements the pgTimeTable Chains Node"""
+from functools import wraps
+import json
+from datetime import datetime, time
+
+from flask import render_template, request, jsonify
+from flask_babel import gettext as _
+
+from config import PG_DEFAULT_DRIVER
+
+from pgadmin.browser.collection import CollectionNodeModule
+from pgadmin.browser.utils import PGChildNodeView
+from pgadmin.browser.server_groups import servers
+from pgadmin.utils.ajax import make_json_response, internal_server_error, \
+ make_response as ajax_response, gone, success_return
+from pgadmin.utils.driver import get_driver
+from pgadmin.utils.preferences import Preferences
+
+
+class ChainModule(CollectionNodeModule):
+ _NODE_TYPE = 'pgt_chain'
+ _COLLECTION_LABEL = _("pgTimeTable Chains")
+
+ def get_nodes(self, gid, sid):
+ """
+ Generate the collection node
+ """
+ if self.show_node:
+ yield self.generate_browser_collection_node(sid)
+
+ @property
+ def script_load(self):
+ """
+ Load the module script for server, when any of the server-group node is
+ initialized.
+ """
+ return servers.ServerModule.node_type
+
+ def backend_supported(self, manager, **kwargs):
+ if hasattr(self, 'show_node') and not self.show_node:
+ return False
+
+ conn = manager.connection()
+
+ status, res = conn.execute_scalar("""
+SELECT
+ has_table_privilege(
+ 'timetable.chain', 'INSERT, SELECT, UPDATE'
+ ) has_priviledge
+WHERE EXISTS(
+ SELECT has_schema_privilege('timetable', 'USAGE')
+ WHERE EXISTS(
+ SELECT cl.oid FROM pg_catalog.pg_class cl
+ LEFT JOIN pg_catalog.pg_namespace ns ON ns.oid=relnamespace
+ WHERE relname='chain' AND nspname='timetable'
+ )
+)
+""")
+ if status and res:
+ status, res = conn.execute_dict("""
+SELECT EXISTS(
+ SELECT 1 FROM information_schema.columns
+ WHERE
+ table_schema='timetable' AND table_name='task' AND
+ column_name='database_connection'
+ ) has_connstr""")
+
+ if not status:
+ return False
+ manager.db_info['timetable'] = res['rows'][0]
+ return True
+ return False
+
+ @property
+ def csssnippets(self):
+ """
+ Returns a snippet of css to include in the page
+ """
+ snippets = [
+ render_template(
+ self._COLLECTION_CSS,
+ node_type=self.node_type,
+ _=_
+ ),
+ render_template(
+ "pgt_chain/css/pgt_chain.css",
+ node_type=self.node_type,
+ _=_
+ )
+ ]
+
+ for submodule in self.submodules:
+ snippets.extend(submodule.csssnippets)
+
+ return snippets
+
+ @property
+ def module_use_template_javascript(self):
+ """
+ Returns whether Jinja2 template is used for generating the javascript
+ module.
+ """
+ return False
+
+ def register(self, app, options):
+ """
+ Override the default register function to automagically register
+ sub-modules at once.
+ """
+ from .tasks import blueprint as module
+ self.submodules.append(module)
+
+ super().register(app, options)
+
+
+blueprint = ChainModule(__name__)
+
+
+class ChainView(PGChildNodeView):
+ node_type = blueprint.node_type
+
+ parent_ids = [
+ {'type': 'int', 'id': 'gid'},
+ {'type': 'int', 'id': 'sid'}
+ ]
+ ids = [
+ {'type': 'int', 'id': 'chain_id'}
+ ]
+
+ operations = dict({
+ 'obj': [
+ {'get': 'properties', 'delete': 'delete', 'put': 'update'},
+ {'get': 'properties', 'post': 'create', 'delete': 'delete'}
+ ],
+ 'nodes': [{'get': 'nodes'}, {'get': 'nodes'}],
+ 'sql': [{'get': 'sql'}],
+ 'msql': [{'get': 'msql'}, {'get': 'msql'}],
+ 'run_now': [{'put': 'run_now'}],
+ 'children': [{'get': 'children'}],
+ 'stats': [{'get': 'statistics'}]
+ })
+
+ 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(self, *args, **kwargs):
+
+ self.manager = get_driver(
+ PG_DEFAULT_DRIVER
+ ).connection_manager(
+ kwargs['sid']
+ )
+ self.conn = self.manager.connection()
+
+ # Set the template path for the sql scripts.
+ self.template_path = 'pgt_chain/sql/default'
+
+ if 'timetable' not in self.manager.db_info:
+ status, res = self.conn.execute_dict("""
+SELECT EXISTS(
+ SELECT 1 FROM information_schema.columns
+ WHERE
+ table_schema='timetable' AND table_name='task' AND
+ column_name='database_connection'
+ ) has_connstr""")
+
+ if not status:
+ return internal_server_error(errormsg=res)
+ self.manager.db_info['timetable'] = res['rows'][0]
+
+ return f(self, *args, **kwargs)
+ return wrap
+
+ @check_precondition
+ def nodes(self, gid, sid, chain_id=None):
+ SQL = render_template(
+ "/".join([self.template_path, self._NODES_SQL]),
+ chain_id=chain_id, conn=self.conn
+ )
+ status, rset = self.conn.execute_dict(SQL)
+
+ if not status:
+ return internal_server_error(errormsg=rset)
+
+ if chain_id is not None:
+ if len(rset['rows']) != 1:
+ return gone(
+ errormsg=_("Could not find the pgTimeTable chain on the server.")
+ )
+ return make_json_response(
+ data=self.blueprint.generate_browser_node(
+ rset['rows'][0]['chain_id'],
+ sid,
+ rset['rows'][0]['chain_name'],
+ "icon-pgt_chain" if rset['rows'][0]['live'] else
+ "icon-pgt_chain-disabled",
+ description=rset['rows'][0]['chain_name']
+ ),
+ status=200
+ )
+
+ res = []
+ for row in rset['rows']:
+ res.append(
+ self.blueprint.generate_browser_node(
+ row['chain_id'],
+ sid,
+ row['chain_name'],
+ "icon-pgt_chain" if row['live'] else
+ "icon-pgt_chain-disabled",
+ description=row['chain_name']
+ )
+ )
+
+ return make_json_response(
+ data=res,
+ status=200
+ )
+
+ @check_precondition
+ def properties(self, gid, sid, chain_id=None):
+ SQL = render_template(
+ "/".join([self.template_path, self._PROPERTIES_SQL]),
+ chain_id=chain_id, conn=self.conn
+ )
+ status, rset = self.conn.execute_dict(SQL)
+
+ if not status:
+ return internal_server_error(errormsg=rset)
+
+ if chain_id is not None:
+ if len(rset['rows']) != 1:
+ return gone(
+ errormsg=_(
+ "Could not find the pgTimeTable chain on the server."
+ )
+ )
+ res = rset['rows'][0]
+ status, rset = self.conn.execute_dict(
+ render_template(
+ "/".join([self.template_path, 'tasks.sql']),
+ chain_id=chain_id, conn=self.conn,
+ has_connstr=self.manager.db_info['timetable']['has_connstr']
+ )
+ )
+ if not status:
+ return internal_server_error(errormsg=rset)
+ res['ctasks'] = rset['rows']
+ for task in res['ctasks']:
+ if isinstance(task.get('parameters'), str):
+ task['parameters'] = json.loads(task['parameters'])
+ else:
+ res = rset['rows']
+
+ return ajax_response(
+ response=res,
+ status=200
+ )
+
+ @check_precondition
+ def create(self, gid, sid):
+ """Create the pgTimeTable chain."""
+ required_args = [
+ 'chain_name'
+ ]
+
+ data = request.form if request.form else json.loads(
+ request.data.decode('utf-8')
+ )
+
+ for arg in required_args:
+ if arg not in data:
+ return make_json_response(
+ status=410,
+ success=0,
+ errormsg=_(
+ "Could not find the required parameter ({})."
+ ).format(arg)
+ )
+
+ for task in data.get('ctasks', []):
+ if not isinstance(task, dict):
+ continue
+ cleaned_params = []
+ for idx, param in enumerate(task.get('parameters', [])):
+ if not isinstance(param, dict):
+ cleaned_params.append({'order_id': idx + 1, 'value': str(param), '_is_json': False})
+ else:
+ try:
+ json.loads(param.get('value', ''))
+ param['_is_json'] = True
+ except (ValueError, TypeError):
+ param['_is_json'] = False
+ cleaned_params.append(param)
+ if cleaned_params:
+ task['parameters'] = cleaned_params
+
+ status, res = self.conn.execute_void('BEGIN')
+ if not status:
+ return internal_server_error(errormsg=res)
+
+ status, res = self.conn.execute_scalar(
+ render_template(
+ "/".join([self.template_path, self._CREATE_SQL]),
+ data=data, conn=self.conn, fetch_id=True,
+ has_connstr=self.manager.db_info['timetable']['has_connstr']
+ )
+ )
+
+ if not status:
+ self.conn.execute_void('END')
+ return internal_server_error(errormsg=res)
+
+ # We need oid of newly created database
+ status, res = self.conn.execute_dict(
+ render_template(
+ "/".join([self.template_path, self._NODES_SQL]),
+ chain_id=res, conn=self.conn
+ )
+ )
+
+ if not status:
+ self.conn.execute_void('ROLLBACK')
+ return internal_server_error(errormsg=res)
+
+ status, commit_res = self.conn.execute_void('COMMIT')
+ if not status:
+ return internal_server_error(errormsg=commit_res)
+
+ row = res['rows'][0]
+
+ return jsonify(
+ node=self.blueprint.generate_browser_node(
+ row['chain_id'],
+ sid,
+ row['chain_name'],
+ icon="icon-pgt_chain" if row['live']
+ else "icon-pgt_chain-disabled"
+ )
+ )
+
+ @check_precondition
+ def update(self, gid, sid, chain_id):
+ """Update the pgTimeTable chain."""
+
+ data = request.form if request.form else json.loads(
+ request.data.decode('utf-8')
+ )
+
+ status, res = self.conn.execute_void('BEGIN')
+ if not status:
+ return internal_server_error(errormsg=res)
+
+ chain_fields = {k: data[k] for k in ['chain_name', 'live', 'max_instances', 'timeout', 'self_destruct', 'exclusive_execution', 'client_name', 'on_error', 'run_at'] if k in data}
+ if chain_fields:
+ sets = []
+ params = []
+ bool_keys = ['live', 'self_destruct', 'exclusive_execution']
+ null_if_empty = ['run_at', 'client_name', 'on_error']
+ for key, val in chain_fields.items():
+ sets.append(f"{key} = %s")
+ if key in bool_keys:
+ params.append('t' if val else 'f')
+ elif key in null_if_empty and not val:
+ params.append(None)
+ else:
+ params.append(val)
+ params.append(chain_id)
+ sql = f"UPDATE timetable.chain SET {', '.join(sets)} WHERE chain_id = %s"
+ status, res = self.conn.execute_void(sql, params)
+ if not status:
+ self.conn.execute_void('ROLLBACK')
+ return internal_server_error(errormsg=res)
+
+ status, res = self._process_ctasks(chain_id, data.get('ctasks', {}))
+ if not status:
+ self.conn.execute_void('ROLLBACK')
+ return internal_server_error(errormsg=res)
+
+ status, res = self.conn.execute_dict(
+ render_template(
+ "/".join([self.template_path, self._NODES_SQL]),
+ chain_id=chain_id, conn=self.conn
+ )
+ )
+
+ if not status:
+ self.conn.execute_void('ROLLBACK')
+ return internal_server_error(errormsg=res)
+
+ status, commit_res = self.conn.execute_void('COMMIT')
+ if not status:
+ return internal_server_error(errormsg=commit_res)
+
+ row = res['rows'][0]
+
+ return jsonify(
+ node=self.blueprint.generate_browser_node(
+ chain_id,
+ sid,
+ row['chain_name'],
+ icon="icon-pgt_chain" if row['live']
+ else "icon-pgt_chain-disabled",
+ description=row['chain_name']
+ )
+ )
+
+ def _process_ctasks(self, chain_id, ctasks):
+ if not isinstance(ctasks, dict):
+ return True, None
+
+ for task in ctasks.get('deleted', []):
+ tid = task.get('task_id') if isinstance(task, dict) else task
+ if tid:
+ status, res = self.conn.execute_void(
+ "DELETE FROM timetable.task WHERE task_id = %s AND chain_id = %s",
+ (tid, chain_id)
+ )
+ if not status:
+ return status, res
+
+ has_connstr = self.manager.db_info['timetable']['has_connstr']
+ for task in ctasks.get('changed', []):
+ if not isinstance(task, dict):
+ continue
+ tid = task.get('task_id')
+ if not tid:
+ continue
+ sets = []
+ params = []
+ field_map = {
+ 'task_name': 'task_name',
+ 'task_order': 'task_order',
+ 'command': 'command',
+ 'ignore_error': 'ignore_error',
+ }
+ if has_connstr:
+ field_map['database_connection'] = 'database_connection'
+ if 'kind' in task:
+ sets.append("kind = %s::timetable.command_kind")
+ params.append(task['kind'])
+ for frontend_field, db_field in field_map.items():
+ if frontend_field in task:
+ sets.append(f"{db_field} = %s")
+ params.append(task[frontend_field])
+ if sets:
+ params.extend([tid, chain_id])
+ sql = f"UPDATE timetable.task SET {', '.join(sets)} WHERE task_id = %s AND chain_id = %s"
+ status, res = self.conn.execute_void(sql, params)
+ if not status:
+ return status, res
+ if 'parameters' in task:
+ status, res = self._upsert_task_params(tid, task['parameters'])
+ if not status:
+ return status, res
+
+ for task in ctasks.get('added', []):
+ if not isinstance(task, dict):
+ continue
+ fields = ['chain_id', 'task_name', 'task_order', 'command']
+ values = [chain_id, task.get('task_name', ''), task.get('task_order', 10), task.get('command', '')]
+ if 'ignore_error' in task:
+ fields.append('ignore_error')
+ values.append(task['ignore_error'])
+ if 'kind' in task:
+ fields.append('kind')
+ values.append(task['kind'])
+ if has_connstr and 'database_connection' in task and task['database_connection']:
+ fields.append('database_connection')
+ values.append(task['database_connection'])
+ placeholders = ', '.join(['%s'] * len(values))
+ sql = f"INSERT INTO timetable.task ({', '.join(fields)}) VALUES ({placeholders}) RETURNING task_id"
+ status, tid = self.conn.execute_scalar(sql, values)
+ if not status:
+ return status, tid
+ if tid:
+ status, res = self._upsert_task_params(tid, task.get('parameters', []))
+ if not status:
+ return status, res
+
+ return True, None
+
+ def _upsert_task_params(self, task_id, parameters):
+ if not parameters:
+ return True, None
+
+ def _insert_param(idx, param):
+ if not isinstance(param, dict):
+ param = {'order_id': idx + 1, 'value': str(param)}
+ param.pop('_t', None)
+ order_id = param.get('order_id')
+ if order_id is None:
+ order_id = idx + 1
+ order_id = int(order_id)
+ val = param.get('value', '')
+ if val is None:
+ val = ''
+ try:
+ json.loads(val)
+ sql = "INSERT INTO timetable.parameter(task_id, order_id, value) VALUES (%s, %s, %s::jsonb)"
+ params = (task_id, order_id, val)
+ except (ValueError, TypeError):
+ sql = "INSERT INTO timetable.parameter(task_id, order_id, value) VALUES (%s, %s, to_jsonb(%s::text))"
+ params = (task_id, order_id, val)
+ return self.conn.execute_void(sql, params)
+
+ status, res = self.conn.execute_void(
+ "DELETE FROM timetable.parameter WHERE task_id = %s", (task_id,)
+ )
+ if not status:
+ return status, res
+
+ if isinstance(parameters, dict):
+ all_params = parameters.get('changed', []) + parameters.get('added', [])
+ else:
+ all_params = parameters
+
+ for idx, param in enumerate(all_params):
+ status, res = _insert_param(idx, param)
+ if not status:
+ return status, res
+ return True, None
+
+ @check_precondition
+ def delete(self, gid, sid, chain_id=None):
+ """Delete the pgTimeTable chain."""
+
+ if chain_id is None:
+ data = request.form if request.form else json.loads(
+ request.data
+ )
+ else:
+ data = {'ids': [chain_id]}
+
+ for chain_id in data['ids']:
+ status, res = self.conn.execute_void(
+ render_template(
+ "/".join([self.template_path, self._DELETE_SQL]),
+ chain_id=chain_id, conn=self.conn
+ )
+ )
+ if not status:
+ return internal_server_error(errormsg=res)
+
+ return make_json_response(success=1)
+
+ @check_precondition
+ def msql(self, gid, sid, chain_id=None):
+ """
+ This function to return modified SQL.
+ """
+ data = {}
+ for k, v in request.args.items():
+ try:
+ data[k] = json.loads(
+ v.decode('utf-8') if hasattr(v, 'decode') else v
+ )
+ except ValueError:
+ data[k] = v
+
+ # Preserve _is_json marker for JSON parameter values in ctasks
+ if isinstance(data.get('ctasks'), list):
+ for task in data['ctasks']:
+ if not isinstance(task, dict):
+ continue
+ for param in task.get('parameters', []):
+ if not isinstance(param, dict):
+ continue
+ val = param.get('value')
+ if isinstance(val, (dict, list)):
+ param['value'] = json.dumps(val, indent=2)
+ param['_is_json'] = True
+ elif '_is_json' not in param:
+ param['_is_json'] = False
+
+ return make_json_response(
+ data=render_template(
+ "/".join([
+ self.template_path,
+ self._CREATE_SQL if chain_id is None else self._UPDATE_SQL
+ ]),
+ chain_id=chain_id, data=data, conn=self.conn, fetch_id=False,
+ has_connstr=self.manager.db_info['timetable']['has_connstr']
+ ),
+ status=200
+ )
+
+ @check_precondition
+ def statistics(self, gid, sid, chain_id):
+ """
+ statistics
+ Returns the statistics for a particular database if chain_id is specified,
+ otherwise it will return statistics for all the databases in that
+ server.
+ """
+ pref = Preferences.module('browser')
+ rows_threshold = pref.preference(
+ 'pgtimetable_row_threshold'
+ )
+
+ status, res = self.conn.execute_dict(
+ render_template(
+ "/".join([self.template_path, 'stats.sql']),
+ chain_id=chain_id, conn=self.conn,
+ rows_threshold=rows_threshold.get()
+ )
+ )
+
+ if not status:
+ return internal_server_error(errormsg=res)
+
+ return make_json_response(
+ data=res,
+ status=200
+ )
+
+ @check_precondition
+ def sql(self, gid, sid, chain_id):
+ """
+ This function will generate sql for sql panel
+ """
+ SQL = render_template(
+ "/".join([self.template_path, self._PROPERTIES_SQL]),
+ chain_id=chain_id, conn=self.conn, last_system_oid=0
+ )
+ status, res = self.conn.execute_dict(SQL)
+ if not status:
+ return internal_server_error(errormsg=res)
+
+ if len(res['rows']) == 0:
+ return gone(
+ _("Could not find the object on the server.")
+ )
+
+ row = res['rows'][0]
+
+ status, res = self.conn.execute_dict(
+ render_template(
+ "/".join([self.template_path, 'tasks.sql']),
+ chain_id=chain_id, conn=self.conn,
+ has_connstr=self.manager.db_info['timetable']['has_connstr']
+ )
+ )
+ if not status:
+ return internal_server_error(errormsg=res)
+
+ row['ctasks'] = res['rows']
+ for task in row['ctasks']:
+ if isinstance(task.get('parameters'), str):
+ task['parameters'] = json.loads(task['parameters'])
+
+ return ajax_response(
+ response=render_template(
+ "/".join([self.template_path, self._CREATE_SQL]),
+ chain_id=chain_id, data=row, conn=self.conn, fetch_id=False,
+ has_connstr=self.manager.db_info['timetable']['has_connstr']
+ )
+ )
+
+ @check_precondition
+ def run_now(self, gid, sid, chain_id):
+ """
+ This function will set the next run to now, to inform pgTimeTable to
+ run the chain now.
+ """
+ status, res = self.conn.execute_void(
+ render_template(
+ "/".join([self.template_path, 'run_now.sql']),
+ chain_id=chain_id, conn=self.conn
+ )
+ )
+ if not status:
+ return internal_server_error(errormsg=res)
+
+ return success_return(
+ message=_("Updated the next runtime to now.")
+ )
+
+ChainView.register_node_view(blueprint)
diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/static/css/pgt_chain.css b/web/pgadmin/browser/server_groups/servers/pg_timetable/static/css/pgt_chain.css
new file mode 100644
index 00000000000..af8a30b228f
--- /dev/null
+++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/static/css/pgt_chain.css
@@ -0,0 +1,3 @@
+div[role=tabpanel] > .pgadmin-control-group.form-group.c.jscexceptions {
+ min-height: 400px;
+}
diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/static/img/coll-pgt_chain.svg b/web/pgadmin/browser/server_groups/servers/pg_timetable/static/img/coll-pgt_chain.svg
new file mode 100644
index 00000000000..4214d98c1df
--- /dev/null
+++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/static/img/coll-pgt_chain.svg
@@ -0,0 +1,11 @@
+
diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/static/img/pgt_chain-disabled.svg b/web/pgadmin/browser/server_groups/servers/pg_timetable/static/img/pgt_chain-disabled.svg
new file mode 100644
index 00000000000..b140d734d16
--- /dev/null
+++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/static/img/pgt_chain-disabled.svg
@@ -0,0 +1,14 @@
+
diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/static/img/pgt_chain.svg b/web/pgadmin/browser/server_groups/servers/pg_timetable/static/img/pgt_chain.svg
new file mode 100644
index 00000000000..df4d423cdeb
--- /dev/null
+++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/static/img/pgt_chain.svg
@@ -0,0 +1,10 @@
+
diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/static/js/pgt_chain.js b/web/pgadmin/browser/server_groups/servers/pg_timetable/static/js/pgt_chain.js
new file mode 100644
index 00000000000..8cc44e7a06f
--- /dev/null
+++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/static/js/pgt_chain.js
@@ -0,0 +1,126 @@
+///////////////////////////////////////////////////////////////
+//
+// pgAdmin 4 - PostgreSQL Tools
+//
+// Copyright (C) 2013 - 2026, The pgAdmin Development Team
+// This software is released under the PostgreSQL Licence
+//
+///////////////////////////////////////////////////////////////
+
+import PgtChainSchema from './pgt_chain.ui';
+import { getNodePgtChainTaskSchema } from '../../tasks/static/js/pgt_chaintask.ui';
+import getApiInstance from '../../../../../../static/js/api_instance';
+import pgAdmin from 'sources/pgadmin';
+
+define('pgadmin.node.pgt_chain', [
+ 'sources/gettext', 'sources/url_for', 'pgadmin.browser',
+ 'pgadmin.node.pgt_chaintask',
+], function(gettext, url_for, pgBrowser) {
+ if (!pgBrowser.Nodes['coll-pgt_chain']) {
+ pgBrowser.Nodes['coll-pgt_chain'] = pgBrowser.Collection.extend({
+ node: 'pgt_chain',
+ label: gettext('pgt_chains'),
+ type: 'coll-pgt_chain',
+ columns: ['chain_id', 'chain_name', 'live', 'returncode', 'run_at'],
+ hasStatistics: false,
+ canDrop: true,
+ canDropCascade: false,
+ });
+ }
+
+ if (!pgBrowser.Nodes['pgt_chain']) {
+ pgBrowser.Nodes['pgt_chain'] = pgBrowser.Node.extend({
+ parent_type: 'server',
+ type: 'pgt_chain',
+ dialogHelp: url_for('help.static', { filename: 'pgtimetable_chains.html' }),
+ hasSQL: true,
+ hasDepends: false,
+ hasStatistics: true,
+ hasCollectiveStatistics: true,
+ width: '80%',
+ height: '80%',
+ canDrop: true,
+ label: gettext('pgTimeTable Chain'),
+ node_image: function () {
+ return 'icon-pgt_chain';
+ },
+ Init: function () {
+ if (this.initialized) {
+ return;
+ }
+ this.initialized = true;
+ },
+
+ getSchema: function (treeNodeInfo, itemNodeData) {
+ return new PgtChainSchema({}, () => getNodePgtChainTaskSchema(treeNodeInfo, itemNodeData));
+ },
+
+ run_pgt_chain_now: function (args) {
+ var input = args || {};
+ var obj = this;
+ var t = pgBrowser.tree;
+ var i = input.item || t.selected();
+ var d = i ? t.itemData(i) : undefined;
+ if (d) {
+ getApiInstance()
+ .put(obj.generate_url(i, 'run_now', d, true))
+ .then(({ data: res }) => {
+ pgAdmin.Browser.notifier.success(res.info);
+ t.unload(i);
+ })
+ .catch(function (error) {
+ pgAdmin.Browser.notifier.pgRespErrorNotify(error);
+ t.unload(i);
+ });
+ }
+ return false;
+ },
+ });
+ }
+ pgBrowser.add_menus([
+ {
+ name: 'create_pgt_chain_on_server',
+ node: 'server',
+ module: pgBrowser.Nodes['pgt_chain'],
+ applies: ['object', 'context'],
+ callback: 'show_obj_properties',
+ category: 'create',
+ priority: 4,
+ label: gettext('pgTimeTable Chain...'),
+ data: { action: 'create' },
+ },
+ {
+ name: 'create_pgt_chain_on_coll',
+ node: 'coll-pgt_chain',
+ module: pgBrowser.Nodes['pgt_chain'],
+ applies: ['object', 'context'],
+ callback: 'show_obj_properties',
+ category: 'create',
+ priority: 4,
+ label: gettext('pgTimeTable Chain...'),
+ data: { action: 'create' },
+ },
+ {
+ name: 'create_pgt_chain',
+ node: 'pgt_chain',
+ module: pgBrowser.Nodes['pgt_chain'],
+ applies: ['object', 'context'],
+ callback: 'show_obj_properties',
+ category: 'create',
+ priority: 4,
+ label: gettext('pgTimeTable Chain...'),
+ data: { action: 'create' },
+ },
+ {
+ name: 'run_now_pgt_chain',
+ node: 'pgt_chain',
+ module: pgBrowser.Nodes['pgt_chain'],
+ applies: ['object', 'context'],
+ callback: 'run_pgt_chain_now',
+ priority: 4,
+ label: gettext('Run now'),
+ data: { action: 'create' },
+ },
+ ]);
+ return pgBrowser.Nodes['pgt_chain'];
+});
diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/static/js/pgt_chain.ui.js b/web/pgadmin/browser/server_groups/servers/pg_timetable/static/js/pgt_chain.ui.js
new file mode 100644
index 00000000000..a86783742cb
--- /dev/null
+++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/static/js/pgt_chain.ui.js
@@ -0,0 +1,126 @@
+/////////////////////////////////////////////////////////////
+//
+// pgAdmin 4 - PostgreSQL Tools
+//
+// Copyright (C) 2013 - 2026, 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';
+import { SCHEMA_STATE_ACTIONS } from 'sources/SchemaView';
+
+export default class PgtChainSchema extends BaseUISchema {
+ constructor(fieldOptions={}, getPgtChainTaskSchema=()=>[], initValues={}) {
+ super({
+ chain_name: '',
+ chain_id: undefined,
+ live: true,
+ max_instances: null,
+ timeout: 0,
+ self_destruct: false,
+ exclusive_execution: false,
+ client_name: '',
+ on_error: null,
+ ctasks: [],
+ run_at: '',
+ ...initValues,
+ });
+
+ this.fieldOptions = {
+ ...fieldOptions,
+ };
+ this.getPgtChainTaskSchema = getPgtChainTaskSchema;
+ }
+
+ get idAttribute() {
+ return 'chain_id';
+ }
+
+ get baseFields() {
+ return [
+ {
+ id: 'chain_name', label: gettext('Name'), type: 'text', noEmpty: true,
+ },{
+ id: 'chain_id', label: gettext('ID'), mode: ['properties'],
+ type: 'int',
+ },{
+ id: 'live', label: gettext('Enabled?'), type: 'switch',
+ },{
+ id: 'max_instances', label: gettext('Max instances'), type: 'int',
+ helpMessage: gettext('Number of instances (clients) this chain can run in parallel. Leave blank for no limit.'),
+ helpMessageMode: ['edit', 'create'],
+ },{
+ id: 'timeout', label: gettext('Timeout (ms)'), type: 'int',
+ helpMessage: gettext('Abort any chain that takes more than the specified number of milliseconds. 0 means no timeout.'),
+ helpMessageMode: ['edit', 'create'],
+ },{
+ id: 'self_destruct', label: gettext('Self-destruct?'), type: 'switch',
+ helpMessage: gettext('If enabled, this chain will delete itself after a successful run.'),
+ helpMessageMode: ['edit', 'create'],
+ },{
+ id: 'exclusive_execution', label: gettext('Exclusive execution?'), type: 'switch',
+ helpMessage: gettext('If enabled, all parallel chains will be paused while executing this chain.'),
+ helpMessageMode: ['edit', 'create'],
+ },{
+ id: 'client_name', label: gettext('Host agent'), type: 'text',
+ helpMessage: gettext('Enter the client name of a machine running pgTimetable if you wish to ensure only that machine will run this job. Leave blank if any host may run the job.'),
+ helpMessageMode: ['edit', 'create'],
+ },{
+ id: 'on_error', label: gettext('On error'), type: 'text',
+ helpMessage: gettext('Action to take on error. NULL=default error handling, LOG=log and continue, ABORT=abort chain.'),
+ helpMessageMode: ['edit', 'create'],
+ },{
+ id: 'run_at', label: gettext('Schedule'), type: 'text',
+ helpMessage: gettext(`
----CRON-Style
+-- * * * * * command to execute
+-- ┬ ┬ ┬ ┬ ┬
+-- │ │ │ │ │
+-- │ │ │ │ └──── day of the week (0 - 7) (Sunday to Saturday)(0 and 7 is Sunday);
+-- │ │ │ └────── month (1 - 12)
+-- │ │ └──────── day of the month (1 - 31)
+-- │ └────────── hour (0 - 23)
+-- └──────────── minute (0 - 59)
`),
+ helpMessageMode: ['create', 'edit'],
+ },{
+ id: 'last_run', label: gettext('Last run'), type: 'text',
+ mode: ['properties'],
+ },{
+ id: 'finished', label: gettext('Finished'), type: 'text',
+ mode: ['properties'],
+ },{
+ id: 'duration', label: gettext('Duration'), type: 'text',
+ mode: ['properties'],
+ },{
+ id: 'next_run', label: gettext('Next run'), type: 'text',
+ mode: ['properties'],
+ },{
+ id: 'currently_running_on', label: gettext('Currently running on'), type: 'text',
+ mode: ['properties'],
+ },{
+ id: 'ctasks', label: '', group: gettext('Tasks'),
+ type: 'collection', mode: ['edit', 'create'],
+ schema: this.getPgtChainTaskSchema(),
+ canEdit: true, canAdd: true, canDelete: true,
+ columns: [
+ 'task_name', 'task_order', 'kind', 'ignore_error',
+ ],
+ depChange: (state, source, topState, actionObj) => {
+ if (actionObj.type === SCHEMA_STATE_ACTIONS.ADD_ROW && state?.ctasks) {
+ const tasks = state.ctasks;
+ const addedTask = actionObj.value?.cid
+ ? tasks.find(t => t.cid === actionObj.value.cid)
+ : tasks[tasks.length - 1];
+
+ if (addedTask && (addedTask.task_order === undefined || addedTask.task_order === null)) {
+ const lastOrder = tasks.reduce((max, t) => Math.max(max, parseInt(t.task_order, 10) || 0), 0);
+ addedTask.task_order = lastOrder + 10;
+ }
+ }
+ return state;
+ },
+ }
+ ];
+ }
+}
diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/__init__.py b/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/__init__.py
new file mode 100644
index 00000000000..335fe903f41
--- /dev/null
+++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/__init__.py
@@ -0,0 +1,635 @@
+##########################################################################
+#
+# pgAdmin 4 - PostgreSQL Tools
+#
+# Copyright (C) 2013 - 2026, The pgAdmin Development Team
+# This software is released under the PostgreSQL Licence
+#
+##########################################################################
+
+"""Implements pgTimeTable Chain Task Node"""
+
+import json
+from functools import wraps
+
+from flask import render_template, request, jsonify
+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, gone, \
+ make_response as ajax_response, internal_server_error
+from pgadmin.utils.driver import get_driver
+from pgadmin.utils.preferences import Preferences
+
+from config import PG_DEFAULT_DRIVER
+
+
+class ChainTaskModule(CollectionNodeModule):
+ """
+ class ChainTaskModule(CollectionNodeModule)
+
+ A module class for ChainTask node derived from CollectionNodeModule.
+
+ Methods:
+ -------
+ * get_nodes(gid, sid, chain_id)
+ - 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.
+ """
+
+ _NODE_TYPE = 'pgt_chaintask'
+ _COLLECTION_LABEL = gettext("Tasks")
+
+ def get_nodes(self, gid, sid, chain_id):
+ """
+ Method is used to generate the browser collection node
+
+ Args:
+ gid: Server Group ID
+ sid: Server ID
+ chain_id: Database Id
+ """
+ yield self.generate_browser_collection_node(chain_id)
+
+ @property
+ def node_inode(self):
+ """
+ Override this property to make the node a leaf node.
+
+ Returns: False as this is the leaf node
+ """
+ return False
+
+ @property
+ def script_load(self):
+ """
+ Load the module script for language, when any of the pga_job nodes
+ are initialized.
+
+ Returns: node type of the server module.
+ """
+ return 'pgt_chain'
+
+ @property
+ def csssnippets(self):
+ """
+ Returns a snippet of css to include in the page
+ """
+ snippets = [
+ render_template(
+ "pgt_chaintask/css/pgt_chaintask.css",
+ node_type=self.node_type
+ )
+ ]
+
+ for submodule in self.submodules:
+ snippets.extend(submodule.csssnippets)
+
+ return snippets
+
+ @property
+ def module_use_template_javascript(self):
+ """
+ Returns whether Jinja2 template is used for generating the javascript
+ module.
+ """
+ return False
+
+
+blueprint = ChainTaskModule(__name__)
+
+
+class ChainTaskView(PGChildNodeView):
+ """
+ class ChainTaskView(PGChildNodeView)
+
+ A view class for ChainTask node derived from PGChildNodeView.
+ This class is responsible for all the stuff related to view like
+ updating chain task, showing properties, showing sql in sql pane.
+
+ Methods:
+ -------
+ * __init__(**kwargs)
+ - Method is used to initialize the ChainTaskView 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 chain tasks within that
+ collection.
+
+ * nodes()
+ - This function will used to create all the child node within that
+ collection.
+ Here it will create all the chain task nodes.
+
+ * properties(gid, sid, chain_id, task_id)
+ - This function will show the properties of the selected chain task
+
+ * update(gid, sid, chain_id, task_id)
+ - This function will update the data for the selected chain task
+
+ * msql(gid, sid, chain_id, task_id)
+ - This function is used to return modified SQL for the selected
+ chain task
+
+ * sql(gid, sid, chain_id, jscid)
+ - Dummy response for sql panel
+
+ * delete(gid, sid, chain_id, jscid)
+ - Drops chain task
+ """
+
+ node_type = blueprint.node_type
+ STEP_NOT_FOUND = "Could not find the specified chain task."
+
+ parent_ids = [
+ {'type': 'int', 'id': 'gid'},
+ {'type': 'int', 'id': 'sid'},
+ {'type': 'int', 'id': 'chain_id'}
+ ]
+ ids = [
+ {'type': 'int', 'id': 'task_id'}
+ ]
+
+ operations = dict({
+ 'obj': [
+ {'get': 'properties', 'put': 'update', 'delete': 'delete'},
+ {'get': 'list', 'post': 'create', 'delete': 'delete'}
+ ],
+ 'nodes': [{'get': 'nodes'}, {'get': 'nodes'}],
+ 'msql': [{'get': 'msql'}, {'get': 'msql'}],
+ 'sql': [{'get': 'sql'}],
+ 'stats': [{'get': 'statistics'}]
+ })
+
+ def __init__(self, **kwargs):
+ """
+ Method is used to initialize the ChainTaskView and its base view.
+ Initialize all the variables create/used dynamically like conn,
+ template_path.
+
+ Args:
+ **kwargs:
+ """
+ self.conn = None
+ self.template_path = None
+ self.manager = None
+
+ super().__init__(**kwargs)
+
+ def check_precondition(f):
+ """
+ This function will behave as a decorator which will check the
+ database connection before running the view. It 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,chain_id
+ self = args[0]
+ self.driver = get_driver(PG_DEFAULT_DRIVER)
+ self.manager = self.driver.connection_manager(kwargs['sid'])
+ self.conn = self.manager.connection()
+
+ self.template_path = 'pgt_chaintask/sql/default'
+
+ if 'timetable' not in self.manager.db_info:
+ status, res = self.conn.execute_dict("""
+SELECT EXISTS(
+ SELECT 1 FROM information_schema.columns
+ WHERE
+ table_schema='timetable' AND table_name='task' AND
+ column_name='database_connection'
+ ) has_connstr""")
+
+ if not status:
+ return internal_server_error(errormsg=res)
+ self.manager.db_info['timetable'] = res['rows'][0]
+
+ return f(*args, **kwargs)
+
+ return wrap
+
+ @check_precondition
+ def list(self, gid, sid, chain_id):
+ """
+ This function is used to list all the chain tasks within
+ that collection.
+
+ Args:
+ gid: Server Group ID
+ sid: Server ID
+ chain_id: Chain ID
+ """
+ sql = render_template(
+ "/".join([self.template_path, self._PROPERTIES_SQL]),
+ chain_id=chain_id,
+ has_connstr=self.manager.db_info['timetable']['has_connstr'],
+ conn=self.conn
+ )
+ status, res = self.conn.execute_dict(sql)
+
+ if not status:
+ return internal_server_error(errormsg=res)
+
+ for task in res['rows']:
+ if isinstance(task.get('parameters'), str):
+ task['parameters'] = json.loads(task['parameters'])
+
+ return ajax_response(
+ response=res['rows'],
+ status=200
+ )
+
+ @check_precondition
+ def nodes(self, gid, sid, chain_id, task_id=None):
+ """
+ This function is used to create all the child nodes
+ within the collection.
+ Here it will create all the chain tasks.
+
+ Args:
+ gid: Server Group ID
+ sid: Server ID
+ chain_id: Chain ID
+ """
+ res = []
+ sql = render_template(
+ "/".join([self.template_path, self._NODES_SQL]),
+ task_id=task_id,
+ chain_id=chain_id,
+ conn=self.conn
+ )
+
+ status, result = self.conn.execute_2darray(sql)
+
+ if not status:
+ return internal_server_error(errormsg=result)
+
+ if task_id is not None:
+ if len(result['rows']) == 0:
+ return gone(errormsg=self.STEP_NOT_FOUND)
+
+ row = result['rows'][0]
+ label = f"{int(row['task_order']):04d}: {row['task_name']}"
+ node = self.blueprint.generate_browser_node(
+ row['task_id'],
+ row['chain_id'],
+ label,
+ icon="icon-pgt_chaintask",
+ enabled=True,
+ kind=row['kind'],
+ description=row['task_name']
+ )
+ return make_json_response(node)
+
+ for row in result['rows']:
+ label = f"{int(row['task_order']):04d}: {row['task_name']}"
+ node = self.blueprint.generate_browser_node(
+ row['task_id'],
+ row['chain_id'],
+ label,
+ icon="icon-pgt_chaintask",
+ enabled=True,
+ kind=row['kind'],
+ description=row['task_name']
+ )
+ res.append(node)
+
+ return make_json_response(
+ data=res,
+ status=200
+ )
+
+ @check_precondition
+ def properties(self, gid, sid, chain_id, task_id):
+ """
+ This function will show the properties of the selected chain task.
+
+ Args:
+ gid: Server Group ID
+ sid: Server ID
+ chain_id: Chain ID
+ task_id: ChainTask ID
+ """
+ sql = render_template(
+ "/".join([self.template_path, self._PROPERTIES_SQL]),
+ task_id=task_id,
+ chain_id=chain_id,
+ has_connstr=self.manager.db_info['timetable']['has_connstr'],
+ conn=self.conn
+ )
+ status, res = self.conn.execute_dict(sql)
+
+ if not status:
+ return internal_server_error(errormsg=res)
+
+ if len(res['rows']) == 0:
+ return gone(errormsg=self.STEP_NOT_FOUND)
+
+ task = res['rows'][0]
+ if isinstance(task.get('parameters'), str):
+ task['parameters'] = json.loads(task['parameters'])
+
+ return ajax_response(
+ response=task,
+ status=200
+ )
+
+ @check_precondition
+ def create(self, gid, sid, chain_id):
+ """
+ This function will create the chain task.
+ """
+ data = request.form if request.form else json.loads(
+ request.data.decode('utf-8')
+ )
+
+ if 'parameters' in data:
+ params_raw = data.get('parameters', [])
+ if isinstance(params_raw, str):
+ params_raw = json.loads(params_raw)
+ if isinstance(params_raw, dict):
+ params_raw = params_raw.get('added', []) + params_raw.get('changed', [])
+ cleaned = []
+ for idx, param in enumerate(params_raw):
+ if not isinstance(param, dict):
+ cleaned.append({'order_id': idx + 1, 'value': str(param), '_is_json': False})
+ else:
+ try:
+ json.loads(param.get('value', ''))
+ param['_is_json'] = True
+ except (ValueError, TypeError):
+ param['_is_json'] = False
+ cleaned.append(param)
+ data['parameters'] = cleaned
+
+ sql = render_template(
+ "/".join([self.template_path, self._CREATE_SQL]),
+ chain_id=chain_id,
+ data=data,
+ has_connstr=self.manager.db_info['timetable']['has_connstr'],
+ conn=self.conn
+ )
+
+ status, res = self.conn.execute_scalar(sql)
+
+ if not status:
+ return internal_server_error(errormsg=res)
+
+ sql = render_template(
+ "/".join([self.template_path, self._NODES_SQL]),
+ task_id=res,
+ chain_id=chain_id,
+ conn=self.conn
+ )
+ status, res = self.conn.execute_2darray(sql)
+
+ if not status:
+ return internal_server_error(errormsg=res)
+
+ if len(res['rows']) == 0:
+ return gone(
+ errormsg=gettext(
+ "Chain task creation failed."
+ )
+ )
+ row = res['rows'][0]
+ label = f"{int(row['task_order']):04d}: {row['task_name']}"
+ node = self.blueprint.generate_browser_node(
+ row['task_id'],
+ row['chain_id'],
+ label,
+ icon="icon-pgt_chaintask"
+ )
+ return jsonify(node=node)
+
+ @check_precondition
+ def update(self, gid, sid, chain_id, task_id):
+ """
+ This function will update the data for the selected chain task.
+
+ Args:
+ gid: Server Group ID
+ sid: Server ID
+ chain_id: Chain ID
+ task_id: ChainTask ID
+ """
+ data = request.form if request.form else json.loads(
+ request.data.decode('utf-8')
+ )
+
+ if 'parameters' in data:
+ params_raw = data.get('parameters', [])
+ if isinstance(params_raw, str):
+ params_raw = json.loads(params_raw)
+
+ if isinstance(params_raw, dict):
+ params_list = params_raw.get('changed', []) + params_raw.get('added', [])
+ else:
+ params_list = params_raw
+
+ cleaned = []
+ for idx, param in enumerate(params_list):
+ if not isinstance(param, dict):
+ param = {'order_id': idx + 1, 'value': str(param)}
+ param.pop('_t', None)
+ param.setdefault('value', '')
+ try:
+ json.loads(param['value'])
+ param['_is_json'] = True
+ except (ValueError, TypeError):
+ param['_is_json'] = False
+ cleaned.append(param)
+ data['parameters'] = cleaned
+
+ sql = render_template(
+ "/".join([self.template_path, self._UPDATE_SQL]),
+ chain_id=chain_id,
+ task_id=task_id,
+ data=data,
+ has_connstr=self.manager.db_info['timetable']['has_connstr'],
+ conn=self.conn
+ )
+
+ status, res = self.conn.execute_void(sql)
+
+ if not status:
+ return internal_server_error(errormsg=res)
+
+ sql = render_template(
+ "/".join([self.template_path, self._NODES_SQL]),
+ task_id=task_id,
+ chain_id=chain_id,
+ conn=self.conn
+ )
+ status, res = self.conn.execute_2darray(sql)
+
+ if not status:
+ return internal_server_error(errormsg=res)
+
+ if len(res['rows']) == 0:
+ return gone(
+ errormsg=gettext(
+ "Chain task update failed."
+ )
+ )
+ row = res['rows'][0]
+ label = f"{int(row['task_order']):04d}: {row['task_name']}"
+ node = self.blueprint.generate_browser_node(
+ task_id,
+ chain_id,
+ label,
+ icon="icon-pgt_chaintask",
+ description=row['task_name']
+ )
+ return jsonify(node=node)
+
+ @check_precondition
+ def delete(self, gid, sid, chain_id, task_id=None):
+ """Delete the chain task."""
+
+ if task_id is None:
+ data = request.form if request.form else json.loads(
+ request.data
+ )
+ else:
+ data = {'ids': [task_id]}
+
+ for task_id in data['ids']:
+ status, res = self.conn.execute_void(
+ render_template(
+ "/".join([self.template_path, self._DELETE_SQL]),
+ chain_id=chain_id, task_id=task_id, conn=self.conn
+ )
+ )
+ if not status:
+ return internal_server_error(errormsg=res)
+
+ return make_json_response(success=1)
+
+ @check_precondition
+ def msql(self, gid, sid, chain_id, task_id=None):
+ """
+ This function is used to return modified SQL for the selected
+ chain task.
+
+ Args:
+ gid: Server Group ID
+ sid: Server ID
+ chain_id: Chain ID
+ task_id: Chain Task ID
+ """
+ data = {}
+ sql = ''
+ for k, v in request.args.items():
+ try:
+ data[k] = json.loads(v)
+ except ValueError:
+ data[k] = v
+
+ if 'parameters' in data:
+ params_raw = data.get('parameters', [])
+ if isinstance(params_raw, str):
+ params_raw = json.loads(params_raw)
+
+ if isinstance(params_raw, dict):
+ params_list = params_raw.get('changed', []) + params_raw.get('added', [])
+ else:
+ params_list = params_raw
+
+ cleaned = []
+ for idx, param in enumerate(params_list):
+ if not isinstance(param, dict):
+ param = {'order_id': idx + 1, 'value': str(param)}
+ param.pop('_t', None)
+ param.setdefault('value', '')
+ try:
+ json.loads(param['value'])
+ param['_is_json'] = True
+ except (ValueError, TypeError):
+ param['_is_json'] = False
+ cleaned.append(param)
+ data['parameters'] = cleaned
+
+ if task_id is None:
+ sql = render_template(
+ "/".join([self.template_path, self._CREATE_SQL]),
+ chain_id=chain_id,
+ data=data,
+ has_connstr=self.manager.db_info['timetable']['has_connstr'],
+ conn=self.conn
+ )
+
+ return make_json_response(
+ data=sql,
+ status=200
+ )
+
+ sql = render_template(
+ "/".join([self.template_path, self._UPDATE_SQL]),
+ chain_id=chain_id,
+ task_id=task_id,
+ data=data,
+ has_connstr=self.manager.db_info['timetable']['has_connstr'],
+ conn=self.conn
+ )
+
+ return make_json_response(
+ data=sql,
+ status=200
+ )
+
+ @check_precondition
+ def statistics(self, gid, sid, chain_id, task_id):
+ """
+ statistics
+ Returns the statistics for a particular database if chain_id is specified,
+ otherwise it will return statistics for all the databases in that
+ server.
+ """
+ pref = Preferences.module('browser')
+ rows_threshold = pref.preference(
+ 'pgtimetable_row_threshold'
+ )
+
+ status, res = self.conn.execute_dict(
+ render_template(
+ "/".join([self.template_path, 'stats.sql']),
+ chain_id=chain_id, task_id=task_id, conn=self.conn,
+ rows_threshold=rows_threshold.get()
+ )
+ )
+
+ if not status:
+ return internal_server_error(errormsg=res)
+
+ return make_json_response(
+ data=res,
+ status=200
+ )
+
+ @check_precondition
+ def sql(self, gid, sid, chain_id, task_id):
+ """
+ Dummy response for sql route.
+ As we need to have msql tab for create and edit mode we can not
+ disable it setting hasSQL=false because we have a single 'hasSQL'
+ flag in JS to display both sql & msql tab
+ """
+ return ajax_response(
+ response=gettext(
+ "-- No SQL could be generated for the selected object."
+ ),
+ status=200
+ )
+
+
+ChainTaskView.register_node_view(blueprint)
diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/static/img/coll-pgt_chaintask.svg b/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/static/img/coll-pgt_chaintask.svg
new file mode 100644
index 00000000000..54c4b73ed32
--- /dev/null
+++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/static/img/coll-pgt_chaintask.svg
@@ -0,0 +1,45 @@
+
+
+
diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/static/img/pgt_chaintask-disabled.svg b/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/static/img/pgt_chaintask-disabled.svg
new file mode 100644
index 00000000000..71aafb8b009
--- /dev/null
+++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/static/img/pgt_chaintask-disabled.svg
@@ -0,0 +1,32 @@
+
+
+
diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/static/img/pgt_chaintask.svg b/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/static/img/pgt_chaintask.svg
new file mode 100644
index 00000000000..27692ead9f4
--- /dev/null
+++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/static/img/pgt_chaintask.svg
@@ -0,0 +1,28 @@
+
+
+
diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/static/js/pgt_chaintask.js b/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/static/js/pgt_chaintask.js
new file mode 100644
index 00000000000..dbe6cc6e72b
--- /dev/null
+++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/static/js/pgt_chaintask.js
@@ -0,0 +1,80 @@
+/////////////////////////////////////////////////////////////
+//
+// pgAdmin 4 - PostgreSQL Tools
+//
+// Copyright (C) 2013 - 2026, The pgAdmin Development Team
+// This software is released under the PostgreSQL Licence
+//
+//////////////////////////////////////////////////////////////
+
+import { getNodePgtChainTaskSchema } from './pgt_chaintask.ui';
+
+define('pgadmin.node.pgt_chaintask', [
+ 'sources/gettext', 'sources/url_for', 'pgadmin.browser',
+], function(gettext, url_for, pgBrowser) {
+
+ if (!pgBrowser.Nodes['coll-pgt_chaintask']) {
+ pgBrowser.Nodes['coll-pgt_chaintask'] =
+ pgBrowser.Collection.extend({
+ node: 'pgt_chaintask',
+ label: gettext('Tasks'),
+ type: 'coll-pgt_chaintask',
+ columns: [
+ 'task_id', 'task_name', 'kind',
+ 'ignore_error',
+ ],
+ hasStatistics: false,
+ canDropCascade: false,
+ });
+ }
+
+ if (!pgBrowser.Nodes['pgt_chaintask']) {
+ pgBrowser.Nodes['pgt_chaintask'] = pgBrowser.Node.extend({
+ parent_type: 'pgt_chain',
+ type: 'pgt_chaintask',
+ dialogHelp: url_for('help.static', {'filename': 'pgtimetable_chains.html'}),
+ hasSQL: true,
+ hasDepends: false,
+ hasStatistics: true,
+ hasCollectiveStatistics: true,
+ width: '70%',
+ height: '80%',
+ canDrop: true,
+ label: gettext('Task'),
+ node_image: function() {
+ return 'icon-pgt_chaintask';
+ },
+ Init: function() {
+ /* Avoid mulitple registration of menus */
+ if (this.initialized)
+ return;
+
+ this.initialized = true;
+
+ pgBrowser.add_menus([{
+ name: 'create_pgt_chaintask_on_job', node: 'pgt_chain', module: this,
+ applies: ['object', 'context'], callback: 'show_obj_properties',
+ category: 'create', priority: 4, label: gettext('Chain Task...'),
+ data: {'action': 'create'},
+ },{
+ name: 'create_pgt_chaintask_on_coll', node: 'coll-pgt_chaintask', module: this,
+ applies: ['object', 'context'], callback: 'show_obj_properties',
+ category: 'create', priority: 4, label: gettext('Chain Task...'),
+ data: {'action': 'create'},
+ },{
+ name: 'create_pgt_chaintask', node: 'pgt_chaintask', module: this,
+ applies: ['object', 'context'], callback: 'show_obj_properties',
+ category: 'create', priority: 4, label: gettext('Chain Task...'),
+ data: {'action': 'create'},
+ }]);
+ },
+
+ getSchema: function(treeNodeInfo, itemNodeData) {
+ return getNodePgtChainTaskSchema(treeNodeInfo, itemNodeData);
+ },
+
+ });
+ }
+
+ return pgBrowser.Nodes['pgt_chaintask'];
+});
diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/static/js/pgt_chaintask.ui.js b/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/static/js/pgt_chaintask.ui.js
new file mode 100644
index 00000000000..c123d92a230
--- /dev/null
+++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/static/js/pgt_chaintask.ui.js
@@ -0,0 +1,258 @@
+//////////////////////////////////////////////////////////////
+//
+// pgAdmin 4 - PostgreSQL Tools
+//
+// Copyright (C) 2013 - 2026, 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';
+import { SCHEMA_STATE_ACTIONS } from 'sources/SchemaView';
+import { getNodeListByName } from '../../../../../../static/js/node_ajax';
+import { isEmptyString } from 'sources/validators';
+
+export function getNodePgtChainTaskSchema(treeNodeInfo, itemNodeData) {
+ const paramSchema = new (class extends BaseUISchema {
+ constructor() {
+ super({ order_id: null, value: '' });
+ }
+ get baseFields() {
+ return [
+ { id: '_t', type: 'boolean' },
+ { id: 'order_id', label: gettext('Order'), type: 'int', noEmpty: true, cell: 'int', width: 20 },
+ { id: 'value', label: gettext('Value'), type: 'multiline', cell: 'text' },
+ ];
+ }
+ validate(state, setError) {
+ if (!state.order_id || state.order_id < 1) {
+ setError('order_id', gettext('Order must be a positive integer.'));
+ return true;
+ }
+ setError('order_id', null);
+ if (isEmptyString(state.value)) {
+ setError('value', gettext('Please enter a parameter value.'));
+ return true;
+ }
+ setError('value', null);
+ }
+ })();
+
+ return new PgtChainTaskSchema(
+ {
+ databases: () =>
+ getNodeListByName('database', treeNodeInfo, itemNodeData, {
+ cacheLevel: 'database',
+ cacheNode: 'database',
+ }),
+ paramSchema,
+ },
+ {
+ jstdbname: treeNodeInfo['server']['db'],
+ }
+ );
+}
+
+export default class PgtChainTaskSchema extends BaseUISchema {
+ constructor(fieldOptions = {}, initValues = {}) {
+ super({
+ task_id: null,
+ chain_id: null,
+ task_name: '',
+ task_order: 10,
+ kind: 'SQL',
+ command: '',
+ database_connection: null,
+ ignore_error: false,
+ parameters: [],
+ ...initValues,
+ });
+
+ this.fieldOptions = {
+ databases: [],
+ paramSchema: new (class extends BaseUISchema {
+ constructor() {
+ super({ order_id: null, value: '' });
+ }
+ get baseFields() {
+ return [
+ { id: '_t', type: 'boolean' },
+ { id: 'order_id', label: gettext('Order'), type: 'int', noEmpty: true, cell: 'int', width: 20 },
+ { id: 'value', label: gettext('Value'), type: 'multiline', cell: 'text' },
+ ];
+ }
+ })(),
+ ...fieldOptions,
+ };
+ }
+
+ get idAttribute() {
+ return 'task_id';
+ }
+
+ get baseFields() {
+ return [
+ {
+ id: 'task_id',
+ label: gettext('ID'),
+ type: 'int',
+ mode: ['properties'],
+ },
+ {
+ id: 'task_name',
+ label: gettext('Name'),
+ type: 'text',
+ noEmpty: true,
+ cell: 'text',
+ },
+ {
+ id: 'task_order',
+ label: gettext('Order'),
+ type: 'int',
+ cell: 'int',
+ },
+ {
+ id: 'kind',
+ label: gettext('Kind'),
+ type: 'select',
+ controlProps: { allowClear: false },
+ cell: 'select',
+ options: [
+ { label: gettext('SQL'), value: 'SQL' },
+ { label: gettext('PROGRAM'), value: 'PROGRAM' },
+ { label: gettext('BUILTIN'), value: 'BUILTIN' },
+ ],
+ },
+ {
+ id: 'database_connection',
+ label: gettext('Connection string'),
+ type: 'text',
+ deps: ['kind'],
+ disabled: (state) => state.kind !== 'SQL',
+ helpMessage: `Optional connection string for the database server. Leave blank to use the pgTimetable database. Each parameter setting is in the form keyword = value. Spaces around the equal sign are optional. To write an empty value, or a value containing spaces, surround it with single quotes, e.g., keyword = 'a value'. Single quotes and backslashes within the value must be escaped with a backslash, i.e., ' and \\.
+For more information, please see the documentation on libpq connection strings.`,
+ helpMessageMode: ['edit', 'create'],
+ },
+ {
+ id: 'ignore_error',
+ label: gettext('Ignore error'),
+ type: 'switch',
+ cell: 'switch',
+ },
+ {
+ id: 'command',
+ label: gettext('Command'),
+ type: (state) => {
+ if (state?.kind === 'SQL') return { type: 'multiline', label: gettext('SQL') };
+ if (state?.kind === 'BUILTIN') return { type: 'select', label: gettext('Internal Command'), options: [
+ { label: 'NoOp', value: 'NoOp' },
+ { label: 'Sleep', value: 'Sleep' },
+ { label: 'Log', value: 'Log' },
+ { label: 'SendMail', value: 'SendMail' },
+ { label: 'Download', value: 'Download' },
+ { label: 'CopyFromFile', value: 'CopyFromFile' },
+ { label: 'CopyToFile', value: 'CopyToFile' },
+ { label: 'Shutdown', value: 'Shutdown' },
+ ] };
+ return { type: 'text', label: gettext('Program') };
+ },
+ group: gettext('Code'),
+ deps: ['kind'],
+ },
+ {
+ id: 'parameters', label: '', group: gettext('Code'),
+ type: 'collection', mode: ['edit', 'create'],
+ deps: ['kind'],
+ schema: this.fieldOptions.paramSchema,
+ canEdit: true, canAdd: true, canDelete: true,
+ uniqueCol: ['order_id'],
+ columns: ['order_id', 'value'],
+ depChange: (state, source, topState, actionObj) => {
+ if (state?.parameters) {
+ state.parameters.forEach(p => { p._t = true; });
+ }
+ if (actionObj.type === SCHEMA_STATE_ACTIONS.ADD_ROW && state?.parameters) {
+ const params = state.parameters;
+ const addedParam = actionObj.value?.cid
+ ? params.find(p => p.cid === actionObj.value.cid)
+ : params[params.length - 1];
+ if (addedParam) {
+ const lastOrder = params.reduce((max, p) => Math.max(max, p.order_id || 0), 0);
+ addedParam.order_id = lastOrder + 1;
+ }
+ }
+ return state;
+ },
+ },
+ ];
+ }
+
+ validate(state, setError) {
+ let errMsg = null;
+
+ if (state.kind === 'SQL') {
+ const r = /\s*\b(\w+)\s*=\s*('([^'\\]*(?:\\.[^'\\]*)*)'|[\w|\.]*)/g;
+ if (!isEmptyString(state.database_connection)) {
+ if (String(state.database_connection).replace(r, '') !== '') {
+ setError('database_connection', gettext('Please enter a valid connection string.'));
+ return true;
+ } else {
+ const params = {
+ host: true,
+ hostaddr: true,
+ port: true,
+ dbname: true,
+ user: true,
+ password: true,
+ connect_timeout: true,
+ client_encoding: true,
+ application_name: true,
+ options: true,
+ fallback_application_name: true,
+ sslmode: true,
+ sslcert: true,
+ sslkey: true,
+ sslrootcert: true,
+ sslcrl: true,
+ keepalives: true,
+ service: true,
+ keepalives_idle: true,
+ keepalives_interval: true,
+ keepalives_count: true,
+ sslcompression: true,
+ requirepeer: true,
+ krbsrvname: true,
+ gsslib: true,
+ };
+ let m;
+ while ((m = r.exec(state.database_connection))) {
+ if (params[m[1]]) {
+ if (m[2]) continue;
+ errMsg = gettext('Please enter a valid connection string.');
+ break;
+ }
+ errMsg = gettext('Invalid parameter in the connection string - %s.', m[1]);
+ break;
+ }
+ }
+
+ if (errMsg) {
+ setError('database_connection', errMsg);
+ return true;
+ } else {
+ setError('database_connection', null);
+ }
+ } else {
+ setError('database_connection', null);
+ }
+
+ if (isEmptyString(state.command)) {
+ setError('command', state.kind === 'SQL' ? gettext('Please specify the SQL to execute.') : gettext('Please specify the program to execute.'));
+ return true;
+ } else {
+ setError('command', null);
+ }
+ }
+ }
+}
diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/template/pgt_chaintask/css/pgt_chaintask.css b/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/template/pgt_chaintask/css/pgt_chaintask.css
new file mode 100644
index 00000000000..339ae85c290
--- /dev/null
+++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/template/pgt_chaintask/css/pgt_chaintask.css
@@ -0,0 +1,26 @@
+.icon-pgt_chaintask {
+ background-image: url('{{ url_for('NODE-pgt_chaintask.static', filename='img/pgt_chaintask.svg') }}') !important;
+ background-repeat: no-repeat;
+ background-size: 20px !important;
+ align-content: center;
+ vertical-align: middle;
+ height: 1.3em;
+}
+
+.icon-pgt_chaintask-disabled {
+ background-image: url('{{ url_for('NODE-pgt_chaintask.static', filename='img/pgt_chaintask-disabled.svg') }}') !important;
+ background-repeat: no-repeat;
+ background-size: 20px !important;
+ align-content: center;
+ vertical-align: middle;
+ height: 1.3em;
+}
+
+.icon-coll-pgt_chaintask {
+ background-image: url('{{ url_for('NODE-pgt_chaintask.static', filename='img/coll-pgt_chaintask.svg') }}') !important;
+ background-repeat: no-repeat;
+ background-size: 20px !important;
+ align-content: center;
+ vertical-align: middle;
+ height: 1.3em;
+}
diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/tests/__init__.py b/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/tests/__init__.py
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/tests/tasks_test_data.json b/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/tests/tasks_test_data.json
new file mode 100644
index 00000000000..1017e50461f
--- /dev/null
+++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/tests/tasks_test_data.json
@@ -0,0 +1,331 @@
+{
+ "pgt_task_create": [
+ {
+ "name": "Create task: With valid data.",
+ "url": "/browser/pgt_chaintask/obj/",
+ "is_positive_test": true,
+ "inventory_data": {},
+ "test_data": {
+ "task_name": "test_task_add%s",
+ "task_order": 10,
+ "kind": "SQL",
+ "command": "SELECT 1;",
+ "ignore_error": false
+ },
+ "mocking_required": false,
+ "mock_data": {},
+ "expected_data": {
+ "status_code": 200,
+ "error_msg": null,
+ "test_result_data": {}
+ }
+ },
+ {
+ "name": "Create task: With parameters.",
+ "url": "/browser/pgt_chaintask/obj/",
+ "is_positive_test": true,
+ "inventory_data": {},
+ "test_data": {
+ "task_name": "test_task_add_params%s",
+ "task_order": 10,
+ "kind": "SQL",
+ "command": "SELECT $1::text AS param;",
+ "ignore_error": false,
+ "parameters": [
+ {
+ "order_id": 1,
+ "value": "test_value"
+ }
+ ]
+ },
+ "mocking_required": false,
+ "mock_data": {},
+ "expected_data": {
+ "status_code": 200,
+ "error_msg": null,
+ "test_result_data": {}
+ }
+ },
+ {
+ "name": "Create task: With existing chain while server is down.",
+ "url": "/browser/pgt_chaintask/obj/",
+ "is_positive_test": false,
+ "inventory_data": {},
+ "test_data": {
+ "task_name": "test_task_add%s",
+ "task_order": 10,
+ "kind": "SQL",
+ "command": "SELECT 1;"
+ },
+ "mocking_required": true,
+ "mock_data": {
+ "function_name": "pgadmin.utils.driver.psycopg3.connection.Connection.execute_scalar",
+ "return_value": "[(False,'Mocked Internal Server Error')]"
+ },
+ "expected_data": {
+ "status_code": 500,
+ "error_msg": "Mocked Internal Server Error",
+ "test_result_data": {}
+ }
+ }
+ ],
+ "pgt_task_delete": [
+ {
+ "name": "Delete task: With existing task.",
+ "url": "/browser/pgt_chaintask/obj/",
+ "is_positive_test": true,
+ "inventory_data": {},
+ "test_data": {},
+ "mocking_required": false,
+ "mock_data": {},
+ "expected_data": {
+ "status_code": 200,
+ "error_msg": null,
+ "test_result_data": {}
+ },
+ "is_list": false
+ },
+ {
+ "name": "Delete tasks: With existing tasks.",
+ "url": "/browser/pgt_chaintask/obj/",
+ "is_positive_test": true,
+ "inventory_data": {},
+ "test_data": {},
+ "mocking_required": false,
+ "mock_data": {},
+ "expected_data": {
+ "status_code": 200,
+ "error_msg": null,
+ "test_result_data": {}
+ },
+ "is_list": true
+ },
+ {
+ "name": "Delete task: With existing task while server is down.",
+ "url": "/browser/pgt_chaintask/obj/",
+ "is_positive_test": false,
+ "inventory_data": {},
+ "test_data": {},
+ "mocking_required": true,
+ "mock_data": {
+ "function_name": "pgadmin.utils.driver.psycopg3.connection.Connection.execute_void",
+ "return_value": "(False,'Mocked Internal Server Error')"
+ },
+ "expected_data": {
+ "status_code": 500,
+ "error_msg": "Mocked Internal Server Error",
+ "test_result_data": {}
+ },
+ "is_list": false
+ }
+ ],
+ "pgt_task_put": [
+ {
+ "name": "Update task: With updated command.",
+ "url": "/browser/pgt_chaintask/obj/",
+ "is_positive_test": true,
+ "inventory_data": {},
+ "test_data": {
+ "command": "SELECT 2;"
+ },
+ "mocking_required": false,
+ "mock_data": {},
+ "expected_data": {
+ "status_code": 200,
+ "error_msg": null,
+ "test_result_data": {}
+ }
+ },
+ {
+ "name": "Update task: Disable ignore_error.",
+ "url": "/browser/pgt_chaintask/obj/",
+ "is_positive_test": true,
+ "inventory_data": {},
+ "test_data": {
+ "ignore_error": true
+ },
+ "mocking_required": false,
+ "mock_data": {},
+ "expected_data": {
+ "status_code": 200,
+ "error_msg": null,
+ "test_result_data": {}
+ }
+ },
+ {
+ "name": "Update task: With parameters.",
+ "url": "/browser/pgt_chaintask/obj/",
+ "is_positive_test": true,
+ "inventory_data": {},
+ "test_data": {
+ "command": "SELECT $1::text;",
+ "parameters": [
+ {
+ "order_id": 1,
+ "value": "updated_value"
+ }
+ ]
+ },
+ "mocking_required": false,
+ "mock_data": {},
+ "expected_data": {
+ "status_code": 200,
+ "error_msg": null,
+ "test_result_data": {}
+ }
+ },
+ {
+ "name": "Update task: With existing task while server is down.",
+ "url": "/browser/pgt_chaintask/obj/",
+ "is_positive_test": false,
+ "inventory_data": {},
+ "test_data": {
+ "command": "SELECT 2;"
+ },
+ "mocking_required": true,
+ "mock_data": {
+ "function_name": "pgadmin.utils.driver.psycopg3.connection.Connection.execute_void",
+ "return_value": "(False,'Mocked Internal Server Error')"
+ },
+ "expected_data": {
+ "status_code": 500,
+ "error_msg": "Mocked Internal Server Error",
+ "test_result_data": {}
+ }
+ }
+ ],
+ "pgt_task_get": [
+ {
+ "name": "Get task: With existing task.",
+ "url": "/browser/pgt_chaintask/obj/",
+ "is_positive_test": true,
+ "inventory_data": {},
+ "test_data": {},
+ "mocking_required": false,
+ "mock_data": {},
+ "expected_data": {
+ "status_code": 200,
+ "error_msg": null,
+ "test_result_data": {}
+ },
+ "is_list": false
+ },
+ {
+ "name": "Get tasks: With existing tasks.",
+ "url": "/browser/pgt_chaintask/obj/",
+ "is_positive_test": true,
+ "inventory_data": {},
+ "test_data": {},
+ "mocking_required": false,
+ "mock_data": {},
+ "expected_data": {
+ "status_code": 200,
+ "error_msg": null,
+ "test_result_data": {}
+ },
+ "is_list": true
+ },
+ {
+ "name": "Get task: With existing task while server is down.",
+ "url": "/browser/pgt_chaintask/obj/",
+ "is_positive_test": false,
+ "inventory_data": {},
+ "test_data": {},
+ "mocking_required": true,
+ "mock_data": {
+ "function_name": "pgadmin.utils.driver.psycopg3.connection.Connection.execute_dict",
+ "return_value": "(False,'Mocked Internal Server Error')"
+ },
+ "expected_data": {
+ "status_code": 500,
+ "error_msg": "Mocked Internal Server Error",
+ "test_result_data": {}
+ },
+ "is_list": false
+ }
+ ],
+ "pgt_task_get_nodes": [
+ {
+ "name": "Get task nodes: With existing task.",
+ "url": "/browser/pgt_chaintask/nodes/",
+ "is_positive_test": true,
+ "inventory_data": {},
+ "test_data": {},
+ "mocking_required": false,
+ "mock_data": {},
+ "expected_data": {
+ "status_code": 200,
+ "error_msg": null,
+ "test_result_data": {}
+ },
+ "is_list": false
+ },
+ {
+ "name": "Get tasks nodes: With existing tasks.",
+ "url": "/browser/pgt_chaintask/nodes/",
+ "is_positive_test": true,
+ "inventory_data": {},
+ "test_data": {},
+ "mocking_required": false,
+ "mock_data": {},
+ "expected_data": {
+ "status_code": 200,
+ "error_msg": null,
+ "test_result_data": {}
+ },
+ "is_list": true
+ }
+ ],
+ "pgt_task_msql": [
+ {
+ "name": "Get task msql: For existing task.",
+ "url": "/browser/pgt_chaintask/msql/",
+ "is_positive_test": true,
+ "inventory_data": {},
+ "test_data": {
+ "command": "SELECT 2;"
+ },
+ "mocking_required": false,
+ "mock_data": {},
+ "expected_data": {
+ "status_code": 200,
+ "error_msg": null,
+ "test_result_data": {}
+ },
+ "is_list": false
+ }
+ ],
+ "pgt_task_get_statistics": [
+ {
+ "name": "Get task stats: With existing task.",
+ "url": "/browser/pgt_chaintask/stats/",
+ "is_positive_test": true,
+ "inventory_data": {},
+ "test_data": {},
+ "mocking_required": false,
+ "mock_data": {},
+ "expected_data": {
+ "status_code": 200,
+ "error_msg": null,
+ "test_result_data": {}
+ }
+ },
+ {
+ "name": "Get task stats: With existing task while server is down.",
+ "url": "/browser/pgt_chaintask/stats/",
+ "is_positive_test": false,
+ "inventory_data": {},
+ "test_data": {},
+ "mocking_required": true,
+ "mock_data": {
+ "function_name": "pgadmin.utils.driver.psycopg3.connection.Connection.execute_dict",
+ "return_value": "(False,'Mocked Internal Server Error')"
+ },
+ "expected_data": {
+ "status_code": 500,
+ "error_msg": "Mocked Internal Server Error",
+ "test_result_data": {}
+ }
+ }
+ ]
+}
diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/tests/test_pgt_task_add.py b/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/tests/test_pgt_task_add.py
new file mode 100644
index 00000000000..0711de1df89
--- /dev/null
+++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/tests/test_pgt_task_add.py
@@ -0,0 +1,66 @@
+##########################################################################
+#
+# pgAdmin 4 - PostgreSQL Tools
+#
+# Copyright (C) 2013 - 2026, The pgAdmin Development Team
+# This software is released under the PostgreSQL Licence
+#
+##########################################################################
+from unittest.mock import patch
+
+import json
+import uuid
+from pgadmin.utils.route import BaseTestGenerator
+from regression.python_test_utils import test_utils as utils
+from pgadmin.browser.server_groups.servers.pg_timetable.tests import \
+ utils as pgt_utils
+from . import utils as tasks_utils
+
+
+class PgtTaskAddTestCase(BaseTestGenerator):
+ """This class will test the add task in the pgTimetable chain API"""
+ scenarios = utils.generate_scenarios("pgt_task_create",
+ tasks_utils.test_cases)
+
+ def setUp(self):
+ super().setUp()
+ flag, msg = pgt_utils.is_valid_server_to_run_pgtimetable(self)
+ if not flag:
+ self.skipTest(msg)
+ flag, msg = pgt_utils.is_pgtimetable_installed_on_server(self)
+ if not flag:
+ self.skipTest(msg)
+
+ self.data = self.test_data
+
+ name = "test_chain_add%s" % str(uuid.uuid4())[1:8]
+ self.chain_id = pgt_utils.create_pgtimetable_chain(self, name)
+
+ def runTest(self):
+ self.pgt_task_name = "test_task_add%s" % str(uuid.uuid4())[1:8]
+
+ if "task_name" in self.data:
+ self.data["task_name"] = self.pgt_task_name
+
+ if self.is_positive_test:
+ response = tasks_utils.api_create(self)
+
+ utils.assert_status_code(self, response)
+
+ response_data = json.loads(response.data)
+ self.task_id = response_data['node']['_id']
+ is_present = pgt_utils.verify_pgtimetable_task(self)
+ self.assertTrue(is_present,
+ "pgTimetable task was not created successfully.")
+ else:
+ if self.mocking_required:
+ with patch(self.mock_data["function_name"],
+ side_effect=eval(self.mock_data["return_value"])):
+ response = tasks_utils.api_create(self)
+
+ utils.assert_status_code(self, response)
+ utils.assert_error_message(self, response)
+
+ def tearDown(self):
+ """Clean up code"""
+ pgt_utils.delete_pgtimetable_chain(self)
diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/tests/test_pgt_task_delete.py b/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/tests/test_pgt_task_delete.py
new file mode 100644
index 00000000000..bd5c81e4b99
--- /dev/null
+++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/tests/test_pgt_task_delete.py
@@ -0,0 +1,74 @@
+##########################################################################
+#
+# pgAdmin 4 - PostgreSQL Tools
+#
+# Copyright (C) 2013 - 2026, The pgAdmin Development Team
+# This software is released under the PostgreSQL Licence
+#
+##########################################################################
+
+import uuid
+from unittest.mock import patch
+
+from pgadmin.utils.route import BaseTestGenerator
+from regression.python_test_utils import test_utils as utils
+from pgadmin.browser.server_groups.servers.pg_timetable.tests import \
+ utils as pgt_utils
+from . import utils as tasks_utils
+
+
+class PgtTaskDeleteTestCase(BaseTestGenerator):
+ """This class will test the delete pgTimetable chain task API"""
+ scenarios = utils.generate_scenarios("pgt_task_delete",
+ tasks_utils.test_cases)
+
+ def setUp(self):
+ super().setUp()
+ flag, msg = pgt_utils.is_valid_server_to_run_pgtimetable(self)
+ if not flag:
+ self.skipTest(msg)
+ flag, msg = pgt_utils.is_pgtimetable_installed_on_server(self)
+ if not flag:
+ self.skipTest(msg)
+
+ self.data = self.test_data
+
+ name = "test_chain_delete%s" % str(uuid.uuid4())[1:8]
+ self.chain_id = pgt_utils.create_pgtimetable_chain(self, name)
+
+ task_name = "test_task_delete%s" % str(uuid.uuid4())[1:8]
+ self.task_id = pgt_utils.create_pgtimetable_task(
+ self, task_name, self.chain_id)
+
+ if self.is_list:
+ task_name2 = "test_task2_delete%s" % str(uuid.uuid4())[1:8]
+ self.task_id_2 = pgt_utils.create_pgtimetable_task(
+ self, task_name2, self.chain_id)
+
+ def runTest(self):
+ """This function will delete pgTimetable chain task"""
+ if self.is_positive_test:
+ if self.is_list:
+ self.data['ids'] = [self.task_id, self.task_id_2]
+ response = tasks_utils.api_delete(self, '')
+ else:
+ response = tasks_utils.api_delete(self)
+
+ utils.assert_status_code(self, response)
+
+ is_present = pgt_utils.verify_pgtimetable_task(self)
+ self.assertFalse(
+ is_present,
+ "pgTimetable task was not deleted successfully")
+ else:
+ if self.mocking_required:
+ with patch(self.mock_data["function_name"],
+ side_effect=[eval(self.mock_data["return_value"])]):
+ response = tasks_utils.api_delete(self)
+
+ utils.assert_status_code(self, response)
+ utils.assert_error_message(self, response)
+
+ def tearDown(self):
+ """Clean up code"""
+ pgt_utils.delete_pgtimetable_chain(self)
diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/tests/test_pgt_task_get.py b/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/tests/test_pgt_task_get.py
new file mode 100644
index 00000000000..9e8a15bc749
--- /dev/null
+++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/tests/test_pgt_task_get.py
@@ -0,0 +1,68 @@
+##########################################################################
+#
+# pgAdmin 4 - PostgreSQL Tools
+#
+# Copyright (C) 2013 - 2026, The pgAdmin Development Team
+# This software is released under the PostgreSQL Licence
+#
+##########################################################################
+
+import uuid
+from unittest.mock import patch
+
+from pgadmin.utils.route import BaseTestGenerator
+from regression.python_test_utils import test_utils as utils
+from pgadmin.browser.server_groups.servers.pg_timetable.tests import \
+ utils as pgt_utils
+from . import utils as tasks_utils
+
+
+class PgtTaskGetTestCase(BaseTestGenerator):
+ """This class will test the get pgTimetable chain task API"""
+ scenarios = utils.generate_scenarios("pgt_task_get",
+ tasks_utils.test_cases)
+
+ def setUp(self):
+ super().setUp()
+ flag, msg = pgt_utils.is_valid_server_to_run_pgtimetable(self)
+ if not flag:
+ self.skipTest(msg)
+ flag, msg = pgt_utils.is_pgtimetable_installed_on_server(self)
+ if not flag:
+ self.skipTest(msg)
+
+ self.data = self.test_data
+
+ name = "test_chain_get%s" % str(uuid.uuid4())[1:8]
+ self.chain_id = pgt_utils.create_pgtimetable_chain(self, name)
+
+ task_name = "test_task_get%s" % str(uuid.uuid4())[1:8]
+ self.task_id = pgt_utils.create_pgtimetable_task(
+ self, task_name, self.chain_id)
+
+ if self.is_list:
+ task_name2 = "test_task_get%s" % str(uuid.uuid4())[1:8]
+ self.task_id_2 = pgt_utils.create_pgtimetable_task(
+ self, task_name2, self.chain_id)
+
+ def runTest(self):
+ """This function will get pgTimetable chain task"""
+ if self.is_positive_test:
+ if self.is_list:
+ response = tasks_utils.api_get(self, '')
+ else:
+ response = tasks_utils.api_get(self)
+
+ utils.assert_status_code(self, response)
+ else:
+ if self.mocking_required:
+ with patch(self.mock_data["function_name"],
+ side_effect=[eval(self.mock_data["return_value"])]):
+ response = tasks_utils.api_get(self)
+
+ utils.assert_status_code(self, response)
+ utils.assert_error_message(self, response)
+
+ def tearDown(self):
+ """Clean up code"""
+ pgt_utils.delete_pgtimetable_chain(self)
diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/tests/test_pgt_task_get_msql.py b/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/tests/test_pgt_task_get_msql.py
new file mode 100644
index 00000000000..ddf866c52a4
--- /dev/null
+++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/tests/test_pgt_task_get_msql.py
@@ -0,0 +1,52 @@
+##########################################################################
+#
+# pgAdmin 4 - PostgreSQL Tools
+#
+# Copyright (C) 2013 - 2026, The pgAdmin Development Team
+# This software is released under the PostgreSQL Licence
+#
+##########################################################################
+
+import uuid
+
+from pgadmin.utils.route import BaseTestGenerator
+from regression.python_test_utils import test_utils as utils
+from pgadmin.browser.server_groups.servers.pg_timetable.tests import \
+ utils as pgt_utils
+from . import utils as tasks_utils
+
+
+class PgtTaskGetMsqlTestCase(BaseTestGenerator):
+ """This class will test the msql pgTimetable chain task API"""
+ scenarios = utils.generate_scenarios("pgt_task_msql",
+ tasks_utils.test_cases)
+
+ def setUp(self):
+ super().setUp()
+ flag, msg = pgt_utils.is_valid_server_to_run_pgtimetable(self)
+ if not flag:
+ self.skipTest(msg)
+ flag, msg = pgt_utils.is_pgtimetable_installed_on_server(self)
+ if not flag:
+ self.skipTest(msg)
+
+ self.data = self.test_data
+
+ name = "test_chain_msql%s" % str(uuid.uuid4())[1:8]
+ self.chain_id = pgt_utils.create_pgtimetable_chain(self, name)
+
+ task_name = "test_task_msql%s" % str(uuid.uuid4())[1:8]
+ self.task_id = pgt_utils.create_pgtimetable_task(
+ self, task_name, self.chain_id)
+
+ def runTest(self):
+ """This function will get pgTimetable msql chain task"""
+ if self.is_positive_test:
+ url_encode_data = self.data
+ response = tasks_utils.api_get_msql(self, url_encode_data)
+
+ utils.assert_status_code(self, response)
+
+ def tearDown(self):
+ """Clean up code"""
+ pgt_utils.delete_pgtimetable_chain(self)
diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/tests/test_pgt_task_get_nodes.py b/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/tests/test_pgt_task_get_nodes.py
new file mode 100644
index 00000000000..dfd6cb98b86
--- /dev/null
+++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/tests/test_pgt_task_get_nodes.py
@@ -0,0 +1,68 @@
+##########################################################################
+#
+# pgAdmin 4 - PostgreSQL Tools
+#
+# Copyright (C) 2013 - 2026, The pgAdmin Development Team
+# This software is released under the PostgreSQL Licence
+#
+##########################################################################
+
+import uuid
+from unittest.mock import patch
+
+from pgadmin.utils.route import BaseTestGenerator
+from regression.python_test_utils import test_utils as utils
+from pgadmin.browser.server_groups.servers.pg_timetable.tests import \
+ utils as pgt_utils
+from . import utils as tasks_utils
+
+
+class PgtTaskGetNodesTestCase(BaseTestGenerator):
+ """This class will test the get nodes pgTimetable chain task API"""
+ scenarios = utils.generate_scenarios("pgt_task_get_nodes",
+ tasks_utils.test_cases)
+
+ def setUp(self):
+ super().setUp()
+ flag, msg = pgt_utils.is_valid_server_to_run_pgtimetable(self)
+ if not flag:
+ self.skipTest(msg)
+ flag, msg = pgt_utils.is_pgtimetable_installed_on_server(self)
+ if not flag:
+ self.skipTest(msg)
+
+ self.data = self.test_data
+
+ name = "test_chain_get_nodes%s" % str(uuid.uuid4())[1:8]
+ self.chain_id = pgt_utils.create_pgtimetable_chain(self, name)
+
+ task_name = "test_task_get_nodes%s" % str(uuid.uuid4())[1:8]
+ self.task_id = pgt_utils.create_pgtimetable_task(
+ self, task_name, self.chain_id)
+
+ if self.is_list:
+ task_name2 = "test_task_get_nodes%s" % str(uuid.uuid4())[1:8]
+ self.task_id_2 = pgt_utils.create_pgtimetable_task(
+ self, task_name2, self.chain_id)
+
+ def runTest(self):
+ """This function will get pgTimetable chain task nodes"""
+ if self.is_positive_test:
+ if self.is_list:
+ response = tasks_utils.api_get(self, '')
+ else:
+ response = tasks_utils.api_get(self)
+
+ utils.assert_status_code(self, response)
+ else:
+ if self.mocking_required:
+ with patch(self.mock_data["function_name"],
+ side_effect=[eval(self.mock_data["return_value"])]):
+ response = tasks_utils.api_get(self)
+
+ utils.assert_status_code(self, response)
+ utils.assert_error_message(self, response)
+
+ def tearDown(self):
+ """Clean up code"""
+ pgt_utils.delete_pgtimetable_chain(self)
diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/tests/test_pgt_task_get_statistics.py b/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/tests/test_pgt_task_get_statistics.py
new file mode 100644
index 00000000000..34e4d596e66
--- /dev/null
+++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/tests/test_pgt_task_get_statistics.py
@@ -0,0 +1,61 @@
+##########################################################################
+#
+# pgAdmin 4 - PostgreSQL Tools
+#
+# Copyright (C) 2013 - 2026, The pgAdmin Development Team
+# This software is released under the PostgreSQL Licence
+#
+##########################################################################
+
+import uuid
+from unittest.mock import patch
+
+from pgadmin.utils.route import BaseTestGenerator
+from regression.python_test_utils import test_utils as utils
+from pgadmin.browser.server_groups.servers.pg_timetable.tests import \
+ utils as pgt_utils
+from . import utils as tasks_utils
+
+
+class PgtTaskGetStatsTestCase(BaseTestGenerator):
+ """This class will test the get pgTimetable chain task stats API"""
+ scenarios = utils.generate_scenarios("pgt_task_get_statistics",
+ tasks_utils.test_cases)
+
+ def setUp(self):
+ super().setUp()
+ flag, msg = pgt_utils.is_valid_server_to_run_pgtimetable(self)
+ if not flag:
+ self.skipTest(msg)
+ flag, msg = pgt_utils.is_pgtimetable_installed_on_server(self)
+ if not flag:
+ self.skipTest(msg)
+
+ self.data = self.test_data
+
+ name = "test_chain_get_stats%s" % str(uuid.uuid4())[1:8]
+ self.chain_id = pgt_utils.create_pgtimetable_chain(self, name)
+
+ task_name = "test_task_get_stats%s" % str(uuid.uuid4())[1:8]
+ self.task_id = pgt_utils.create_pgtimetable_task(
+ self, task_name, self.chain_id)
+
+ def runTest(self):
+ """This function will get pgTimetable chain task stats"""
+
+ if self.is_positive_test:
+ response = tasks_utils.api_get_stats(self)
+
+ utils.assert_status_code(self, response)
+ else:
+ if self.mocking_required:
+ with patch(self.mock_data["function_name"],
+ side_effect=[eval(self.mock_data["return_value"])]):
+ response = tasks_utils.api_get_stats(self)
+
+ utils.assert_status_code(self, response)
+ utils.assert_error_message(self, response)
+
+ def tearDown(self):
+ """Clean up code"""
+ pgt_utils.delete_pgtimetable_chain(self)
diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/tests/test_pgt_task_put.py b/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/tests/test_pgt_task_put.py
new file mode 100644
index 00000000000..79b52887c4a
--- /dev/null
+++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/tests/test_pgt_task_put.py
@@ -0,0 +1,65 @@
+##########################################################################
+#
+# pgAdmin 4 - PostgreSQL Tools
+#
+# Copyright (C) 2013 - 2026, The pgAdmin Development Team
+# This software is released under the PostgreSQL Licence
+#
+##########################################################################
+
+import uuid
+from unittest.mock import patch
+
+from pgadmin.utils.route import BaseTestGenerator
+from regression.python_test_utils import test_utils as utils
+from pgadmin.browser.server_groups.servers.pg_timetable.tests import \
+ utils as pgt_utils
+from . import utils as tasks_utils
+
+
+class PgtTaskPutTestCase(BaseTestGenerator):
+ """This class will test the update pgTimetable chain task API"""
+ scenarios = utils.generate_scenarios("pgt_task_put",
+ tasks_utils.test_cases)
+
+ def setUp(self):
+ super().setUp()
+ flag, msg = pgt_utils.is_valid_server_to_run_pgtimetable(self)
+ if not flag:
+ self.skipTest(msg)
+ flag, msg = pgt_utils.is_pgtimetable_installed_on_server(self)
+ if not flag:
+ self.skipTest(msg)
+
+ self.data = self.test_data
+
+ name = "test_chain_update%s" % str(uuid.uuid4())[1:8]
+ self.chain_id = pgt_utils.create_pgtimetable_chain(self, name)
+
+ task_name = "test_task_update%s" % str(uuid.uuid4())[1:8]
+ self.task_id = pgt_utils.create_pgtimetable_task(
+ self, task_name, self.chain_id)
+
+ def runTest(self):
+ """This function will update pgTimetable chain task"""
+
+ self.data['task_id'] = str(self.task_id)
+
+ if self.is_positive_test:
+ response = tasks_utils.api_put(self)
+
+ utils.assert_status_code(self, response)
+ else:
+ if self.mocking_required:
+ with patch(self.mock_data["function_name"],
+ side_effect=[eval(self.mock_data["return_value"])]):
+ response = tasks_utils.api_put(self)
+ else:
+ response = tasks_utils.api_put(self)
+
+ utils.assert_status_code(self, response)
+ utils.assert_error_message(self, response)
+
+ def tearDown(self):
+ """Clean up code"""
+ pgt_utils.delete_pgtimetable_chain(self)
diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/tests/utils.py b/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/tests/utils.py
new file mode 100644
index 00000000000..9e8a0931b6f
--- /dev/null
+++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/tests/utils.py
@@ -0,0 +1,85 @@
+##########################################################################
+#
+# pgAdmin 4 - PostgreSQL Tools
+#
+# Copyright (C) 2013 - 2026, The pgAdmin Development Team
+# This software is released under the PostgreSQL Licence
+#
+##########################################################################
+
+import os
+import json
+from urllib.parse import urlencode
+
+from regression.python_test_utils import test_utils as utils
+
+CURRENT_PATH = os.path.dirname(os.path.realpath(__file__))
+with open(CURRENT_PATH + "/tasks_test_data.json") as data_file:
+ test_cases = json.load(data_file)
+
+
+def api_create(self):
+ return self.tester.post('{0}{1}/{2}/{3}/'.
+ format(self.url, utils.SERVER_GROUP,
+ self.server_id, self.chain_id),
+ data=json.dumps(self.data),
+ content_type='html/json')
+
+
+def api_delete(self, task_id=None):
+ if task_id is None:
+ task_id = self.task_id
+ return self.tester.delete('{0}{1}/{2}/{3}/{4}'.
+ format(self.url, utils.SERVER_GROUP,
+ self.server_id, self.chain_id,
+ task_id),
+ data=json.dumps(self.data),
+ content_type='html/json')
+
+
+def api_put(self):
+ return self.tester.put('{0}{1}/{2}/{3}/{4}'.
+ format(self.url, utils.SERVER_GROUP, self.server_id,
+ self.chain_id, self.task_id),
+ data=json.dumps(self.data),
+ content_type='html/json')
+
+
+def api_get(self, task_id=None):
+ if task_id is None:
+ task_id = self.task_id
+ return self.tester.get('{0}{1}/{2}/{3}/{4}'.
+ format(self.url, utils.SERVER_GROUP,
+ self.server_id, self.chain_id,
+ task_id),
+ content_type='html/json')
+
+
+def api_get_msql(self, url_encode_data, task_id=None):
+ if task_id is None:
+ task_id = '/' + str(self.task_id)
+ return self.tester.get("{0}{1}/{2}/{3}{4}?{5}".
+ format(self.url, utils.SERVER_GROUP,
+ self.server_id, self.chain_id,
+ task_id,
+ urlencode(url_encode_data)),
+ data=json.dumps(self.data),
+ follow_redirects=True)
+
+
+def api_get_sql(self):
+ return self.tester.get('{0}{1}/{2}/{3}/{4}'.
+ format(self.url.replace('/obj/', '/sql/'),
+ utils.SERVER_GROUP,
+ self.server_id, self.chain_id,
+ self.task_id),
+ content_type='html/json')
+
+
+def api_get_stats(self):
+ return self.tester.get('{0}{1}/{2}/{3}/{4}'.
+ format(self.url.replace('/obj/', '/stats/'),
+ utils.SERVER_GROUP,
+ self.server_id, self.chain_id,
+ self.task_id),
+ content_type='html/json')
diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/templates/macros/pgt_chaintask.macros b/web/pgadmin/browser/server_groups/servers/pg_timetable/templates/macros/pgt_chaintask.macros
new file mode 100644
index 00000000000..88bb109cc5c
--- /dev/null
+++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/templates/macros/pgt_chaintask.macros
@@ -0,0 +1,62 @@
+{% macro INSERT(has_connstr, chain_id, data, conn) -%}
+INSERT INTO timetable.task(
+ chain_id, task_name, task_order, command, kind{% if has_connstr %}, database_connection{% endif %}
+ {% if data.ignore_error is defined %}, ignore_error{% endif %}
+) VALUES (
+ {{ chain_id|qtLiteral(conn) }}::integer,
+ {{ data.task_name|qtLiteral(conn) }}::text,
+ {{ data.task_order|qtLiteral(conn) }}::integer,
+ {{ data.command|qtLiteral(conn) }}::text,
+ {{ data.kind|qtLiteral(conn) }}::timetable.command_kind
+ {% if has_connstr %}, {% if data.database_connection %}{{ data.database_connection|qtLiteral(conn) }}::text{% else %}NULL{% endif %}{% endif %}
+ {% if data.ignore_error is defined %},
+ {% if data.ignore_error %}true{% else %}false{% endif %}{% endif %}
+) RETURNING task_id
+{%- endmacro %}
+{% macro UPDATE(has_connstr, chain_id, task_id, data, conn) -%}
+{% set has_task_fields = 'task_name' in data or 'task_order' in data or 'command' in data or 'kind' in data or 'ignore_error' in data or (has_connstr and 'database_connection' in data) %}
+{% if has_task_fields %}
+{% set comma = joiner(', ') %}
+UPDATE timetable.task
+SET
+{% if 'task_name' in data %}{{ comma() }}
+ task_name = {{ data.task_name|qtLiteral(conn) }}::text{% endif %}{% if 'task_order' in data %}{{ comma() }}
+ task_order = {{ data.task_order|qtLiteral(conn) }}::integer{% endif %}{% if 'command' in data %}{{ comma() }}
+ command = {{ data.command|qtLiteral(conn) }}::text{% endif %}{% if 'kind' in data %}{{ comma() }}
+ kind = {{ data.kind|qtLiteral(conn) }}::timetable.command_kind{% endif %}{% if 'ignore_error' in data %}{{ comma() }}
+ ignore_error = {% if data.ignore_error %}true{% else %}false{% endif %}{% endif %}{% if has_connstr and 'database_connection' in data %}{{ comma() }}
+ database_connection = {{ data.database_connection|qtLiteral(conn) }}::text{% endif %}
+ WHERE task_id = {{ task_id|qtLiteral(conn) }}::integer AND chain_id = {{ chain_id|qtLiteral(conn) }}::integer;
+{% endif %}
+{% if 'parameters' in data %}
+DELETE FROM timetable.parameter WHERE task_id = {{ task_id|qtLiteral(conn) }}::integer;
+{% if data.parameters|length > 0 %}
+INSERT INTO timetable.parameter(task_id, order_id, value)
+VALUES
+{% for param in data.parameters %}
+({{ task_id|qtLiteral(conn) }}::integer, {{ param.order_id|qtLiteral(conn) }}::integer, {% if param._is_json %}{{ param.value|qtLiteral(conn) }}::jsonb{% else %}to_jsonb({{ param.value|qtLiteral(conn) }}::text){% endif %}){% if not loop.last %},{% endif %}
+{% endfor %}
+;
+{% endif %}
+{% endif %}
+{%- endmacro %}
+{% macro DELETE(chain_id, task_id, conn) -%}
+DELETE FROM timetable.parameter WHERE task_id = {{ task_id|qtLiteral(conn) }}::integer;
+DELETE FROM timetable.task
+WHERE task_id = {{ task_id|qtLiteral(conn) }}::integer AND chain_id = {{ chain_id|qtLiteral(conn) }}::integer;
+{%- endmacro %}
+{% macro PROPERTIES(has_connstr, chain_id, task_id, conn) -%}
+SELECT
+ t.task_id, t.chain_id, t.task_name, t.task_order,
+ t.kind::text AS kind,
+ t.command, t.database_connection, t.ignore_error,
+ NULL AS parameters
+FROM
+ timetable.task t
+WHERE
+{% if task_id %}
+ t.task_id = {{ task_id|qtLiteral(conn) }}::integer AND
+{% endif %}
+ t.chain_id = {{ chain_id|qtLiteral(conn) }}::integer
+ORDER BY t.task_order, t.task_name;
+{%- endmacro %}
diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chain/css/pgt_chain.css b/web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chain/css/pgt_chain.css
new file mode 100644
index 00000000000..1dd88450b4e
--- /dev/null
+++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chain/css/pgt_chain.css
@@ -0,0 +1,26 @@
+.icon-pgt_chain {
+ background-image: url('{{ url_for('NODE-pgt_chain.static', filename='img/pgt_chain.svg') }}') !important;
+ background-repeat: no-repeat;
+ background-size: 20px !important;
+ align-content: center;
+ vertical-align: middle;
+ height: 1.3em;
+}
+
+.icon-pgt_chain-disabled {
+ background-image: url('{{ url_for('NODE-pgt_chain.static', filename='img/pgt_chain-disabled.svg') }}') !important;
+ background-repeat: no-repeat;
+ background-size: 20px !important;
+ align-content: center;
+ vertical-align: middle;
+ height: 1.3em;
+}
+
+.icon-coll-pgt_chain {
+ background-image: url('{{ url_for('NODE-pgt_chain.static', filename='img/coll-pgt_chain.svg') }}') !important;
+ background-repeat: no-repeat;
+ background-size: 20px !important;
+ align-content: center;
+ vertical-align: middle;
+ height: 1.3em;
+}
diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chain/sql/default/create.sql b/web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chain/sql/default/create.sql
new file mode 100644
index 00000000000..25981b6e586
--- /dev/null
+++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chain/sql/default/create.sql
@@ -0,0 +1,43 @@
+DO $$
+DECLARE
+ cid integer;
+ tid integer;
+BEGIN
+INSERT INTO timetable.chain(
+ chain_name, run_at, client_name, live, max_instances, timeout, self_destruct, exclusive_execution, on_error
+) VALUES (
+ {{ data.chain_name|qtLiteral(conn) }}::text,
+ {% if data.run_at and data.run_at|length > 0 %}{{ data.run_at|qtLiteral(conn) }}::text{% else %}NULL{% endif %},
+ {% if data.client_name and data.client_name|length > 0 %}{{ data.client_name|qtLiteral(conn) }}::text{% else %}NULL{% endif %},
+ {% if data.live %}true{% else %}false{% endif %},
+ {% if data.max_instances is defined and data.max_instances is not none %}{{ data.max_instances|qtLiteral(conn) }}::integer{% else %}NULL{% endif %},
+ {% if data.timeout is defined and data.timeout is not none %}{{ data.timeout|qtLiteral(conn) }}::integer{% else %}0{% endif %},
+ {% if data.self_destruct %}true{% else %}false{% endif %},
+ {% if data.exclusive_execution %}true{% else %}false{% endif %},
+ {% if data.on_error and data.on_error|length > 0 %}{{ data.on_error|qtLiteral(conn) }}::text{% else %}NULL{% endif %}
+) RETURNING chain_id INTO cid;
+{% if 'ctasks' in data and data.ctasks|length > 0 %}
+
+{% for task in data.ctasks %}
+INSERT INTO timetable.task(
+ chain_id, task_name, task_order, command, kind
+ {% if 'ignore_error' in task %}, ignore_error{% endif %}
+ {% if 'database_connection' in task and task.database_connection %}, database_connection{% endif %}
+) VALUES (
+ cid, {{ task.task_name|qtLiteral(conn) }}::text, {{ task.task_order|qtLiteral(conn) }}::integer, {{ task.command|qtLiteral(conn) }}::text, {{ task.kind|qtLiteral(conn) }}::timetable.command_kind
+ {% if 'ignore_error' in task %}, {% if task.ignore_error %}true{% else %}false{% endif %}{% endif %}
+ {% if 'database_connection' in task and task.database_connection %}, {{ task.database_connection|qtLiteral(conn) }}::text{% endif %}
+) RETURNING task_id INTO tid;
+{% if 'parameters' in task and task.parameters|length > 0 %}
+{% for param in task.parameters %}
+INSERT INTO timetable.parameter(task_id, order_id, value)
+VALUES (tid, {{ param.order_id|qtLiteral(conn) }}::integer, {% if param._is_json %}{{ param.value|qtLiteral(conn) }}::jsonb{% else %}to_jsonb({{ param.value|qtLiteral(conn) }}::text){% endif %});
+{% endfor %}
+{% endif %}
+{% endfor %}{% endif %}
+
+END
+$$;
+{% if fetch_id %}
+SELECT chain_id FROM timetable.chain WHERE chain_name = {{ data.chain_name|qtLiteral(conn) }}::text;
+{% endif %}
diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chain/sql/default/delete.sql b/web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chain/sql/default/delete.sql
new file mode 100644
index 00000000000..91c4121f449
--- /dev/null
+++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chain/sql/default/delete.sql
@@ -0,0 +1,13 @@
+{# Execute deletions safely within an isolated transaction block #}
+BEGIN;
+
+{# 1. Purge dependent child tasks assigned to this target execution context #}
+DELETE FROM timetable.task WHERE chain_id = {{ chain_id|int }};
+
+{# 2. Clear any execution log histories tracking this specific scheduler block #}
+DELETE FROM timetable.execution_log WHERE chain_id = {{ chain_id|int }};
+
+{# 3. Safe termination of the master background orchestration node #}
+DELETE FROM timetable.chain WHERE chain_id = {{ chain_id|int }};
+
+COMMIT;
diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chain/sql/default/nodes.sql b/web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chain/sql/default/nodes.sql
new file mode 100644
index 00000000000..37c814ffb98
--- /dev/null
+++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chain/sql/default/nodes.sql
@@ -0,0 +1,8 @@
+SELECT
+ chain_id, chain_name, run_at, max_instances, timeout, live, self_destruct, exclusive_execution, client_name, on_error
+FROM
+ timetable.chain
+{% if chain_id %}
+WHERE chain_id = {{ chain_id|qtLiteral(conn) }}::integer
+{% endif %}
+ORDER BY chain_name;
diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chain/sql/default/properties.sql b/web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chain/sql/default/properties.sql
new file mode 100644
index 00000000000..792f54e54b9
--- /dev/null
+++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chain/sql/default/properties.sql
@@ -0,0 +1,29 @@
+SELECT
+ j.chain_id, j.chain_name, j.live,
+ j.max_instances, j.timeout, j.self_destruct,
+ j.exclusive_execution, j.on_error,
+ j.client_name,
+ CASE WHEN el.returncode = 0 THEN 'success' ELSE 'failure (' || returncode::text || ')' END AS status,
+ el.last_run, el.finished,
+ CASE
+ WHEN (el.finished - el.last_run) > INTERVAL '1 day' THEN
+ to_char((el.finished - el.last_run), 'DD" days "HH24"h" MI"m" SS"s"')
+ ELSE
+ to_char((el.finished - el.last_run), 'HH24"h" MI"m" SS"s"')
+ END AS duration,
+ j.run_at,
+ el.last_run,
+ CASE WHEN j.live THEN timetable.next_run(j.run_at) ELSE NULL END AS next_run,
+ COALESCE(ac.client_name, 'Not running') AS currently_running_on
+FROM
+ timetable.chain j
+ LEFT OUTER JOIN (
+ SELECT DISTINCT ON (l.chain_id) returncode, chain_id, last_run, finished
+ FROM timetable.execution_log AS l
+ ORDER BY chain_id, txid DESC
+ ) el ON el.chain_id = j.chain_id
+ LEFT OUTER JOIN timetable.active_chain AS ac ON j.chain_id = ac.chain_id
+{% if chain_id %}
+WHERE j.chain_id = {{ chain_id|qtLiteral(conn) }}::integer
+{% endif %}
+ORDER BY j.chain_name;
diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chain/sql/default/run_now.sql b/web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chain/sql/default/run_now.sql
new file mode 100644
index 00000000000..d1f7f15fcd3
--- /dev/null
+++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chain/sql/default/run_now.sql
@@ -0,0 +1,21 @@
+DO $$
+DECLARE
+ _client_name text;
+ _chain_id bigint := {{ chain_id|qtLiteral(conn) }}::bigint;
+BEGIN
+ SELECT client_name INTO _client_name
+ FROM timetable.chain
+ WHERE chain_id = _chain_id;
+
+ IF _client_name IS NULL OR _client_name = '' THEN
+ SELECT client_name INTO _client_name
+ FROM timetable.active_session LIMIT 1;
+
+ IF NOT FOUND THEN
+ RAISE EXCEPTION 'no client_name specified and no active agents';
+ END IF;
+ END IF;
+
+ PERFORM timetable.notify_chain_start(_chain_id, _client_name);
+END
+$$;
diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chain/sql/default/stats.sql b/web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chain/sql/default/stats.sql
new file mode 100644
index 00000000000..8bbba6884e6
--- /dev/null
+++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chain/sql/default/stats.sql
@@ -0,0 +1,18 @@
+SELECT
+ el.txid AS {{ conn|qtIdent(_('Run')) }},
+ CASE WHEN el.returncode = 0 THEN 'success' ELSE 'failure (' || returncode::text || ')' END AS {{ conn|qtIdent(_('Status')) }},
+ el.last_run AS {{ conn|qtIdent(_('Start time')) }},
+ el.finished AS {{ conn|qtIdent(_('End time')) }},
+ CASE
+ WHEN (el.finished - el.last_run) > INTERVAL '1 day' THEN
+ to_char((el.finished - el.last_run), 'DD" days "HH24"h" MI"m" SS"s"')
+ ELSE
+ to_char((el.finished - el.last_run), 'HH24"h" MI"m" SS"s"')
+ END AS {{ conn|qtIdent(_('Duration')) }},
+ t.task_name AS {{ conn|qtIdent(_('Task')) }}
+FROM
+ timetable.execution_log AS el INNER JOIN timetable.task AS t On (el.chain_id = t.chain_id AND el.task_id = t.task_id)
+WHERE
+ el.chain_id = {{ chain_id|qtLiteral(conn) }}::integer
+ORDER BY last_run DESC, txid DESC
+LIMIT {{ rows_threshold }};
diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chain/sql/default/tasks.sql b/web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chain/sql/default/tasks.sql
new file mode 100644
index 00000000000..b78af6d437e
--- /dev/null
+++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chain/sql/default/tasks.sql
@@ -0,0 +1,2 @@
+{% import 'macros/pgt_chaintask.macros' as TASK %}
+{{ TASK.PROPERTIES(has_connstr, chain_id, task_id, conn) }}
diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chain/sql/default/update.sql b/web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chain/sql/default/update.sql
new file mode 100644
index 00000000000..47aae734ee9
--- /dev/null
+++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chain/sql/default/update.sql
@@ -0,0 +1,13 @@
+{% if 'chain_name' in data or 'live' in data or 'max_instances' in data or 'timeout' in data or 'self_destruct' in data or 'exclusive_execution' in data or 'client_name' in data or 'on_error' in data or 'run_at' in data %}
+UPDATE timetable.chain
+SET {% if 'chain_name' in data %}chain_name = {{ data.chain_name|qtLiteral(conn) }}::text{% if 'live' in data or 'max_instances' in data or 'timeout' in data or 'self_destruct' in data or 'exclusive_execution' in data or 'client_name' in data or 'on_error' in data or 'run_at' in data %}, {% endif %}{% endif %}
+{% if 'live' in data %}live = {% if data.live %}true{% else %}false{% endif %}{% if 'max_instances' in data or 'timeout' in data or 'self_destruct' in data or 'exclusive_execution' in data or 'client_name' in data or 'on_error' in data or 'run_at' in data %}, {% endif %}{% endif %}
+{% if 'max_instances' in data %}max_instances = {% if data.max_instances is not none %}{{ data.max_instances|qtLiteral(conn) }}::integer{% else %}NULL{% endif %}{% if 'timeout' in data or 'self_destruct' in data or 'exclusive_execution' in data or 'client_name' in data or 'on_error' in data or 'run_at' in data %}, {% endif %}{% endif %}
+{% if 'timeout' in data %}timeout = {% if data.timeout is not none %}{{ data.timeout|qtLiteral(conn) }}::integer{% else %}0{% endif %}{% if 'self_destruct' in data or 'exclusive_execution' in data or 'client_name' in data or 'on_error' in data or 'run_at' in data %}, {% endif %}{% endif %}
+{% if 'self_destruct' in data %}self_destruct = {% if data.self_destruct %}true{% else %}false{% endif %}{% if 'exclusive_execution' in data or 'client_name' in data or 'on_error' in data or 'run_at' in data %}, {% endif %}{% endif %}
+{% if 'exclusive_execution' in data %}exclusive_execution = {% if data.exclusive_execution %}true{% else %}false{% endif %}{% if 'client_name' in data or 'on_error' in data or 'run_at' in data %}, {% endif %}{% endif %}
+{% if 'client_name' in data %}client_name = {% if data.client_name and data.client_name|length > 0 %}{{ data.client_name|qtLiteral(conn) }}::text{% else %}NULL{% endif %}{% if 'on_error' in data or 'run_at' in data %}, {% endif %}{% endif %}
+{% if 'on_error' in data %}on_error = {% if data.on_error and data.on_error|length > 0 %}{{ data.on_error|qtLiteral(conn) }}::text{% else %}NULL{% endif %}{% if 'run_at' in data %}, {% endif %}{% endif %}
+{% if 'run_at' in data %}run_at = {% if data.run_at and data.run_at|length > 0 %}{{ data.run_at|qtLiteral(conn) }}::text{% else %}NULL{% endif %}{% endif %}
+WHERE chain_id = {{ chain_id|qtLiteral(conn) }}::integer;
+{% endif %}
diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chaintask/css/pgt_chaintask.css b/web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chaintask/css/pgt_chaintask.css
new file mode 100644
index 00000000000..339ae85c290
--- /dev/null
+++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chaintask/css/pgt_chaintask.css
@@ -0,0 +1,26 @@
+.icon-pgt_chaintask {
+ background-image: url('{{ url_for('NODE-pgt_chaintask.static', filename='img/pgt_chaintask.svg') }}') !important;
+ background-repeat: no-repeat;
+ background-size: 20px !important;
+ align-content: center;
+ vertical-align: middle;
+ height: 1.3em;
+}
+
+.icon-pgt_chaintask-disabled {
+ background-image: url('{{ url_for('NODE-pgt_chaintask.static', filename='img/pgt_chaintask-disabled.svg') }}') !important;
+ background-repeat: no-repeat;
+ background-size: 20px !important;
+ align-content: center;
+ vertical-align: middle;
+ height: 1.3em;
+}
+
+.icon-coll-pgt_chaintask {
+ background-image: url('{{ url_for('NODE-pgt_chaintask.static', filename='img/coll-pgt_chaintask.svg') }}') !important;
+ background-repeat: no-repeat;
+ background-size: 20px !important;
+ align-content: center;
+ vertical-align: middle;
+ height: 1.3em;
+}
diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chaintask/sql/default/create.sql b/web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chaintask/sql/default/create.sql
new file mode 100644
index 00000000000..352a3bcdf79
--- /dev/null
+++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chaintask/sql/default/create.sql
@@ -0,0 +1,20 @@
+{% import 'macros/pgt_chaintask.macros' as TASK %}
+WITH tid AS (
+ {{ TASK.INSERT(has_connstr, chain_id, data, conn) | replace(';', '') }}
+)
+{% if 'parameters' in data and data.parameters|length > 0 %}
+, ins AS (
+ INSERT INTO timetable.parameter(task_id, order_id, value)
+ SELECT tid.task_id, p.order_id, p.val
+ FROM tid,
+ (VALUES
+ {% for param in data.parameters %}
+ ({{ param.order_id|qtLiteral(conn) }}::integer, {% if param._is_json %}{{ param.value|qtLiteral(conn) }}::jsonb{% else %}to_jsonb({{ param.value|qtLiteral(conn) }}::text){% endif %}){% if not loop.last %},{% endif %}
+ {% endfor %}
+ ) AS p(order_id, val)
+ RETURNING task_id
+)
+SELECT task_id FROM ins LIMIT 1
+{% else %}
+SELECT task_id FROM tid;
+{% endif %}
diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chaintask/sql/default/delete.sql b/web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chaintask/sql/default/delete.sql
new file mode 100644
index 00000000000..d2da5b04fa9
--- /dev/null
+++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chaintask/sql/default/delete.sql
@@ -0,0 +1,2 @@
+{% import 'macros/pgt_chaintask.macros' as TASK %}
+{{ TASK.DELETE(chain_id, task_id, conn) }}
diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chaintask/sql/default/nodes.sql b/web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chaintask/sql/default/nodes.sql
new file mode 100644
index 00000000000..5ec42b8f519
--- /dev/null
+++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chaintask/sql/default/nodes.sql
@@ -0,0 +1,11 @@
+SELECT
+ task_id, chain_id, task_name, task_order,
+ kind::text AS kind
+FROM
+ timetable.task
+WHERE
+{% if task_id %}
+ task_id = {{ task_id|qtLiteral(conn) }}::integer AND
+{% endif %}
+ chain_id = {{ chain_id|qtLiteral(conn) }}::integer
+ORDER BY task_order, task_name;
diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chaintask/sql/default/properties.sql b/web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chaintask/sql/default/properties.sql
new file mode 100644
index 00000000000..b78af6d437e
--- /dev/null
+++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chaintask/sql/default/properties.sql
@@ -0,0 +1,2 @@
+{% import 'macros/pgt_chaintask.macros' as TASK %}
+{{ TASK.PROPERTIES(has_connstr, chain_id, task_id, conn) }}
diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chaintask/sql/default/stats.sql b/web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chaintask/sql/default/stats.sql
new file mode 100644
index 00000000000..b06eca79082
--- /dev/null
+++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chaintask/sql/default/stats.sql
@@ -0,0 +1,19 @@
+SELECT
+ txid AS {{ conn|qtIdent(_('Run')) }},
+ CASE WHEN returncode = 0 THEN 'success' ELSE 'failure (' || returncode::text || ')' END AS {{ conn|qtIdent(_('Status')) }},
+ last_run AS {{ conn|qtIdent(_('Start time')) }},
+ CASE
+ WHEN (el.finished - el.last_run) > INTERVAL '1 day' THEN
+ to_char((el.finished - el.last_run), 'DD" days "HH24"h" MI"m" SS"s"')
+ ELSE
+ to_char((el.finished - el.last_run), 'HH24"h" MI"m" SS"s"')
+ END AS {{ conn|qtIdent(_('Duration')) }},
+ el.finished AS {{ conn|qtIdent(_('End time')) }},
+ left(output,500) AS {{ conn|qtIdent(_('Output')) }}
+FROM
+ timetable.execution_log AS el
+WHERE
+ chain_id = {{ chain_id|qtLiteral(conn) }}::integer
+ AND task_id = {{ task_id|qtLiteral(conn) }}::integer
+ORDER BY last_run DESC NULLS FIRST, txid DESC
+LIMIT {{ rows_threshold }};
diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chaintask/sql/default/update.sql b/web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chaintask/sql/default/update.sql
new file mode 100644
index 00000000000..adbc82fc3e4
--- /dev/null
+++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chaintask/sql/default/update.sql
@@ -0,0 +1,2 @@
+{% import 'macros/pgt_chaintask.macros' as TASK %}
+{{ TASK.UPDATE(has_connstr, chain_id, task_id, data, conn) }}
diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/tests/__init__.py b/web/pgadmin/browser/server_groups/servers/pg_timetable/tests/__init__.py
new file mode 100644
index 00000000000..cf4bf367cac
--- /dev/null
+++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/tests/__init__.py
@@ -0,0 +1,15 @@
+##########################################################################
+#
+# pgAdmin 4 - PostgreSQL Tools
+#
+# Copyright (C) 2013 - 2026, The pgAdmin Development Team
+# This software is released under the PostgreSQL Licence
+#
+##########################################################################
+
+from pgadmin.utils.route import BaseTestGenerator
+
+
+class PgTimetableCreateTestCase(BaseTestGenerator):
+ def runTest(self):
+ return
diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/tests/pgt_timetable_test_data.json b/web/pgadmin/browser/server_groups/servers/pg_timetable/tests/pgt_timetable_test_data.json
new file mode 100644
index 00000000000..00f243eb8f9
--- /dev/null
+++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/tests/pgt_timetable_test_data.json
@@ -0,0 +1,448 @@
+{
+ "pgt_chain_create": [
+ {
+ "name": "Create chain: With valid data.",
+ "url": "/browser/pgt_chain/obj/",
+ "is_positive_test": true,
+ "inventory_data": {},
+ "test_data": {
+ "chain_name": "test_chain_add%s",
+ "live": true,
+ "max_instances": null,
+ "timeout": 0,
+ "self_destruct": false,
+ "exclusive_execution": false,
+ "client_name": "",
+ "on_error": null,
+ "run_at": ""
+ },
+ "mocking_required": false,
+ "mock_data": {},
+ "expected_data": {
+ "status_code": 200,
+ "error_msg": null,
+ "test_result_data": {}
+ }
+ },
+ {
+ "name": "Create chain: With invalid data (missing chain_name).",
+ "url": "/browser/pgt_chain/obj/",
+ "is_positive_test": false,
+ "inventory_data": {},
+ "test_data": {
+ "live": true
+ },
+ "mocking_required": false,
+ "mock_data": {},
+ "expected_data": {
+ "status_code": 410,
+ "error_msg": "Could not find the required parameter (chain_name).",
+ "test_result_data": {}
+ }
+ },
+ {
+ "name": "Create chain: With valid data while server down.",
+ "url": "/browser/pgt_chain/obj/",
+ "is_positive_test": false,
+ "inventory_data": {},
+ "test_data": {
+ "chain_name": "test_chain_add%s",
+ "live": true
+ },
+ "mocking_required": true,
+ "mock_data": {
+ "function_name": "pgadmin.utils.driver.psycopg3.connection.Connection.execute_scalar",
+ "return_value": "[(False,'Mocked Internal Server Error')]"
+ },
+ "expected_data": {
+ "status_code": 500,
+ "error_msg": "Mocked Internal Server Error",
+ "test_result_data": {}
+ }
+ },
+ {
+ "name": "Create chain: With tasks and parameters.",
+ "url": "/browser/pgt_chain/obj/",
+ "is_positive_test": true,
+ "inventory_data": {},
+ "test_data": {
+ "chain_name": "test_chain_add%s",
+ "live": true,
+ "client_name": "",
+ "ctasks": [
+ {
+ "task_name": "test_task_1",
+ "task_order": 10,
+ "kind": "SQL",
+ "command": "SELECT 1;",
+ "ignore_error": false,
+ "parameters": [
+ {
+ "order_id": 1,
+ "value": "param_value"
+ }
+ ]
+ }
+ ]
+ },
+ "mocking_required": false,
+ "mock_data": {},
+ "expected_data": {
+ "status_code": 200,
+ "error_msg": null,
+ "test_result_data": {}
+ }
+ }
+ ],
+ "pgt_chain_get": [
+ {
+ "name": "Get chain: With existing chain.",
+ "url": "/browser/pgt_chain/obj/",
+ "is_positive_test": true,
+ "inventory_data": {},
+ "test_data": {},
+ "mocking_required": false,
+ "mock_data": {},
+ "expected_data": {
+ "status_code": 200,
+ "error_msg": null,
+ "test_result_data": {}
+ },
+ "is_list": false
+ },
+ {
+ "name": "Get chains: With existing chains.",
+ "url": "/browser/pgt_chain/obj/",
+ "is_positive_test": true,
+ "inventory_data": {},
+ "test_data": {},
+ "mocking_required": false,
+ "mock_data": {},
+ "expected_data": {
+ "status_code": 200,
+ "error_msg": null,
+ "test_result_data": {}
+ },
+ "is_list": true
+ },
+ {
+ "name": "Get chain: With non-existing chain.",
+ "url": "/browser/pgt_chain/obj/",
+ "is_positive_test": false,
+ "inventory_data": {},
+ "test_data": {
+ "chain_id": 99999
+ },
+ "mocking_required": false,
+ "mock_data": {},
+ "expected_data": {
+ "status_code": 410,
+ "error_msg": "Could not find the pgTimeTable chain on the server.",
+ "test_result_data": {}
+ },
+ "is_list": false
+ },
+ {
+ "name": "Get chain: With existing chain while server down.",
+ "url": "/browser/pgt_chain/obj/",
+ "is_positive_test": false,
+ "inventory_data": {},
+ "test_data": {},
+ "mocking_required": true,
+ "mock_data": {
+ "function_name": "pgadmin.utils.driver.psycopg3.connection.Connection.execute_dict",
+ "return_value": "(False,'Mocked Internal Server Error')"
+ },
+ "expected_data": {
+ "status_code": 500,
+ "error_msg": "Mocked Internal Server Error",
+ "test_result_data": {}
+ },
+ "is_list": false
+ }
+ ],
+ "pgt_chain_put": [
+ {
+ "name": "Update chain: With description.",
+ "url": "/browser/pgt_chain/obj/",
+ "is_positive_test": true,
+ "inventory_data": {},
+ "test_data": {
+ "chain_name": "updated_chain_name"
+ },
+ "mocking_required": false,
+ "mock_data": {},
+ "expected_data": {
+ "status_code": 200,
+ "error_msg": null,
+ "test_result_data": {}
+ }
+ },
+ {
+ "name": "Update chain: Disable chain.",
+ "url": "/browser/pgt_chain/obj/",
+ "is_positive_test": true,
+ "inventory_data": {},
+ "test_data": {
+ "live": false
+ },
+ "mocking_required": false,
+ "mock_data": {},
+ "expected_data": {
+ "status_code": 200,
+ "error_msg": null,
+ "test_result_data": {}
+ }
+ },
+ {
+ "name": "Update chain: With existing chain while server down.",
+ "url": "/browser/pgt_chain/obj/",
+ "is_positive_test": false,
+ "inventory_data": {},
+ "test_data": {
+ "chain_name": "updated_chain_name"
+ },
+ "mocking_required": true,
+ "mock_data": {
+ "function_name": "pgadmin.utils.driver.psycopg3.connection.Connection.execute_void",
+ "return_value": "(False,'Mocked Internal Server Error')"
+ },
+ "expected_data": {
+ "status_code": 500,
+ "error_msg": "Mocked Internal Server Error",
+ "test_result_data": {}
+ }
+ }
+ ],
+ "pgt_chain_delete": [
+ {
+ "name": "Delete chain: With existing chain.",
+ "url": "/browser/pgt_chain/obj/",
+ "is_positive_test": true,
+ "inventory_data": {},
+ "test_data": {},
+ "mocking_required": false,
+ "mock_data": {},
+ "expected_data": {
+ "status_code": 200,
+ "error_msg": null,
+ "test_result_data": {}
+ },
+ "is_list": false
+ },
+ {
+ "name": "Delete chains: With existing chains.",
+ "url": "/browser/pgt_chain/obj/",
+ "is_positive_test": true,
+ "inventory_data": {},
+ "test_data": {},
+ "mocking_required": false,
+ "mock_data": {},
+ "expected_data": {
+ "status_code": 200,
+ "error_msg": null,
+ "test_result_data": {}
+ },
+ "is_list": true
+ }
+ ],
+ "pgt_chain_sql": [
+ {
+ "name": "Get chain sql: With existing chain.",
+ "url": "/browser/pgt_chain/sql/",
+ "is_positive_test": true,
+ "inventory_data": {},
+ "test_data": {},
+ "mocking_required": false,
+ "mock_data": {},
+ "expected_data": {
+ "status_code": 200,
+ "error_msg": null,
+ "test_result_data": {}
+ }
+ },
+ {
+ "name": "Get chain sql: With non-existing chain.",
+ "url": "/browser/pgt_chain/sql/",
+ "is_positive_test": false,
+ "inventory_data": {},
+ "test_data": {
+ "chain_id": 99999
+ },
+ "mocking_required": false,
+ "mock_data": {},
+ "expected_data": {
+ "status_code": 410,
+ "error_msg": "Could not find the object on the server.",
+ "test_result_data": {}
+ }
+ },
+ {
+ "name": "Get chain sql: With existing chain while server down.",
+ "url": "/browser/pgt_chain/sql/",
+ "is_positive_test": false,
+ "inventory_data": {},
+ "test_data": {},
+ "mocking_required": true,
+ "mock_data": {
+ "function_name": "pgadmin.utils.driver.psycopg3.connection.Connection.execute_dict",
+ "return_value": "(False,'Mocked Internal Server Error')"
+ },
+ "expected_data": {
+ "status_code": 500,
+ "error_msg": "Mocked Internal Server Error",
+ "test_result_data": {}
+ }
+ }
+ ],
+ "pgt_chain_get_nodes": [
+ {
+ "name": "Get chain nodes: With existing chain.",
+ "url": "/browser/pgt_chain/nodes/",
+ "is_positive_test": true,
+ "inventory_data": {},
+ "test_data": {},
+ "mocking_required": false,
+ "mock_data": {},
+ "expected_data": {
+ "status_code": 200,
+ "error_msg": null,
+ "test_result_data": {}
+ },
+ "is_list": false
+ },
+ {
+ "name": "Get chains nodes: With existing chains.",
+ "url": "/browser/pgt_chain/nodes/",
+ "is_positive_test": true,
+ "inventory_data": {},
+ "test_data": {},
+ "mocking_required": false,
+ "mock_data": {},
+ "expected_data": {
+ "status_code": 200,
+ "error_msg": null,
+ "test_result_data": {}
+ },
+ "is_list": true
+ },
+ {
+ "name": "Get chain nodes: With non-existing chain.",
+ "url": "/browser/pgt_chain/nodes/",
+ "is_positive_test": false,
+ "inventory_data": {},
+ "test_data": {
+ "chain_id": 99999
+ },
+ "mocking_required": false,
+ "mock_data": {},
+ "expected_data": {
+ "status_code": 410,
+ "error_msg": "Could not find the pgTimeTable chain on the server.",
+ "test_result_data": {}
+ },
+ "is_list": false
+ },
+ {
+ "name": "Get chain nodes: With existing chain while server down.",
+ "url": "/browser/pgt_chain/nodes/",
+ "is_positive_test": false,
+ "inventory_data": {},
+ "test_data": {},
+ "mocking_required": true,
+ "mock_data": {
+ "function_name": "pgadmin.utils.driver.psycopg3.connection.Connection.execute_dict",
+ "return_value": "(False,'Mocked Internal Server Error')"
+ },
+ "expected_data": {
+ "status_code": 500,
+ "error_msg": "Mocked Internal Server Error",
+ "test_result_data": {}
+ },
+ "is_list": false
+ }
+ ],
+ "pgt_chain_msql": [
+ {
+ "name": "Get chain msql: With existing chain and updated name.",
+ "url": "/browser/pgt_chain/msql/",
+ "is_positive_test": true,
+ "inventory_data": {},
+ "test_data": {
+ "chain_name": "updated_chain_name"
+ },
+ "mocking_required": false,
+ "mock_data": {},
+ "expected_data": {
+ "status_code": 200,
+ "error_msg": null,
+ "test_result_data": {}
+ }
+ },
+ {
+ "name": "Get chain msql: With existing chain disable.",
+ "url": "/browser/pgt_chain/msql/",
+ "is_positive_test": true,
+ "inventory_data": {},
+ "test_data": {
+ "live": false
+ },
+ "mocking_required": false,
+ "mock_data": {},
+ "expected_data": {
+ "status_code": 200,
+ "error_msg": null,
+ "test_result_data": {}
+ }
+ }
+ ],
+ "pgt_chain_get_statistics": [
+ {
+ "name": "Get chain stats: With existing chain.",
+ "url": "/browser/pgt_chain/stats/",
+ "is_positive_test": true,
+ "inventory_data": {},
+ "test_data": {},
+ "mocking_required": false,
+ "mock_data": {},
+ "expected_data": {
+ "status_code": 200,
+ "error_msg": null,
+ "test_result_data": {}
+ }
+ },
+ {
+ "name": "Get chain stats: With existing chain while server down.",
+ "url": "/browser/pgt_chain/stats/",
+ "is_positive_test": false,
+ "inventory_data": {},
+ "test_data": {},
+ "mocking_required": true,
+ "mock_data": {
+ "function_name": "pgadmin.utils.driver.psycopg3.connection.Connection.execute_dict",
+ "return_value": "(False,'Mocked Internal Server Error')"
+ },
+ "expected_data": {
+ "status_code": 500,
+ "error_msg": "Mocked Internal Server Error",
+ "test_result_data": {}
+ }
+ }
+ ],
+ "pgt_chain_run_now": [
+ {
+ "name": "Run chain now: With existing chain.",
+ "url": "/browser/pgt_chain/run_now/",
+ "is_positive_test": true,
+ "inventory_data": {},
+ "test_data": {},
+ "mocking_required": false,
+ "mock_data": {},
+ "expected_data": {
+ "status_code": 200,
+ "error_msg": null,
+ "test_result_data": {}
+ }
+ }
+ ]
+}
diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/tests/test_pgt_chain_add.py b/web/pgadmin/browser/server_groups/servers/pg_timetable/tests/test_pgt_chain_add.py
new file mode 100644
index 00000000000..8445964c75e
--- /dev/null
+++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/tests/test_pgt_chain_add.py
@@ -0,0 +1,68 @@
+##########################################################################
+#
+# pgAdmin 4 - PostgreSQL Tools
+#
+# Copyright (C) 2013 - 2026, The pgAdmin Development Team
+# This software is released under the PostgreSQL Licence
+#
+##########################################################################
+from unittest.mock import patch
+
+import json
+import uuid
+from pgadmin.utils.route import BaseTestGenerator
+from regression.python_test_utils import test_utils as utils
+from . import utils as pgt_utils
+
+
+class PgtChainAddTestCase(BaseTestGenerator):
+ """This class will test the add pgTimetable chain API"""
+ scenarios = utils.generate_scenarios("pgt_chain_create",
+ pgt_utils.test_cases)
+
+ def setUp(self):
+ super().setUp()
+ self.data = self.test_data
+
+ flag, msg = pgt_utils.is_valid_server_to_run_pgtimetable(self)
+ if not flag:
+ self.skipTest(msg)
+ flag, msg = pgt_utils.is_pgtimetable_installed_on_server(self)
+ if not flag:
+ self.skipTest(msg)
+
+ def runTest(self):
+ """This function will add pgTimetable chain"""
+ self.pgt_chain = "test_chain_add%s" % str(uuid.uuid4())[1:8]
+
+ if "chain_name" in self.data:
+ self.data["chain_name"] = self.pgt_chain
+
+ if self.is_positive_test:
+ response = pgt_utils.api_create(self)
+
+ utils.assert_status_code(self, response)
+
+ response_data = json.loads(response.data)
+ self.chain_id = response_data['node']['_id']
+ is_present = pgt_utils.verify_pgtimetable_chain(self)
+ self.assertTrue(is_present,
+ "pgTimetable chain was not created successfully")
+ else:
+ if self.mocking_required:
+ with patch(self.mock_data["function_name"],
+ side_effect=eval(self.mock_data["return_value"])):
+ response = pgt_utils.api_create(self)
+
+ utils.assert_status_code(self, response)
+ utils.assert_error_message(self, response)
+ else:
+ response = pgt_utils.api_create(self)
+
+ utils.assert_status_code(self, response)
+ utils.assert_error_message(self, response)
+
+ def tearDown(self):
+ """Clean up code"""
+ if self.is_positive_test:
+ pgt_utils.delete_pgtimetable_chain(self)
diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/tests/test_pgt_chain_delete.py b/web/pgadmin/browser/server_groups/servers/pg_timetable/tests/test_pgt_chain_delete.py
new file mode 100644
index 00000000000..10e7c77e1da
--- /dev/null
+++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/tests/test_pgt_chain_delete.py
@@ -0,0 +1,58 @@
+##########################################################################
+#
+# pgAdmin 4 - PostgreSQL Tools
+#
+# Copyright (C) 2013 - 2026, The pgAdmin Development Team
+# This software is released under the PostgreSQL Licence
+#
+##########################################################################
+
+import uuid
+from pgadmin.utils.route import BaseTestGenerator
+from regression.python_test_utils import test_utils as utils
+from . import utils as pgt_utils
+
+
+class PgtChainDeleteTestCase(BaseTestGenerator):
+ """This class will test the delete pgTimetable chain API"""
+ scenarios = utils.generate_scenarios("pgt_chain_delete",
+ pgt_utils.test_cases)
+
+ def setUp(self):
+ super().setUp()
+ flag, msg = pgt_utils.is_valid_server_to_run_pgtimetable(self)
+ if not flag:
+ self.skipTest(msg)
+ flag, msg = pgt_utils.is_pgtimetable_installed_on_server(self)
+ if not flag:
+ self.skipTest(msg)
+
+ self.data = self.test_data
+
+ name = "test_chain_delete%s" % str(uuid.uuid4())[1:8]
+ self.chain_id = pgt_utils.create_pgtimetable_chain(self, name)
+
+ if self.is_list:
+ name2 = "test_chain2_delete%s" % str(uuid.uuid4())[1:8]
+ self.chain_id2 = pgt_utils.create_pgtimetable_chain(self, name2)
+
+ def runTest(self):
+ """This function will delete pgTimetable chain"""
+ if self.is_positive_test:
+ if self.is_list:
+ self.data['ids'] = [self.chain_id, self.chain_id2]
+ response = pgt_utils.api_delete(self, '')
+ else:
+ response = pgt_utils.api_delete(self)
+
+ utils.assert_status_code(self, response)
+
+ is_present = pgt_utils.verify_pgtimetable_chain(self)
+ self.assertFalse(
+ is_present, "pgTimetable chain was not deleted successfully")
+
+ def tearDown(self):
+ """Clean up code"""
+ pgt_utils.delete_pgtimetable_chain(self)
+ if self.is_list:
+ pgt_utils.delete_pgtimetable_chain(self, self.chain_id2)
diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/tests/test_pgt_chain_get.py b/web/pgadmin/browser/server_groups/servers/pg_timetable/tests/test_pgt_chain_get.py
new file mode 100644
index 00000000000..01e833256a7
--- /dev/null
+++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/tests/test_pgt_chain_get.py
@@ -0,0 +1,69 @@
+##########################################################################
+#
+# pgAdmin 4 - PostgreSQL Tools
+#
+# Copyright (C) 2013 - 2026, The pgAdmin Development Team
+# This software is released under the PostgreSQL Licence
+#
+##########################################################################
+
+import uuid
+from unittest.mock import patch
+
+from pgadmin.utils.route import BaseTestGenerator
+from regression.python_test_utils import test_utils as utils
+from . import utils as pgt_utils
+
+
+class PgtChainGetTestCase(BaseTestGenerator):
+ """This class will test the get pgTimetable chain API"""
+ scenarios = utils.generate_scenarios("pgt_chain_get",
+ pgt_utils.test_cases)
+
+ def setUp(self):
+ super().setUp()
+ flag, msg = pgt_utils.is_valid_server_to_run_pgtimetable(self)
+ if not flag:
+ self.skipTest(msg)
+ flag, msg = pgt_utils.is_pgtimetable_installed_on_server(self)
+ if not flag:
+ self.skipTest(msg)
+
+ self.data = self.test_data
+
+ name = "test_chain_get%s" % str(uuid.uuid4())[1:8]
+ self.chain_id = pgt_utils.create_pgtimetable_chain(self, name)
+
+ if self.is_list:
+ name_2 = "test_chain_get%s" % str(uuid.uuid4())[1:8]
+ self.chain_id_2 = pgt_utils.create_pgtimetable_chain(self, name_2)
+
+ def runTest(self):
+ """This function will get pgTimetable chain"""
+
+ if self.is_positive_test:
+ if self.is_list:
+ response = pgt_utils.api_get(self, '')
+ else:
+ response = pgt_utils.api_get(self)
+
+ utils.assert_status_code(self, response)
+ else:
+ if self.mocking_required:
+ with patch(self.mock_data["function_name"],
+ side_effect=[eval(self.mock_data["return_value"])]):
+ response = pgt_utils.api_get(self)
+ elif 'chain_id' in self.data:
+ existing_chain_id = self.chain_id
+ self.chain_id = self.data["chain_id"]
+ response = pgt_utils.api_get(self)
+ self.chain_id = existing_chain_id
+
+ utils.assert_status_code(self, response)
+ utils.assert_error_message(self, response)
+
+ def tearDown(self):
+ """Clean up code"""
+ pgt_utils.delete_pgtimetable_chain(self)
+ if self.is_list:
+ pgt_utils.delete_pgtimetable_chain(self, self.chain_id_2)
diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/tests/test_pgt_chain_get_msql.py b/web/pgadmin/browser/server_groups/servers/pg_timetable/tests/test_pgt_chain_get_msql.py
new file mode 100644
index 00000000000..035e7c890f6
--- /dev/null
+++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/tests/test_pgt_chain_get_msql.py
@@ -0,0 +1,49 @@
+##########################################################################
+#
+# pgAdmin 4 - PostgreSQL Tools
+#
+# Copyright (C) 2013 - 2026, The pgAdmin Development Team
+# This software is released under the PostgreSQL Licence
+#
+##########################################################################
+from unittest.mock import patch
+
+import json
+import uuid
+from pgadmin.utils.route import BaseTestGenerator
+from regression.python_test_utils import test_utils as utils
+from . import utils as pgt_utils
+
+
+class PgtChainGetMsqlTestCase(BaseTestGenerator):
+ """This class will test the msql for pgTimetable chain API"""
+ scenarios = utils.generate_scenarios("pgt_chain_msql",
+ pgt_utils.test_cases)
+
+ def setUp(self):
+ super().setUp()
+ flag, msg = pgt_utils.is_valid_server_to_run_pgtimetable(self)
+ if not flag:
+ self.skipTest(msg)
+ flag, msg = pgt_utils.is_pgtimetable_installed_on_server(self)
+ if not flag:
+ self.skipTest(msg)
+
+ self.data = self.test_data
+
+ name = "test_chain_msql%s" % str(uuid.uuid4())[1:8]
+ self.chain_id = pgt_utils.create_pgtimetable_chain(self, name)
+
+ def runTest(self):
+ """This function will get msql for pgTimetable chain"""
+
+ if self.is_positive_test:
+ url_encode_data = self.data
+
+ response = pgt_utils.api_get_msql(self, url_encode_data)
+
+ utils.assert_status_code(self, response)
+
+ def tearDown(self):
+ """Clean up code"""
+ pgt_utils.delete_pgtimetable_chain(self)
diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/tests/test_pgt_chain_get_nodes.py b/web/pgadmin/browser/server_groups/servers/pg_timetable/tests/test_pgt_chain_get_nodes.py
new file mode 100644
index 00000000000..972eacebf99
--- /dev/null
+++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/tests/test_pgt_chain_get_nodes.py
@@ -0,0 +1,69 @@
+##########################################################################
+#
+# pgAdmin 4 - PostgreSQL Tools
+#
+# Copyright (C) 2013 - 2026, The pgAdmin Development Team
+# This software is released under the PostgreSQL Licence
+#
+##########################################################################
+
+import uuid
+from unittest.mock import patch
+
+from pgadmin.utils.route import BaseTestGenerator
+from regression.python_test_utils import test_utils as utils
+from . import utils as pgt_utils
+
+
+class PgtChainGetNodesTestCase(BaseTestGenerator):
+ """This class will test the get pgTimetable chain nodes API"""
+ scenarios = utils.generate_scenarios("pgt_chain_get_nodes",
+ pgt_utils.test_cases)
+
+ def setUp(self):
+ super().setUp()
+ flag, msg = pgt_utils.is_valid_server_to_run_pgtimetable(self)
+ if not flag:
+ self.skipTest(msg)
+ flag, msg = pgt_utils.is_pgtimetable_installed_on_server(self)
+ if not flag:
+ self.skipTest(msg)
+
+ self.data = self.test_data
+
+ name = "test_chain_get_nodes%s" % str(uuid.uuid4())[1:8]
+ self.chain_id = pgt_utils.create_pgtimetable_chain(self, name)
+
+ if self.is_list:
+ name_2 = "test_chain_get_nodes%s" % str(uuid.uuid4())[1:8]
+ self.chain_id_2 = pgt_utils.create_pgtimetable_chain(self, name_2)
+
+ def runTest(self):
+ """This function will get pgTimetable chain nodes"""
+
+ if self.is_positive_test:
+ if self.is_list:
+ response = pgt_utils.api_get(self, '')
+ else:
+ response = pgt_utils.api_get(self)
+
+ utils.assert_status_code(self, response)
+ else:
+ if self.mocking_required:
+ with patch(self.mock_data["function_name"],
+ side_effect=[eval(self.mock_data["return_value"])]):
+ response = pgt_utils.api_get(self)
+ elif 'chain_id' in self.data:
+ existing_chain_id = self.chain_id
+ self.chain_id = self.data["chain_id"]
+ response = pgt_utils.api_get(self)
+ self.chain_id = existing_chain_id
+
+ utils.assert_status_code(self, response)
+ utils.assert_error_message(self, response)
+
+ def tearDown(self):
+ """Clean up code"""
+ pgt_utils.delete_pgtimetable_chain(self)
+ if self.is_list:
+ pgt_utils.delete_pgtimetable_chain(self, self.chain_id_2)
diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/tests/test_pgt_chain_get_statistics.py b/web/pgadmin/browser/server_groups/servers/pg_timetable/tests/test_pgt_chain_get_statistics.py
new file mode 100644
index 00000000000..4ccbff0aed9
--- /dev/null
+++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/tests/test_pgt_chain_get_statistics.py
@@ -0,0 +1,55 @@
+##########################################################################
+#
+# pgAdmin 4 - PostgreSQL Tools
+#
+# Copyright (C) 2013 - 2026, The pgAdmin Development Team
+# This software is released under the PostgreSQL Licence
+#
+##########################################################################
+
+import uuid
+from unittest.mock import patch
+
+from pgadmin.utils.route import BaseTestGenerator
+from regression.python_test_utils import test_utils as utils
+from . import utils as pgt_utils
+
+
+class PgtChainGetStatsTestCase(BaseTestGenerator):
+ """This class will test the get pgTimetable chain stats API"""
+ scenarios = utils.generate_scenarios("pgt_chain_get_statistics",
+ pgt_utils.test_cases)
+
+ def setUp(self):
+ super().setUp()
+ flag, msg = pgt_utils.is_valid_server_to_run_pgtimetable(self)
+ if not flag:
+ self.skipTest(msg)
+ flag, msg = pgt_utils.is_pgtimetable_installed_on_server(self)
+ if not flag:
+ self.skipTest(msg)
+
+ self.data = self.test_data
+
+ name = "test_chain_get_stats%s" % str(uuid.uuid4())[1:8]
+ self.chain_id = pgt_utils.create_pgtimetable_chain(self, name)
+
+ def runTest(self):
+ """This function will get pgTimetable chain stats"""
+
+ if self.is_positive_test:
+ response = pgt_utils.api_get_stats(self)
+
+ utils.assert_status_code(self, response)
+ else:
+ if self.mocking_required:
+ with patch(self.mock_data["function_name"],
+ side_effect=[eval(self.mock_data["return_value"])]):
+ response = pgt_utils.api_get_stats(self)
+
+ utils.assert_status_code(self, response)
+ utils.assert_error_message(self, response)
+
+ def tearDown(self):
+ """Clean up code"""
+ pgt_utils.delete_pgtimetable_chain(self)
diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/tests/test_pgt_chain_put.py b/web/pgadmin/browser/server_groups/servers/pg_timetable/tests/test_pgt_chain_put.py
new file mode 100644
index 00000000000..40d052d9093
--- /dev/null
+++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/tests/test_pgt_chain_put.py
@@ -0,0 +1,55 @@
+##########################################################################
+#
+# pgAdmin 4 - PostgreSQL Tools
+#
+# Copyright (C) 2013 - 2026, The pgAdmin Development Team
+# This software is released under the PostgreSQL Licence
+#
+##########################################################################
+from unittest.mock import patch
+
+import json
+import uuid
+from pgadmin.utils.route import BaseTestGenerator
+from regression.python_test_utils import test_utils as utils
+from . import utils as pgt_utils
+
+
+class PgtChainPutTestCase(BaseTestGenerator):
+ """This class will test the put pgTimetable chain API"""
+ scenarios = utils.generate_scenarios("pgt_chain_put",
+ pgt_utils.test_cases)
+
+ def setUp(self):
+ super().setUp()
+ flag, msg = pgt_utils.is_valid_server_to_run_pgtimetable(self)
+ if not flag:
+ self.skipTest(msg)
+ flag, msg = pgt_utils.is_pgtimetable_installed_on_server(self)
+ if not flag:
+ self.skipTest(msg)
+
+ self.data = self.test_data
+
+ name = "test_chain_put%s" % str(uuid.uuid4())[1:8]
+ self.chain_id = pgt_utils.create_pgtimetable_chain(self, name)
+
+ def runTest(self):
+ """This function will update pgTimetable chain"""
+
+ if self.is_positive_test:
+ response = pgt_utils.api_put(self)
+
+ utils.assert_status_code(self, response)
+ else:
+ if self.mocking_required:
+ with patch(self.mock_data["function_name"],
+ side_effect=[eval(self.mock_data["return_value"])]):
+ response = pgt_utils.api_put(self)
+
+ utils.assert_status_code(self, response)
+ utils.assert_error_message(self, response)
+
+ def tearDown(self):
+ """Clean up code"""
+ pgt_utils.delete_pgtimetable_chain(self)
diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/tests/test_pgt_chain_sql.py b/web/pgadmin/browser/server_groups/servers/pg_timetable/tests/test_pgt_chain_sql.py
new file mode 100644
index 00000000000..246aa3e34b8
--- /dev/null
+++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/tests/test_pgt_chain_sql.py
@@ -0,0 +1,60 @@
+##########################################################################
+#
+# pgAdmin 4 - PostgreSQL Tools
+#
+# Copyright (C) 2013 - 2026, The pgAdmin Development Team
+# This software is released under the PostgreSQL Licence
+#
+##########################################################################
+
+import uuid
+from unittest.mock import patch
+
+from pgadmin.utils.route import BaseTestGenerator
+from regression.python_test_utils import test_utils as utils
+from . import utils as pgt_utils
+
+
+class PgtChainSqlTestCase(BaseTestGenerator):
+ """This class will test the get pgTimetable chain sql API"""
+ scenarios = utils.generate_scenarios("pgt_chain_sql",
+ pgt_utils.test_cases)
+
+ def setUp(self):
+ super().setUp()
+ flag, msg = pgt_utils.is_valid_server_to_run_pgtimetable(self)
+ if not flag:
+ self.skipTest(msg)
+ flag, msg = pgt_utils.is_pgtimetable_installed_on_server(self)
+ if not flag:
+ self.skipTest(msg)
+
+ self.data = self.test_data
+
+ name = "test_chain_sql%s" % str(uuid.uuid4())[1:8]
+ self.chain_id = pgt_utils.create_pgtimetable_chain(self, name)
+
+ def runTest(self):
+ """This function will get pgTimetable chain sql"""
+
+ if self.is_positive_test:
+ response = pgt_utils.api_get_sql(self)
+
+ utils.assert_status_code(self, response)
+ else:
+ if self.mocking_required:
+ with patch(self.mock_data["function_name"],
+ side_effect=[eval(self.mock_data["return_value"])]):
+ response = pgt_utils.api_get_sql(self)
+ elif 'chain_id' in self.data:
+ existing_chain_id = self.chain_id
+ self.chain_id = self.data["chain_id"]
+ response = pgt_utils.api_get_sql(self)
+ self.chain_id = existing_chain_id
+
+ utils.assert_status_code(self, response)
+ utils.assert_error_message(self, response)
+
+ def tearDown(self):
+ """Clean up code"""
+ pgt_utils.delete_pgtimetable_chain(self)
diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/tests/utils.py b/web/pgadmin/browser/server_groups/servers/pg_timetable/tests/utils.py
new file mode 100644
index 00000000000..da2ba04a18b
--- /dev/null
+++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/tests/utils.py
@@ -0,0 +1,339 @@
+##########################################################################
+#
+# pgAdmin 4 - PostgreSQL Tools
+#
+# Copyright (C) 2013 - 2026, The pgAdmin Development Team
+# This software is released under the PostgreSQL Licence
+#
+##########################################################################
+
+import sys
+import traceback
+import os
+import json
+from urllib.parse import urlencode
+
+from regression.python_test_utils import test_utils as utils
+from regression import parent_node_dict
+from pgadmin.utils import server_utils
+
+CURRENT_PATH = os.path.dirname(os.path.realpath(__file__))
+with open(CURRENT_PATH + "/pgt_timetable_test_data.json") as data_file:
+ test_cases = json.load(data_file)
+
+
+def api_create(self):
+ return self.tester.post('{0}{1}/{2}/'.
+ format(self.url, str(utils.SERVER_GROUP),
+ str(self.server_id)),
+ data=json.dumps(self.data),
+ content_type='html/json')
+
+
+def api_get(self, chain_id=None):
+ if chain_id is None:
+ chain_id = self.chain_id
+ return self.tester.get('{0}{1}/{2}/{3}'.
+ format(self.url, utils.SERVER_GROUP,
+ self.server_id, chain_id),
+ content_type='html/json')
+
+
+def api_put(self):
+ return self.tester.put('{0}{1}/{2}/{3}'.
+ format(self.url, utils.SERVER_GROUP,
+ self.server_id, self.chain_id),
+ data=json.dumps(self.data),
+ follow_redirects=True,
+ content_type='html/json')
+
+
+def api_delete(self, chain_id=None):
+ if chain_id is None:
+ chain_id = self.chain_id
+ return self.tester.delete('{0}{1}/{2}/{3}'.
+ format(self.url, utils.SERVER_GROUP,
+ self.server_id, chain_id),
+ data=json.dumps(self.data),
+ content_type='html/json')
+
+
+def api_get_msql(self, url_encode_data):
+ return self.tester.get("{0}{1}/{2}/{3}?{4}".
+ format(self.url, utils.SERVER_GROUP,
+ self.server_id, self.chain_id,
+ urlencode(url_encode_data)),
+ follow_redirects=True)
+
+
+def api_get_sql(self):
+ return self.tester.get('{0}{1}/{2}/{3}'.
+ format(self.url.replace('/obj/', '/sql/'),
+ utils.SERVER_GROUP,
+ self.server_id, self.chain_id),
+ content_type='html/json')
+
+
+def api_get_stats(self):
+ return self.tester.get('{0}{1}/{2}/{3}'.
+ format(self.url.replace('/obj/', '/stats/'),
+ utils.SERVER_GROUP,
+ self.server_id, self.chain_id),
+ content_type='html/json')
+
+
+def api_run_now(self):
+ return self.tester.put('{0}{1}/{2}/{3}'.
+ format(self.url.replace('/obj/', '/run_now/'),
+ utils.SERVER_GROUP,
+ self.server_id, self.chain_id),
+ data=json.dumps(self.data),
+ content_type='html/json')
+
+
+def is_valid_server_to_run_pgtimetable(self):
+ self.server_id = parent_node_dict["server"][-1]["server_id"]
+ server_con = server_utils.connect_server(self, self.server_id)
+ if not server_con["info"] == "Server connected.":
+ raise Exception("Could not connect to server to add pgTimetable chain.")
+ return True, None
+
+
+def is_pgtimetable_installed_on_server(self):
+ try:
+ connection = utils.get_db_connection(
+ self.server['db'],
+ self.server['username'],
+ self.server['db_password'],
+ self.server['host'],
+ self.server['port'],
+ self.server['sslmode']
+ )
+ pg_cursor = connection.cursor()
+
+ SQL = """
+ SELECT
+ has_table_privilege(
+ 'timetable.chain', 'INSERT, SELECT, UPDATE'
+ ) has_priviledge
+ WHERE EXISTS(
+ SELECT has_schema_privilege('timetable', 'USAGE')
+ WHERE EXISTS(
+ SELECT cl.oid FROM pg_catalog.pg_class cl
+ LEFT JOIN pg_catalog.pg_namespace ns ON ns.oid=relnamespace
+ WHERE relname='chain' AND nspname='timetable'
+ )
+ )
+ """
+ pg_cursor.execute(SQL)
+ result = pg_cursor.fetchone()
+ if result is None:
+ connection.close()
+ message = "Make sure pgTimetable is installed properly."
+ return False, message
+
+ SQL = """
+ SELECT EXISTS(
+ SELECT 1 FROM information_schema.columns
+ WHERE
+ table_schema='timetable' AND table_name='task' AND
+ column_name='database_connection'
+ ) has_connstr
+ """
+ pg_cursor.execute(SQL)
+ result = pg_cursor.fetchone()
+ if result is None:
+ connection.close()
+ message = "Make sure pgTimetable is installed properly."
+ return False, message
+
+ connection.close()
+ return True, None
+ except Exception:
+ traceback.print_exc(file=sys.stderr)
+ return False, "Error checking pgTimetable installation."
+
+
+def create_pgtimetable_chain(self, name):
+ connection = None
+ try:
+ connection = utils.get_db_connection(
+ self.server['db'],
+ self.server['username'],
+ self.server['db_password'],
+ self.server['host'],
+ self.server['port'],
+ self.server['sslmode']
+ )
+ old_isolation_level = connection.isolation_level
+ utils.set_isolation_level(connection, 0)
+ pg_cursor = connection.cursor()
+ pg_cursor.execute(
+ """
+ INSERT INTO timetable.chain(
+ chain_name, live
+ ) VALUES (
+ '{0}'::text, true
+ ) RETURNING chain_id;
+ """.format(name)
+ )
+ chain_id = pg_cursor.fetchone()
+ utils.set_isolation_level(connection, old_isolation_level)
+ connection.commit()
+ return chain_id[0]
+ except Exception:
+ traceback.print_exc(file=sys.stderr)
+ finally:
+ if connection:
+ connection.close()
+
+
+def delete_pgtimetable_chain(self, chain_id=None):
+ if chain_id is None:
+ chain_id = self.chain_id
+ connection = None
+ try:
+ connection = utils.get_db_connection(
+ self.server['db'],
+ self.server['username'],
+ self.server['db_password'],
+ self.server['host'],
+ self.server['port'],
+ self.server['sslmode']
+ )
+ old_isolation_level = connection.isolation_level
+ utils.set_isolation_level(connection, 0)
+ pg_cursor = connection.cursor()
+ pg_cursor.execute(
+ "DELETE FROM timetable.task "
+ "WHERE chain_id = '%s'::integer;" % chain_id
+ )
+ pg_cursor.execute(
+ "DELETE FROM timetable.chain "
+ "WHERE chain_id = '%s'::integer;" % chain_id
+ )
+ utils.set_isolation_level(connection, old_isolation_level)
+ connection.commit()
+ except Exception:
+ traceback.print_exc(file=sys.stderr)
+ finally:
+ if connection:
+ connection.close()
+
+
+def verify_pgtimetable_chain(self):
+ connection = None
+ try:
+ connection = utils.get_db_connection(
+ self.server['db'],
+ self.server['username'],
+ self.server['db_password'],
+ self.server['host'],
+ self.server['port'],
+ self.server['sslmode']
+ )
+ pg_cursor = connection.cursor()
+ pg_cursor.execute(
+ "SELECT COUNT(*) FROM timetable.chain "
+ "WHERE chain_id = '%s'::integer;" % self.chain_id
+ )
+ result = pg_cursor.fetchone()
+ count = result[0]
+ return count is not None and int(count) != 0
+ except Exception:
+ traceback.print_exc(file=sys.stderr)
+ finally:
+ if connection:
+ connection.close()
+
+
+def create_pgtimetable_task(self, task_name, chain_id):
+ connection = None
+ try:
+ connection = utils.get_db_connection(
+ self.server['db'],
+ self.server['username'],
+ self.server['db_password'],
+ self.server['host'],
+ self.server['port'],
+ self.server['sslmode']
+ )
+ old_isolation_level = connection.isolation_level
+ utils.set_isolation_level(connection, 0)
+ pg_cursor = connection.cursor()
+ query = """
+ INSERT INTO timetable.task(
+ chain_id, task_name, task_order, command, kind
+ ) VALUES (
+ {0}::integer, '{1}'::text, 10, 'SELECT 1', 'SQL'
+ ) RETURNING task_id;
+ """.format(chain_id, task_name)
+ pg_cursor.execute(query)
+ task_id = pg_cursor.fetchone()
+ utils.set_isolation_level(connection, old_isolation_level)
+ connection.commit()
+ return task_id[0]
+ except Exception:
+ traceback.print_exc(file=sys.stderr)
+ finally:
+ if connection:
+ connection.close()
+
+
+def delete_pgtimetable_task(self, task_id=None):
+ if task_id is None:
+ task_id = self.task_id
+ connection = None
+ try:
+ connection = utils.get_db_connection(
+ self.server['db'],
+ self.server['username'],
+ self.server['db_password'],
+ self.server['host'],
+ self.server['port'],
+ self.server['sslmode']
+ )
+ old_isolation_level = connection.isolation_level
+ utils.set_isolation_level(connection, 0)
+ pg_cursor = connection.cursor()
+ pg_cursor.execute(
+ "DELETE FROM timetable.parameter "
+ "WHERE task_id = '%s'::integer;" % task_id
+ )
+ pg_cursor.execute(
+ "DELETE FROM timetable.task "
+ "WHERE task_id = '%s'::integer;" % task_id
+ )
+ utils.set_isolation_level(connection, old_isolation_level)
+ connection.commit()
+ except Exception:
+ traceback.print_exc(file=sys.stderr)
+ finally:
+ if connection:
+ connection.close()
+
+
+def verify_pgtimetable_task(self):
+ connection = None
+ try:
+ connection = utils.get_db_connection(
+ self.server['db'],
+ self.server['username'],
+ self.server['db_password'],
+ self.server['host'],
+ self.server['port'],
+ self.server['sslmode']
+ )
+ pg_cursor = connection.cursor()
+ pg_cursor.execute(
+ "SELECT COUNT(*) FROM timetable.task "
+ "WHERE task_id = '%s'::integer;" % self.task_id
+ )
+ result = pg_cursor.fetchone()
+ count = result[0]
+ return count is not None and int(count) != 0
+ except Exception:
+ traceback.print_exc(file=sys.stderr)
+ finally:
+ if connection:
+ connection.close()
diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/utils.py b/web/pgadmin/browser/server_groups/servers/pg_timetable/utils.py
new file mode 100644
index 00000000000..228d46b3203
--- /dev/null
+++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/utils.py
@@ -0,0 +1,11 @@
+##########################################################################
+#
+# pgAdmin 4 - PostgreSQL Tools
+#
+# Copyright (C) 2013 - 2026, The pgAdmin Development Team
+# This software is released under the PostgreSQL Licence
+#
+##########################################################################
+
+"""pgtimetable helper utilities"""
+from flask import render_template
diff --git a/web/webpack.config.js b/web/webpack.config.js
index 6e5c6877980..510a204b23d 100644
--- a/web/webpack.config.js
+++ b/web/webpack.config.js
@@ -231,6 +231,7 @@ module.exports = [{
'pure|pgadmin.node.replica_node',
'pure|pgadmin.node.pgd_replication_groups',
'pure|pgadmin.node.pgd_replication_servers',
+ 'pure|pgadmin.node.pgt_chain',
],
},
},
@@ -252,6 +253,7 @@ module.exports = [{
'pure|pgadmin.tools.import_export_servers',
'pure|pgadmin.tools.debugger',
'pure|pgadmin.node.pga_job',
+ 'pure|pgadmin.node.pgt_chain',
'pure|pgadmin.tools.schema_diff',
'pure|pgadmin.tools.file_manager',
'pure|pgadmin.tools.search_objects',
diff --git a/web/webpack.shim.js b/web/webpack.shim.js
index c370b31af72..7e3ce7c1cf9 100644
--- a/web/webpack.shim.js
+++ b/web/webpack.shim.js
@@ -118,6 +118,8 @@ let webpackShimConfig = {
'pgadmin.node.pga_job': path.join(__dirname, './pgadmin/browser/server_groups/servers/pgagent/static/js/pga_job'),
'pgadmin.node.pga_jobstep': path.join(__dirname, './pgadmin/browser/server_groups/servers/pgagent/steps/static/js/pga_jobstep'),
'pgadmin.node.pga_schedule': path.join(__dirname, './pgadmin/browser/server_groups/servers/pgagent/schedules/static/js/pga_schedule'),
+ 'pgadmin.node.pgt_chain': path.join(__dirname, './pgadmin/browser/server_groups/servers/pg_timetable/static/js/pgt_chain'),
+ 'pgadmin.node.pgt_chaintask': path.join(__dirname, './pgadmin/browser/server_groups/servers/pg_timetable/tasks/static/js/pgt_chaintask'),
'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'),