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 @@ + + + + + coll-pgt_chain + + + 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 @@ + + + + + pgt_chain-disabled + + + + + + 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 @@ + + + + + pgt_chain + + 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'),