From 4d2ed340cf6ae8b6bed509e2ad0d61f948e48a63 Mon Sep 17 00:00:00 2001 From: Regina Obe Date: Thu, 2 Jul 2026 11:45:29 -0400 Subject: [PATCH 01/14] WIP: pg_timetable patterned after pgAgent --- .../servers/pg_timetable/__init__.py | 55 ++++++ .../servers/pg_timetable/chains/__init__.py | 172 ++++++++++++++++++ .../chains/sql/default/check_extension.sql | 4 + .../chains/sql/default/create.sql | 46 +++++ .../chains/sql/default/delete.sql | 13 ++ .../chains/sql/default/properties.sql | 30 +++ .../chains/sql/default/update.sql | 46 +++++ .../pg_timetable/static/css/pg_timetable.css | 24 +++ .../pg_timetable/static/js/pg_timestable.js | 59 ++++++ .../static/js/pg_timetable_schema.ui.js | 126 +++++++++++++ 10 files changed, 575 insertions(+) create mode 100644 web/pgadmin/browser/server_groups/servers/pg_timetable/__init__.py create mode 100644 web/pgadmin/browser/server_groups/servers/pg_timetable/chains/__init__.py create mode 100644 web/pgadmin/browser/server_groups/servers/pg_timetable/chains/sql/default/check_extension.sql create mode 100644 web/pgadmin/browser/server_groups/servers/pg_timetable/chains/sql/default/create.sql create mode 100644 web/pgadmin/browser/server_groups/servers/pg_timetable/chains/sql/default/delete.sql create mode 100644 web/pgadmin/browser/server_groups/servers/pg_timetable/chains/sql/default/properties.sql create mode 100644 web/pgadmin/browser/server_groups/servers/pg_timetable/chains/sql/default/update.sql create mode 100644 web/pgadmin/browser/server_groups/servers/pg_timetable/static/css/pg_timetable.css create mode 100644 web/pgadmin/browser/server_groups/servers/pg_timetable/static/js/pg_timestable.js create mode 100644 web/pgadmin/browser/server_groups/servers/pg_timetable/static/js/pg_timetable_schema.ui.js 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..8014c95a195 --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/__init__.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 +# +########################################################################## + +"""Implements the pg_timetable Jobs Node""" + +from flask_babelex import gettext +from pgadmin.browser.utils import PgAdminModule +from pgadmin.utils.ajax import make_json_response, internal_server_error + + +class PGTimetableModule(PgAdminModule): + def __init__(self, *args, **kwargs): + super(PGTimetableModule, self).__init__(*args, **kwargs) + self.submodules = [] + + def get_own_all_modules(self): + """Dynamically loads and initializes submodules like chains.""" + if not self.submodules: + from .chains import blueprint as chains_blueprint + self.submodules.append(chains_blueprint) + return self.submodules + + @property + def node_type(self): + return 'pg_timetable' + + @property + def label(self): + return gettext('pg_timetable') + + def get_own_stylesheets(self): + """Registers the custom CSS node icons.""" + return [ + 'pg_timetable/css/pg_timetable.css' + ] + + def get_own_javascripts(self): + """ + Registers the front-end JS files. This tells pgAdmin's script loader + to pull in your node definition file automatically on application load. + """ + return [{ + 'name': 'pg_timetable', + 'path': 'pg_timetable/js/pg_timetable', + 'when': None # Loads unconditionally during application boot + }] + +# Initialize the main extension blueprint +blueprint = PGTimetableModule('pg_timetable', __name__, static_folder='static') diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/chains/__init__.py b/web/pgadmin/browser/server_groups/servers/pg_timetable/chains/__init__.py new file mode 100644 index 00000000000..8acdceec91f --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/chains/__init__.py @@ -0,0 +1,172 @@ + +from pgadmin.browser.plugins import BrowserPluginModule +from pgadmin.browser.utils import NodeView +from pgadmin.utils.ajax import make_json_response, internal_server_error +from pgadmin.utils.driver import get_driver +from pgadmin.utils.constants import MANAGED_PROVIDER +from flask import request + +# 1. The Browser Module (registers the node infrastructure in pgAdmin) +class PGTimetableChainModule(BrowserPluginModule): + def __init__(self, *args, **kwargs): + super(PGTimetableChainModule, self).__init__(*args, **kwargs) + self.node_type = 'pg_timetable_chain' + self.parent_type = 'server' + + def get_nodes(self, gid, sid): + """Generates visual child tree items under the active Server connection.""" + pass + + +# Initialize the module instance for pgAdmin's plugin loader +blueprint = PGTimetableChainModule('pg_timetable_chain', __name__, static_folder='static') + + +# 2. The Node View (maps REST API endpoints to this specific node type) +class PGTimetableChainView(NodeView): + node_type = 'pg_timetable_chain' + parent_ids = [{'type': 'server', 'id': 'sid'}] + ids = [{'type': 'pg_timetable_chain', 'id': 'chain_id'}] + + def _get_connection(self, sid): + """Helper to get a connection pointing to the maintenance DB.""" + driver = get_driver(MANAGED_PROVIDER) + manager = driver.connection_manager(sid) + conn = manager.connection(manager.db) + return driver, conn + + def list(self, gid, sid): + """Triggered automatically when clicking the pg_timetable folder node.""" + try: + driver, conn = self._get_connection(sid) + + # Check if pg_timetable schema is present + sql_path = 'pg_timetable/chains/sql/default/check_extension.sql' + status, res = conn.execute_scalar(self.render_template_file(sql_path)) + + if not res: + return make_json_response(data=[]) # Hide gracefully if not installed + + # Fetch active cron chains + sql = "SELECT chain_id as id, name FROM timetable.chain;" + status, result = conn.execute_dict(sql) + + nodes = [] + for row in result['rows']: + nodes.append(self.blueprint.generate_browser_node( + id=row['id'], + label=row['name'], + icon='icon-pg_timetable_chain' + )) + return make_json_response(data=nodes) + except Exception as e: + return internal_server_error(errormsg=str(e)) + + def create(self, gid, sid): + """Invoked when a POST request hits the node endpoint with form data.""" + try: + # Read JSON payload sent by the React frontend + data = request.get_json() + driver, conn = self._get_connection(sid) + + # Render and execute the template file + sql = self.render_template_file( + 'pg_timetable/chains/sql/default/create.sql', + data=data + ) + status, result = conn.execute_dict(sql) + + # Fetch the ID of the freshly generated object to return to the UI tree + id_sql = f"SELECT chain_id FROM timetable.chain WHERE name = {driver.qtLiteral(data['name'])} ORDER BY chain_id DESC LIMIT 1;" + status, id_res = conn.execute_dict(id_sql) + new_id = id_res['rows'][0]['chain_id'] + + return make_json_response( + data=self.blueprint.generate_browser_node( + id=new_id, + label=data['name'], + icon='icon-pg_timetable_chain' + ) + ) + except Exception as e: + # Make sure to rollback if things fail mid-transaction + return internal_server_error(errormsg=str(e)) + + def update(self, gid, sid, chain_id): + """Invoked automatically when a PUT request hits the matching node instance.""" + try: + # Parse form edits sent down from the client model layer + data = request.get_json() + driver, conn = self._get_connection(sid) + + # Compile and push structural edits through the database driver connection + sql = self.render_template_file( + 'pg_timetable/chains/sql/default/update.sql', + data=data, + chain_id=chain_id + ) + status, result = conn.execute_dict(sql) + + # Fetch the updated name to verify if the sidebar tree label needs a rename event + current_name = data.get('name') + if not current_name: + name_sql = f"SELECT name FROM timetable.chain WHERE chain_id = {int(chain_id)};" + status, name_res = conn.execute_dict(name_sql) + current_name = name_res['rows'][0]['name'] + + return make_json_response( + data=self.blueprint.generate_browser_node( + id=chain_id, + label=current_name, + icon='icon-pg_timetable_chain' + ) + ) + except Exception as e: + # Prevent data contamination if validation fails midway through operations + return internal_server_error(errormsg=str(e)) + + def properties(self, gid, sid, chain_id): + """Invoked when pgAdmin requests data to fill the properties dashboard panel.""" + try: + driver, conn = self._get_connection(sid) + + # Render script using the specific clicked chain_id parameter + sql = self.render_template_file( + 'pg_timetable/chains/sql/default/properties.sql', + chain_id=chain_id + ) + status, result = conn.execute_dict(sql) + + if not result['rows']: + return internal_server_error(errormsg="Requested pg_timetable chain not found.") + + # Grab the row dictionary mapping directly to your React schema structure + data = result['rows'][0] + + return make_json_response(data=data) + except Exception as e: + return internal_server_error(errormsg=str(e)) + + def delete(self, gid, sid, chain_id): + """Invoked automatically when a user hits Delete/Drop on a tree node selection.""" + try: + driver, conn = self._get_connection(sid) + + # Compile parameters and push structural deletions to the cluster + sql = self.render_template_file( + 'pg_timetable/chains/sql/default/delete.sql', + chain_id=chain_id + ) + status, result = conn.execute_dict(sql) + + # Return a success flag response to notify the frontend to remove the node + return make_json_response( + success=True, + info="pg_timetable Chain successfully dropped." + ) + except Exception as e: + # Safe rollback operation triggers implicitly on driver connection breakdown + return internal_server_error(errormsg=str(e)) + +# 3. Connect the View routing back to the Module Blueprint +PGTimetableChainView.register_node_view(blueprint) diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/chains/sql/default/check_extension.sql b/web/pgadmin/browser/server_groups/servers/pg_timetable/chains/sql/default/check_extension.sql new file mode 100644 index 00000000000..5ced2b0609e --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/chains/sql/default/check_extension.sql @@ -0,0 +1,4 @@ +{# Check if pg_timetable schema exists in the current connected database #} +SELECT EXISTS ( + SELECT 1 FROM pg_namespace WHERE nspname = 'timetable' +) AS has_timetable; diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/chains/sql/default/create.sql b/web/pgadmin/browser/server_groups/servers/pg_timetable/chains/sql/default/create.sql new file mode 100644 index 00000000000..ab4c07f3846 --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/chains/sql/default/create.sql @@ -0,0 +1,46 @@ +{# Create a transaction to ensure both the chain and tasks are added safely #} +BEGIN; + +{# 1. Register the base chain/job with the timetable system #} +SELECT timetable.add_job( + job_name => {{ data.name|qtLiteral }}, + job_schedule => {{ data.run_at|qtLiteral }}, + job_command => NULL, {# We leave this NULL because we add separate step tasks below #} + job_kind => 'SQL'::timetable.task_kind, + job_client_name => NULL, + job_max_instances => {{ data.max_instances|default(1, true)|int }}, + job_live => {{ data.live|lower }} +); + +{# + 2. Target the newly created job id to assign custom child tasks. + pg_timetable automatically names the base background chain after the job_name. +#} +DO $$ +DECLARE + v_chain_id bigint; +BEGIN + SELECT chain_id INTO v_chain_id + FROM timetable.chain + WHERE name = {{ data.name|qtLiteral }} + ORDER BY chain_id DESC LIMIT 1; + + {# Loop through the collection of tasks passed from our React multi-row grid #} + {% if data.tasks and data.tasks|length > 0 %} + {% for task in data.tasks %} + INSERT INTO timetable.task ( + chain_id, + task_order, + task_kind, + command + ) VALUES ( + v_chain_id, + {{ loop.index }}, + {{ task.task_kind|qtLiteral }}::timetable.task_kind, + {{ task.command|qtLiteral }} + ); + {% endfor %} + {% endif %} +END $$; + +COMMIT; diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/chains/sql/default/delete.sql b/web/pgadmin/browser/server_groups/servers/pg_timetable/chains/sql/default/delete.sql new file mode 100644 index 00000000000..91c4121f449 --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/chains/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/chains/sql/default/properties.sql b/web/pgadmin/browser/server_groups/servers/pg_timetable/chains/sql/default/properties.sql new file mode 100644 index 00000000000..18f3dd0f6ee --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/chains/sql/default/properties.sql @@ -0,0 +1,30 @@ +SELECT + c.chain_id AS id, + c.name, + c.run_at, + c.max_instances, + c.live, + {# Generate a clean JSON array of tasks matching the schema expectations #} + coalesce( + ( + SELECT json_agg( + json_build_object( + 'task_order', t.task_order, + 'task_kind', t.task_kind, + 'command', t.command + ) ORDER BY t.task_order ASC + ) + FROM timetable.task t + WHERE t.chain_id = c.chain_id + ), + '[]'::json + ) AS tasks +FROM + timetable.chain c +WHERE + {# If chain_id is omitted, this query returns all rows for the list view #} + {% if chain_id %} + c.chain_id = {{ chain_id|int }} + {% else %} + 1 = 1 + {% endif %}; diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/chains/sql/default/update.sql b/web/pgadmin/browser/server_groups/servers/pg_timetable/chains/sql/default/update.sql new file mode 100644 index 00000000000..10fe11837c3 --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/chains/sql/default/update.sql @@ -0,0 +1,46 @@ +{# Execute edits in an isolated block to prevent broken partial task steps #} +BEGIN; + +{# 1. Apply core parameter changes to the master chain row if any modifications occurred #} +{% if data.name or data.run_at or data.max_instances is defined or data.live is defined %} +UPDATE timetable.chain +SET + {% if data.name %} + name = {{ data.name|qtLiteral }}, + {% endif %} + {% if data.run_at %} + run_at = {{ data.run_at|qtLiteral }}, + {% endif %} + {% if data.max_instances is defined %} + max_instances = {{ data.max_instances|int }}, + {% endif %} + {% if data.live is defined %} + live = {{ data.live|lower }}, + {% endif %} + {# Remove trailing comma safely using standard Postgres syntax dummy assignment #} + chain_id = chain_id +WHERE chain_id = {{ chain_id|int }}; +{% endif %} + +{# 2. Re-align steps layout if the tasks grid configuration was edited #} +{% if data.tasks and data.tasks|length > 0 %} + {# Wipe pre-existing tasks assigned to this target id #} + DELETE FROM timetable.task WHERE chain_id = {{ chain_id|int }}; + + {# Insert the updated sequence list derived from the visual collection grid #} + {% for task in data.tasks %} + INSERT INTO timetable.task ( + chain_id, + task_order, + task_kind, + command + ) VALUES ( + {{ chain_id|int }}, + {{ loop.index }}, + {{ task.task_kind|qtLiteral }}::timetable.task_kind, + {{ task.command|qtLiteral }} + ); + {% endfor %} +{% endif %} + +COMMIT; diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/static/css/pg_timetable.css b/web/pgadmin/browser/server_groups/servers/pg_timetable/static/css/pg_timetable.css new file mode 100644 index 00000000000..153abd7acd6 --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/static/css/pg_timetable.css @@ -0,0 +1,24 @@ +/* Node icon displayed directly inside the left-hand navigation explorer tree */ +.icon-pg_timetable_chain { + background-image: none !important; +} + +.icon-pg_timetable_chain:before { + font-family: 'Font Awesome 5 Free', 'FontAwesome'; + font-weight: 900; + content: "\f017"; /* Unicode character string representing a clock/stopwatch icon */ + color: #2b6cb0; /* Theme-compliant dark blue tint matching server elements */ + font-size: 14px; + display: inline-block; +} + +/* Modifier state icon adjustment applied when the targeted chain is disabled or offline */ +.icon-pg_timetable_chain-disabled:before { + color: #a0aec0; /* Muted gray tint indicating an inactive cron state */ + content: "\f017"; +} + +/* Context menu header overlay utility styling */ +.ajs-content .icon-pg_timetable_chain:before { + padding-right: 8px; +} diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/static/js/pg_timestable.js b/web/pgadmin/browser/server_groups/servers/pg_timetable/static/js/pg_timestable.js new file mode 100644 index 00000000000..b93217998de --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/static/js/pg_timestable.js @@ -0,0 +1,59 @@ +define([ + 'sources/pgadmin', + 'sources/gettext', + 'sources/browser/node', + './pg_timetable_schema.ui' // Imports the React UI Schema file we defined earlier +], function(pgAdmin, gettext, Node, PGTimetableChainSchema) { + + // 1. Prevent duplicate registrations if pgAdmin reloads the scripts + if (pgAdmin.Browser.Nodes['pg_timetable_chain']) { + return pgAdmin.Browser.Nodes['pg_timetable_chain']; + } + + // 2. Define the main browser node mirroring native pgAgent structures + var pgTimetableChainNode = pgAdmin.Browser.Nodes['pg_timetable_chain'] = Node.extend({ + type: 'pg_timetable_chain', + label: gettext('pg_timetable Chain'), + hasId: true, + parent_type: 'server', + canDrop: true, // Enables the "Delete/Drop" menu item + + // Configures context and upper tools menu layouts + menus: [{ + name: 'create_pg_timetable_chain', + module: this, + applies: ['tools', 'context'], + callback: 'show_obj_properties', + category: 'create', + priority: 4, + label: gettext('pg_timetable Chain...'), + icon: 'fa fa-plus', + enable: 'canCreate' + }], + + // 3. Dynamic icon evaluator tied directly to our custom CSS classes + icon: function(node_data) { + if (node_data && node_data.live === false) { + return 'icon-pg_timetable_chain-disabled'; + } + return 'icon-pg_timetable_chain'; + }, + + // 4. Verification checking if a server connection is open before enabling menus + canCreate: function(node) { + return node && node.connected; + }, + + // 5. Connects the UI to our React schema for building Create/Edit dialog forms + getSchema: function(treeInfo, itemData) { + return new PGTimetableChainSchema.default({ + role: treeInfo.server.user, + server_info: treeInfo.server + }); + } + }); + + return pgTimetableChainNode; +}); +// Inside your web/pgadmin/browser/server_groups/servers/pg_timetable/static/js/pg_timetable.js node extension + diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/static/js/pg_timetable_schema.ui.js b/web/pgadmin/browser/server_groups/servers/pg_timetable/static/js/pg_timetable_schema.ui.js new file mode 100644 index 00000000000..38e6a9d9c09 --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/static/js/pg_timetable_schema.ui.js @@ -0,0 +1,126 @@ +import BaseUISchema from 'sources/utils/BaseUISchema'; +import gettext from 'sources/gettext'; + +// 1. Define the child schema for individual tasks inside the chain array +class PGTimetableTaskSchema extends BaseUISchema { + constructor() { + super({ + task_kind: 'SQL', + command: '' + }); + } + + get baseFields() { + return [ + { + id: 'task_kind', + label: gettext('Kind'), + type: 'select', + control: 'select', + options: [ + { label: 'SQL Script', value: 'SQL' }, + { label: 'Shell / OS Command', value: 'PROGRAM' }, + { label: 'Built-In Boot Task', value: 'BUILTIN' } + ], + editable: true, + width: 150 + }, + { + id: 'command', + label: gettext('Script / Command Executable'), + type: 'textarea', + editable: true, + cell: 'string' + } + ]; + } +} + +// 2. Define the main schema for the master chain configuration +export default class PGTimetableChainSchema extends BaseUISchema { + constructor(fieldOptions = {}) { + super({ + id: undefined, + name: '', + run_at: '', + max_instances: 1, + live: true, + tasks: [] + }); + this.fieldOptions = fieldOptions; + } + + get baseFields() { + return [ + { + id: 'name', + label: gettext('Name'), + type: 'text', + mode: ['properties', 'create', 'edit'], + group: gettext('General'), + required: true + }, + { + id: 'live', + label: gettext('Enabled?'), + type: 'switch', + mode: ['properties', 'create', 'edit'], + group: gettext('General') + }, + { + id: 'max_instances', + label: gettext('Max Instances'), + type: 'int', + mode: ['properties', 'create', 'edit'], + group: gettext('General'), + min: 1 + }, + { + id: 'run_at', + label: gettext('Cron Expression'), + type: 'text', + mode: ['properties', 'create', 'edit'], + group: gettext('Schedules'), + placeholder: '* * * * *', + required: true + }, + { + id: 'tasks', + label: gettext('Tasks / Steps Sequence'), + type: 'collection', + mode: ['create', 'edit'], + group: gettext('Steps'), + schema: new PGTimetableTaskSchema(), // Nesting our row-level task schema here + canAdd: true, + canDelete: true, + canEdit: true, + uniqueCol : ['task_order'] + } + ]; + } + + // Frontend validation logic before sending data to create.sql / update.sql + validate(data, modifiedFields) { + let msg = null; + + if (!data.name || data.name.trim() === '') { + msg = gettext('Chain name cannot be empty.'); + this.setError('name', msg); + return true; + } + + if (!data.run_at || data.run_at.trim() === '') { + msg = gettext('Cron expression schedule is required.'); + this.setError('run_at', msg); + return true; + } + + if (!data.tasks || data.tasks.length === 0) { + msg = gettext('A chain must contain at least one step task.'); + this.setError('tasks', msg); + return true; + } + + return false; // Returns false if the form passes validation safely + } +} From 926e98a551251c51a5c16836c2dd5d843b9bb0f0 Mon Sep 17 00:00:00 2001 From: Regina Obe Date: Tue, 7 Jul 2026 15:36:53 -0400 Subject: [PATCH 02/14] pg_timetable plugin for pgAdmin patterned after pgAgent jobs WIP for #10148 - PGTimetable Chains node underneath Servers if pg_timetable is installed in maintenance database - Tasks node under Chain - Ability to add/edit a Chain - Ability to add/edit a Task under a Chain - Generate create sql script for a Chain - Ability to trigger Run Now for a change - Basic text editor for Task parameters and ability to add/edit - Icons for PGTime table to represent chains and tasks - Kind drop down list containing SQL, PROGRAM, BUILTIN - Program, drop down list if kind is "BUILTIN" - Auto increment order id of both task and parameters. Increment by 1 for parameters and 10 for tasks - Stats tab for both chain and chain task - Make database connection not required for SQL and note it defaults to pgTimeTable database - last_run, next_run, currently_running_on to the chain readonly properties screen - Regression tests for pg_timetable - Add cron help preserve layout with html pre so easy to read --- .../browser/server_groups/servers/__init__.py | 4 + .../servers/pg_timetable/__init__.py | 637 +++++++++++++++++- .../servers/pg_timetable/chains/__init__.py | 172 ----- .../chains/sql/default/check_extension.sql | 4 - .../chains/sql/default/create.sql | 46 -- .../chains/sql/default/properties.sql | 30 - .../chains/sql/default/update.sql | 46 -- .../pg_timetable/static/css/pg_timetable.css | 24 - .../pg_timetable/static/css/pgt_chain.css | 3 + .../static/img/coll-pgt_chain.svg | 11 + .../static/img/pgt_chain-disabled.svg | 14 + .../pg_timetable/static/img/pgt_chain.svg | 10 + .../pg_timetable/static/js/pg_timestable.js | 59 -- .../static/js/pg_timetable_schema.ui.js | 126 ---- .../pg_timetable/static/js/pgt_chain.js | 126 ++++ .../pg_timetable/static/js/pgt_chain.ui.js | 120 ++++ .../servers/pg_timetable/tasks/__init__.py | 613 +++++++++++++++++ .../tasks/static/img/coll-pgt_chaintask.svg | 45 ++ .../static/img/pgt_chaintask-disabled.svg | 32 + .../tasks/static/img/pgt_chaintask.svg | 28 + .../tasks/static/js/pgt_chaintask.js | 80 +++ .../tasks/static/js/pgt_chaintask.ui.js | 249 +++++++ .../pgt_chaintask/css/pgt_chaintask.css | 26 + .../pg_timetable/tasks/tests/__init__.py | 0 .../tasks/tests/tasks_test_data.json | 313 +++++++++ .../tasks/tests/test_pgt_task_add.py | 66 ++ .../tasks/tests/test_pgt_task_delete.py | 63 ++ .../tasks/tests/test_pgt_task_get.py | 68 ++ .../tasks/tests/test_pgt_task_get_msql.py | 51 ++ .../tasks/tests/test_pgt_task_get_nodes.py | 68 ++ .../tests/test_pgt_task_get_statistics.py | 61 ++ .../tasks/tests/test_pgt_task_put.py | 63 ++ .../servers/pg_timetable/tasks/tests/utils.py | 85 +++ .../templates/macros/pgt_chaintask.macros | 72 ++ .../templates/pgt_chain/css/pgt_chain.css | 26 + .../pgt_chain/sql/default/create.sql | 43 ++ .../pgt_chain}/sql/default/delete.sql | 0 .../templates/pgt_chain/sql/default/nodes.sql | 8 + .../pgt_chain/sql/default/properties.sql | 29 + .../pgt_chain/sql/default/run_now.sql | 8 + .../templates/pgt_chain/sql/default/stats.sql | 18 + .../templates/pgt_chain/sql/default/tasks.sql | 2 + .../pgt_chain/sql/default/update.sql | 13 + .../pgt_chaintask/css/pgt_chaintask.css | 26 + .../pgt_chaintask/sql/default/create.sql | 21 + .../pgt_chaintask/sql/default/delete.sql | 2 + .../pgt_chaintask/sql/default/nodes.sql | 11 + .../pgt_chaintask/sql/default/properties.sql | 2 + .../pgt_chaintask/sql/default/stats.sql | 19 + .../pgt_chaintask/sql/default/update.sql | 2 + .../servers/pg_timetable/tests/__init__.py | 15 + .../tests/pgt_timetable_test_data.json | 447 ++++++++++++ .../pg_timetable/tests/test_pgt_chain_add.py | 68 ++ .../tests/test_pgt_chain_delete.py | 57 ++ .../pg_timetable/tests/test_pgt_chain_get.py | 69 ++ .../tests/test_pgt_chain_get_msql.py | 48 ++ .../tests/test_pgt_chain_get_nodes.py | 68 ++ .../tests/test_pgt_chain_get_statistics.py | 55 ++ .../pg_timetable/tests/test_pgt_chain_put.py | 55 ++ .../pg_timetable/tests/test_pgt_chain_sql.py | 59 ++ .../servers/pg_timetable/tests/utils.py | 320 +++++++++ .../servers/pg_timetable/utils.py | 11 + web/webpack.config.js | 2 + web/webpack.shim.js | 2 + 64 files changed, 4382 insertions(+), 539 deletions(-) delete mode 100644 web/pgadmin/browser/server_groups/servers/pg_timetable/chains/__init__.py delete mode 100644 web/pgadmin/browser/server_groups/servers/pg_timetable/chains/sql/default/check_extension.sql delete mode 100644 web/pgadmin/browser/server_groups/servers/pg_timetable/chains/sql/default/create.sql delete mode 100644 web/pgadmin/browser/server_groups/servers/pg_timetable/chains/sql/default/properties.sql delete mode 100644 web/pgadmin/browser/server_groups/servers/pg_timetable/chains/sql/default/update.sql delete mode 100644 web/pgadmin/browser/server_groups/servers/pg_timetable/static/css/pg_timetable.css create mode 100644 web/pgadmin/browser/server_groups/servers/pg_timetable/static/css/pgt_chain.css create mode 100644 web/pgadmin/browser/server_groups/servers/pg_timetable/static/img/coll-pgt_chain.svg create mode 100644 web/pgadmin/browser/server_groups/servers/pg_timetable/static/img/pgt_chain-disabled.svg create mode 100644 web/pgadmin/browser/server_groups/servers/pg_timetable/static/img/pgt_chain.svg delete mode 100644 web/pgadmin/browser/server_groups/servers/pg_timetable/static/js/pg_timestable.js delete mode 100644 web/pgadmin/browser/server_groups/servers/pg_timetable/static/js/pg_timetable_schema.ui.js create mode 100644 web/pgadmin/browser/server_groups/servers/pg_timetable/static/js/pgt_chain.js create mode 100644 web/pgadmin/browser/server_groups/servers/pg_timetable/static/js/pgt_chain.ui.js create mode 100644 web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/__init__.py create mode 100644 web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/static/img/coll-pgt_chaintask.svg create mode 100644 web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/static/img/pgt_chaintask-disabled.svg create mode 100644 web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/static/img/pgt_chaintask.svg create mode 100644 web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/static/js/pgt_chaintask.js create mode 100644 web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/static/js/pgt_chaintask.ui.js create mode 100644 web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/template/pgt_chaintask/css/pgt_chaintask.css create mode 100644 web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/tests/__init__.py create mode 100644 web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/tests/tasks_test_data.json create mode 100644 web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/tests/test_pgt_task_add.py create mode 100644 web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/tests/test_pgt_task_delete.py create mode 100644 web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/tests/test_pgt_task_get.py create mode 100644 web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/tests/test_pgt_task_get_msql.py create mode 100644 web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/tests/test_pgt_task_get_nodes.py create mode 100644 web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/tests/test_pgt_task_get_statistics.py create mode 100644 web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/tests/test_pgt_task_put.py create mode 100644 web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/tests/utils.py create mode 100644 web/pgadmin/browser/server_groups/servers/pg_timetable/templates/macros/pgt_chaintask.macros create mode 100644 web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chain/css/pgt_chain.css create mode 100644 web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chain/sql/default/create.sql rename web/pgadmin/browser/server_groups/servers/pg_timetable/{chains => templates/pgt_chain}/sql/default/delete.sql (100%) create mode 100644 web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chain/sql/default/nodes.sql create mode 100644 web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chain/sql/default/properties.sql create mode 100644 web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chain/sql/default/run_now.sql create mode 100644 web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chain/sql/default/stats.sql create mode 100644 web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chain/sql/default/tasks.sql create mode 100644 web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chain/sql/default/update.sql create mode 100644 web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chaintask/css/pgt_chaintask.css create mode 100644 web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chaintask/sql/default/create.sql create mode 100644 web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chaintask/sql/default/delete.sql create mode 100644 web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chaintask/sql/default/nodes.sql create mode 100644 web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chaintask/sql/default/properties.sql create mode 100644 web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chaintask/sql/default/stats.sql create mode 100644 web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chaintask/sql/default/update.sql create mode 100644 web/pgadmin/browser/server_groups/servers/pg_timetable/tests/__init__.py create mode 100644 web/pgadmin/browser/server_groups/servers/pg_timetable/tests/pgt_timetable_test_data.json create mode 100644 web/pgadmin/browser/server_groups/servers/pg_timetable/tests/test_pgt_chain_add.py create mode 100644 web/pgadmin/browser/server_groups/servers/pg_timetable/tests/test_pgt_chain_delete.py create mode 100644 web/pgadmin/browser/server_groups/servers/pg_timetable/tests/test_pgt_chain_get.py create mode 100644 web/pgadmin/browser/server_groups/servers/pg_timetable/tests/test_pgt_chain_get_msql.py create mode 100644 web/pgadmin/browser/server_groups/servers/pg_timetable/tests/test_pgt_chain_get_nodes.py create mode 100644 web/pgadmin/browser/server_groups/servers/pg_timetable/tests/test_pgt_chain_get_statistics.py create mode 100644 web/pgadmin/browser/server_groups/servers/pg_timetable/tests/test_pgt_chain_put.py create mode 100644 web/pgadmin/browser/server_groups/servers/pg_timetable/tests/test_pgt_chain_sql.py create mode 100644 web/pgadmin/browser/server_groups/servers/pg_timetable/tests/utils.py create mode 100644 web/pgadmin/browser/server_groups/servers/pg_timetable/utils.py 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 index 8014c95a195..2d41a7e4563 100644 --- a/web/pgadmin/browser/server_groups/servers/pg_timetable/__init__.py +++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/__init__.py @@ -7,49 +7,622 @@ # ########################################################################## -"""Implements the pg_timetable Jobs Node""" +"""Implements the pgTimeTable Chains Node""" +from functools import wraps +import json +from datetime import datetime, time -from flask_babelex import gettext -from pgadmin.browser.utils import PgAdminModule -from pgadmin.utils.ajax import make_json_response, internal_server_error +from flask import render_template, request, jsonify +from flask_babel import gettext as _ +from config import PG_DEFAULT_DRIVER -class PGTimetableModule(PgAdminModule): - def __init__(self, *args, **kwargs): - super(PGTimetableModule, self).__init__(*args, **kwargs) - self.submodules = [] +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 - def get_own_all_modules(self): - """Dynamically loads and initializes submodules like chains.""" - if not self.submodules: - from .chains import blueprint as chains_blueprint - self.submodules.append(chains_blueprint) - return self.submodules + +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""") + + manager.db_info['timetable'] = res['rows'][0] + return True + return False @property - def node_type(self): - return 'pg_timetable' + 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 label(self): - return gettext('pg_timetable') + 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: + _, 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""") + + 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 + ) - def get_own_stylesheets(self): - """Registers the custom CSS node icons.""" - return [ - 'pg_timetable/css/pg_timetable.css' + @check_precondition + def create(self, gid, sid): + """Create the pgAgent job.""" + required_args = [ + 'chain_name' ] - def get_own_javascripts(self): + 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 + ) + ) + + self.conn.execute_void('END') + if not status: + return internal_server_error(errormsg=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') + ) + + 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'] + for key, val in chain_fields.items(): + sets.append(f"{key} = %s") + if key in bool_keys: + params.append('t' if val else 'f') + 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: + return internal_server_error(errormsg=res) + + self._process_ctasks(chain_id, data.get('ctasks', {})) + + 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: + return internal_server_error(errormsg=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 + + for task in ctasks.get('deleted', []): + tid = task.get('task_id') if isinstance(task, dict) else task + if tid: + self.conn.execute_void( + "DELETE FROM timetable.task WHERE task_id = %s AND chain_id = %s", + (tid, chain_id) + ) + + 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', + 'database_connection': 'database_connection', + 'ignore_error': 'ignore_error', + } + 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" + self.conn.execute_void(sql, params) + if 'parameters' in task: + self._upsert_task_params(tid, task['parameters']) + + has_connstr = self.manager.db_info['timetable']['has_connstr'] + 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 status and tid: + self._upsert_task_params(tid, task.get('parameters', [])) + + def _upsert_task_params(self, task_id, parameters): + if not parameters: + return + if isinstance(parameters, dict): + parameters = parameters.get('added', []) + parameters.get('changed', []) + if not parameters: + return + self.conn.execute_void( + "DELETE FROM timetable.parameter WHERE task_id = %s", (task_id,) + ) + for idx, param in enumerate(parameters): + if not isinstance(param, dict): + param = {'order_id': idx + 1, 'value': str(param)} + order_id = param.get('order_id') + if order_id is None: + order_id = idx + 1 + 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) + self.conn.execute_void(sql, params) + + @check_precondition + def delete(self, gid, sid, chain_id=None): + """Delete the pgAgent Job.""" + + 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 + + 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): """ - Registers the front-end JS files. This tells pgAdmin's script loader - to pull in your node definition file automatically on application load. + 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. """ - return [{ - 'name': 'pg_timetable', - 'path': 'pg_timetable/js/pg_timetable', - 'when': None # Loads unconditionally during application boot - }] + pref = Preferences.module('browser') + rows_threshold = pref.preference( + 'pgagent_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 the pgAgent to + run the job 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.") + ) -# Initialize the main extension blueprint -blueprint = PGTimetableModule('pg_timetable', __name__, static_folder='static') +ChainView.register_node_view(blueprint) diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/chains/__init__.py b/web/pgadmin/browser/server_groups/servers/pg_timetable/chains/__init__.py deleted file mode 100644 index 8acdceec91f..00000000000 --- a/web/pgadmin/browser/server_groups/servers/pg_timetable/chains/__init__.py +++ /dev/null @@ -1,172 +0,0 @@ - -from pgadmin.browser.plugins import BrowserPluginModule -from pgadmin.browser.utils import NodeView -from pgadmin.utils.ajax import make_json_response, internal_server_error -from pgadmin.utils.driver import get_driver -from pgadmin.utils.constants import MANAGED_PROVIDER -from flask import request - -# 1. The Browser Module (registers the node infrastructure in pgAdmin) -class PGTimetableChainModule(BrowserPluginModule): - def __init__(self, *args, **kwargs): - super(PGTimetableChainModule, self).__init__(*args, **kwargs) - self.node_type = 'pg_timetable_chain' - self.parent_type = 'server' - - def get_nodes(self, gid, sid): - """Generates visual child tree items under the active Server connection.""" - pass - - -# Initialize the module instance for pgAdmin's plugin loader -blueprint = PGTimetableChainModule('pg_timetable_chain', __name__, static_folder='static') - - -# 2. The Node View (maps REST API endpoints to this specific node type) -class PGTimetableChainView(NodeView): - node_type = 'pg_timetable_chain' - parent_ids = [{'type': 'server', 'id': 'sid'}] - ids = [{'type': 'pg_timetable_chain', 'id': 'chain_id'}] - - def _get_connection(self, sid): - """Helper to get a connection pointing to the maintenance DB.""" - driver = get_driver(MANAGED_PROVIDER) - manager = driver.connection_manager(sid) - conn = manager.connection(manager.db) - return driver, conn - - def list(self, gid, sid): - """Triggered automatically when clicking the pg_timetable folder node.""" - try: - driver, conn = self._get_connection(sid) - - # Check if pg_timetable schema is present - sql_path = 'pg_timetable/chains/sql/default/check_extension.sql' - status, res = conn.execute_scalar(self.render_template_file(sql_path)) - - if not res: - return make_json_response(data=[]) # Hide gracefully if not installed - - # Fetch active cron chains - sql = "SELECT chain_id as id, name FROM timetable.chain;" - status, result = conn.execute_dict(sql) - - nodes = [] - for row in result['rows']: - nodes.append(self.blueprint.generate_browser_node( - id=row['id'], - label=row['name'], - icon='icon-pg_timetable_chain' - )) - return make_json_response(data=nodes) - except Exception as e: - return internal_server_error(errormsg=str(e)) - - def create(self, gid, sid): - """Invoked when a POST request hits the node endpoint with form data.""" - try: - # Read JSON payload sent by the React frontend - data = request.get_json() - driver, conn = self._get_connection(sid) - - # Render and execute the template file - sql = self.render_template_file( - 'pg_timetable/chains/sql/default/create.sql', - data=data - ) - status, result = conn.execute_dict(sql) - - # Fetch the ID of the freshly generated object to return to the UI tree - id_sql = f"SELECT chain_id FROM timetable.chain WHERE name = {driver.qtLiteral(data['name'])} ORDER BY chain_id DESC LIMIT 1;" - status, id_res = conn.execute_dict(id_sql) - new_id = id_res['rows'][0]['chain_id'] - - return make_json_response( - data=self.blueprint.generate_browser_node( - id=new_id, - label=data['name'], - icon='icon-pg_timetable_chain' - ) - ) - except Exception as e: - # Make sure to rollback if things fail mid-transaction - return internal_server_error(errormsg=str(e)) - - def update(self, gid, sid, chain_id): - """Invoked automatically when a PUT request hits the matching node instance.""" - try: - # Parse form edits sent down from the client model layer - data = request.get_json() - driver, conn = self._get_connection(sid) - - # Compile and push structural edits through the database driver connection - sql = self.render_template_file( - 'pg_timetable/chains/sql/default/update.sql', - data=data, - chain_id=chain_id - ) - status, result = conn.execute_dict(sql) - - # Fetch the updated name to verify if the sidebar tree label needs a rename event - current_name = data.get('name') - if not current_name: - name_sql = f"SELECT name FROM timetable.chain WHERE chain_id = {int(chain_id)};" - status, name_res = conn.execute_dict(name_sql) - current_name = name_res['rows'][0]['name'] - - return make_json_response( - data=self.blueprint.generate_browser_node( - id=chain_id, - label=current_name, - icon='icon-pg_timetable_chain' - ) - ) - except Exception as e: - # Prevent data contamination if validation fails midway through operations - return internal_server_error(errormsg=str(e)) - - def properties(self, gid, sid, chain_id): - """Invoked when pgAdmin requests data to fill the properties dashboard panel.""" - try: - driver, conn = self._get_connection(sid) - - # Render script using the specific clicked chain_id parameter - sql = self.render_template_file( - 'pg_timetable/chains/sql/default/properties.sql', - chain_id=chain_id - ) - status, result = conn.execute_dict(sql) - - if not result['rows']: - return internal_server_error(errormsg="Requested pg_timetable chain not found.") - - # Grab the row dictionary mapping directly to your React schema structure - data = result['rows'][0] - - return make_json_response(data=data) - except Exception as e: - return internal_server_error(errormsg=str(e)) - - def delete(self, gid, sid, chain_id): - """Invoked automatically when a user hits Delete/Drop on a tree node selection.""" - try: - driver, conn = self._get_connection(sid) - - # Compile parameters and push structural deletions to the cluster - sql = self.render_template_file( - 'pg_timetable/chains/sql/default/delete.sql', - chain_id=chain_id - ) - status, result = conn.execute_dict(sql) - - # Return a success flag response to notify the frontend to remove the node - return make_json_response( - success=True, - info="pg_timetable Chain successfully dropped." - ) - except Exception as e: - # Safe rollback operation triggers implicitly on driver connection breakdown - return internal_server_error(errormsg=str(e)) - -# 3. Connect the View routing back to the Module Blueprint -PGTimetableChainView.register_node_view(blueprint) diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/chains/sql/default/check_extension.sql b/web/pgadmin/browser/server_groups/servers/pg_timetable/chains/sql/default/check_extension.sql deleted file mode 100644 index 5ced2b0609e..00000000000 --- a/web/pgadmin/browser/server_groups/servers/pg_timetable/chains/sql/default/check_extension.sql +++ /dev/null @@ -1,4 +0,0 @@ -{# Check if pg_timetable schema exists in the current connected database #} -SELECT EXISTS ( - SELECT 1 FROM pg_namespace WHERE nspname = 'timetable' -) AS has_timetable; diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/chains/sql/default/create.sql b/web/pgadmin/browser/server_groups/servers/pg_timetable/chains/sql/default/create.sql deleted file mode 100644 index ab4c07f3846..00000000000 --- a/web/pgadmin/browser/server_groups/servers/pg_timetable/chains/sql/default/create.sql +++ /dev/null @@ -1,46 +0,0 @@ -{# Create a transaction to ensure both the chain and tasks are added safely #} -BEGIN; - -{# 1. Register the base chain/job with the timetable system #} -SELECT timetable.add_job( - job_name => {{ data.name|qtLiteral }}, - job_schedule => {{ data.run_at|qtLiteral }}, - job_command => NULL, {# We leave this NULL because we add separate step tasks below #} - job_kind => 'SQL'::timetable.task_kind, - job_client_name => NULL, - job_max_instances => {{ data.max_instances|default(1, true)|int }}, - job_live => {{ data.live|lower }} -); - -{# - 2. Target the newly created job id to assign custom child tasks. - pg_timetable automatically names the base background chain after the job_name. -#} -DO $$ -DECLARE - v_chain_id bigint; -BEGIN - SELECT chain_id INTO v_chain_id - FROM timetable.chain - WHERE name = {{ data.name|qtLiteral }} - ORDER BY chain_id DESC LIMIT 1; - - {# Loop through the collection of tasks passed from our React multi-row grid #} - {% if data.tasks and data.tasks|length > 0 %} - {% for task in data.tasks %} - INSERT INTO timetable.task ( - chain_id, - task_order, - task_kind, - command - ) VALUES ( - v_chain_id, - {{ loop.index }}, - {{ task.task_kind|qtLiteral }}::timetable.task_kind, - {{ task.command|qtLiteral }} - ); - {% endfor %} - {% endif %} -END $$; - -COMMIT; diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/chains/sql/default/properties.sql b/web/pgadmin/browser/server_groups/servers/pg_timetable/chains/sql/default/properties.sql deleted file mode 100644 index 18f3dd0f6ee..00000000000 --- a/web/pgadmin/browser/server_groups/servers/pg_timetable/chains/sql/default/properties.sql +++ /dev/null @@ -1,30 +0,0 @@ -SELECT - c.chain_id AS id, - c.name, - c.run_at, - c.max_instances, - c.live, - {# Generate a clean JSON array of tasks matching the schema expectations #} - coalesce( - ( - SELECT json_agg( - json_build_object( - 'task_order', t.task_order, - 'task_kind', t.task_kind, - 'command', t.command - ) ORDER BY t.task_order ASC - ) - FROM timetable.task t - WHERE t.chain_id = c.chain_id - ), - '[]'::json - ) AS tasks -FROM - timetable.chain c -WHERE - {# If chain_id is omitted, this query returns all rows for the list view #} - {% if chain_id %} - c.chain_id = {{ chain_id|int }} - {% else %} - 1 = 1 - {% endif %}; diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/chains/sql/default/update.sql b/web/pgadmin/browser/server_groups/servers/pg_timetable/chains/sql/default/update.sql deleted file mode 100644 index 10fe11837c3..00000000000 --- a/web/pgadmin/browser/server_groups/servers/pg_timetable/chains/sql/default/update.sql +++ /dev/null @@ -1,46 +0,0 @@ -{# Execute edits in an isolated block to prevent broken partial task steps #} -BEGIN; - -{# 1. Apply core parameter changes to the master chain row if any modifications occurred #} -{% if data.name or data.run_at or data.max_instances is defined or data.live is defined %} -UPDATE timetable.chain -SET - {% if data.name %} - name = {{ data.name|qtLiteral }}, - {% endif %} - {% if data.run_at %} - run_at = {{ data.run_at|qtLiteral }}, - {% endif %} - {% if data.max_instances is defined %} - max_instances = {{ data.max_instances|int }}, - {% endif %} - {% if data.live is defined %} - live = {{ data.live|lower }}, - {% endif %} - {# Remove trailing comma safely using standard Postgres syntax dummy assignment #} - chain_id = chain_id -WHERE chain_id = {{ chain_id|int }}; -{% endif %} - -{# 2. Re-align steps layout if the tasks grid configuration was edited #} -{% if data.tasks and data.tasks|length > 0 %} - {# Wipe pre-existing tasks assigned to this target id #} - DELETE FROM timetable.task WHERE chain_id = {{ chain_id|int }}; - - {# Insert the updated sequence list derived from the visual collection grid #} - {% for task in data.tasks %} - INSERT INTO timetable.task ( - chain_id, - task_order, - task_kind, - command - ) VALUES ( - {{ chain_id|int }}, - {{ loop.index }}, - {{ task.task_kind|qtLiteral }}::timetable.task_kind, - {{ task.command|qtLiteral }} - ); - {% endfor %} -{% endif %} - -COMMIT; diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/static/css/pg_timetable.css b/web/pgadmin/browser/server_groups/servers/pg_timetable/static/css/pg_timetable.css deleted file mode 100644 index 153abd7acd6..00000000000 --- a/web/pgadmin/browser/server_groups/servers/pg_timetable/static/css/pg_timetable.css +++ /dev/null @@ -1,24 +0,0 @@ -/* Node icon displayed directly inside the left-hand navigation explorer tree */ -.icon-pg_timetable_chain { - background-image: none !important; -} - -.icon-pg_timetable_chain:before { - font-family: 'Font Awesome 5 Free', 'FontAwesome'; - font-weight: 900; - content: "\f017"; /* Unicode character string representing a clock/stopwatch icon */ - color: #2b6cb0; /* Theme-compliant dark blue tint matching server elements */ - font-size: 14px; - display: inline-block; -} - -/* Modifier state icon adjustment applied when the targeted chain is disabled or offline */ -.icon-pg_timetable_chain-disabled:before { - color: #a0aec0; /* Muted gray tint indicating an inactive cron state */ - content: "\f017"; -} - -/* Context menu header overlay utility styling */ -.ajs-content .icon-pg_timetable_chain:before { - padding-right: 8px; -} 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/pg_timestable.js b/web/pgadmin/browser/server_groups/servers/pg_timetable/static/js/pg_timestable.js deleted file mode 100644 index b93217998de..00000000000 --- a/web/pgadmin/browser/server_groups/servers/pg_timetable/static/js/pg_timestable.js +++ /dev/null @@ -1,59 +0,0 @@ -define([ - 'sources/pgadmin', - 'sources/gettext', - 'sources/browser/node', - './pg_timetable_schema.ui' // Imports the React UI Schema file we defined earlier -], function(pgAdmin, gettext, Node, PGTimetableChainSchema) { - - // 1. Prevent duplicate registrations if pgAdmin reloads the scripts - if (pgAdmin.Browser.Nodes['pg_timetable_chain']) { - return pgAdmin.Browser.Nodes['pg_timetable_chain']; - } - - // 2. Define the main browser node mirroring native pgAgent structures - var pgTimetableChainNode = pgAdmin.Browser.Nodes['pg_timetable_chain'] = Node.extend({ - type: 'pg_timetable_chain', - label: gettext('pg_timetable Chain'), - hasId: true, - parent_type: 'server', - canDrop: true, // Enables the "Delete/Drop" menu item - - // Configures context and upper tools menu layouts - menus: [{ - name: 'create_pg_timetable_chain', - module: this, - applies: ['tools', 'context'], - callback: 'show_obj_properties', - category: 'create', - priority: 4, - label: gettext('pg_timetable Chain...'), - icon: 'fa fa-plus', - enable: 'canCreate' - }], - - // 3. Dynamic icon evaluator tied directly to our custom CSS classes - icon: function(node_data) { - if (node_data && node_data.live === false) { - return 'icon-pg_timetable_chain-disabled'; - } - return 'icon-pg_timetable_chain'; - }, - - // 4. Verification checking if a server connection is open before enabling menus - canCreate: function(node) { - return node && node.connected; - }, - - // 5. Connects the UI to our React schema for building Create/Edit dialog forms - getSchema: function(treeInfo, itemData) { - return new PGTimetableChainSchema.default({ - role: treeInfo.server.user, - server_info: treeInfo.server - }); - } - }); - - return pgTimetableChainNode; -}); -// Inside your web/pgadmin/browser/server_groups/servers/pg_timetable/static/js/pg_timetable.js node extension - diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/static/js/pg_timetable_schema.ui.js b/web/pgadmin/browser/server_groups/servers/pg_timetable/static/js/pg_timetable_schema.ui.js deleted file mode 100644 index 38e6a9d9c09..00000000000 --- a/web/pgadmin/browser/server_groups/servers/pg_timetable/static/js/pg_timetable_schema.ui.js +++ /dev/null @@ -1,126 +0,0 @@ -import BaseUISchema from 'sources/utils/BaseUISchema'; -import gettext from 'sources/gettext'; - -// 1. Define the child schema for individual tasks inside the chain array -class PGTimetableTaskSchema extends BaseUISchema { - constructor() { - super({ - task_kind: 'SQL', - command: '' - }); - } - - get baseFields() { - return [ - { - id: 'task_kind', - label: gettext('Kind'), - type: 'select', - control: 'select', - options: [ - { label: 'SQL Script', value: 'SQL' }, - { label: 'Shell / OS Command', value: 'PROGRAM' }, - { label: 'Built-In Boot Task', value: 'BUILTIN' } - ], - editable: true, - width: 150 - }, - { - id: 'command', - label: gettext('Script / Command Executable'), - type: 'textarea', - editable: true, - cell: 'string' - } - ]; - } -} - -// 2. Define the main schema for the master chain configuration -export default class PGTimetableChainSchema extends BaseUISchema { - constructor(fieldOptions = {}) { - super({ - id: undefined, - name: '', - run_at: '', - max_instances: 1, - live: true, - tasks: [] - }); - this.fieldOptions = fieldOptions; - } - - get baseFields() { - return [ - { - id: 'name', - label: gettext('Name'), - type: 'text', - mode: ['properties', 'create', 'edit'], - group: gettext('General'), - required: true - }, - { - id: 'live', - label: gettext('Enabled?'), - type: 'switch', - mode: ['properties', 'create', 'edit'], - group: gettext('General') - }, - { - id: 'max_instances', - label: gettext('Max Instances'), - type: 'int', - mode: ['properties', 'create', 'edit'], - group: gettext('General'), - min: 1 - }, - { - id: 'run_at', - label: gettext('Cron Expression'), - type: 'text', - mode: ['properties', 'create', 'edit'], - group: gettext('Schedules'), - placeholder: '* * * * *', - required: true - }, - { - id: 'tasks', - label: gettext('Tasks / Steps Sequence'), - type: 'collection', - mode: ['create', 'edit'], - group: gettext('Steps'), - schema: new PGTimetableTaskSchema(), // Nesting our row-level task schema here - canAdd: true, - canDelete: true, - canEdit: true, - uniqueCol : ['task_order'] - } - ]; - } - - // Frontend validation logic before sending data to create.sql / update.sql - validate(data, modifiedFields) { - let msg = null; - - if (!data.name || data.name.trim() === '') { - msg = gettext('Chain name cannot be empty.'); - this.setError('name', msg); - return true; - } - - if (!data.run_at || data.run_at.trim() === '') { - msg = gettext('Cron expression schedule is required.'); - this.setError('run_at', msg); - return true; - } - - if (!data.tasks || data.tasks.length === 0) { - msg = gettext('A chain must contain at least one step task.'); - this.setError('tasks', msg); - return true; - } - - return false; // Returns false if the form passes validation safely - } -} 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..4f5824540c3 --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/static/js/pgt_chain.ui.js @@ -0,0 +1,120 @@ +///////////////////////////////////////////////////////////// +// +// 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 lastOrder = tasks.reduce((max, t) => Math.max(max, t.task_order || 0), 0); + tasks[tasks.length - 1].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..7d4940c813d --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/__init__.py @@ -0,0 +1,613 @@ +########################################################################## +# +# pgAdmin 4 - PostgreSQL Tools +# +# Copyright (C) 2013 - 2026, The pgAdmin Development Team +# This software is released under the PostgreSQL Licence +# +########################################################################## + +"""Implements pgAgent Job Step 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 job step node, 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 job step nodes within that + collection. + + * nodes() + - This function will used to create all the child node within that + collection. + Here it will create all the job step node. + + * properties(gid, sid, chain_id, task_id) + - This function will show the properties of the selected job step node + + * update(gid, sid, chain_id, task_id) + - This function will update the data for the selected job step node + + * msql(gid, sid, chain_id, task_id) + - This function is used to return modified SQL for the selected + job step node + + * sql(gid, sid, chain_id, jscid) + - Dummy response for sql panel + + * delete(gid, sid, chain_id, jscid) + - Drops job step + """ + + 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: + _, 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""") + + 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 job step nodes within + that collection. + + Args: + gid: Server Group ID + sid: Server ID + chain_id: Job 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 job step nodes. + + Args: + gid: Server Group ID + sid: Server ID + chain_id: Job 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] + return make_json_response( + self.blueprint.generate_browser_node( + row['task_id'], + row['chain_id'], + row['task_name'], + icon="icon-pgt_chaintask", + enabled=True, + kind=row['kind'], + description=row['task_name'] + ) + ) + + for row in result['rows']: + res.append( + self.blueprint.generate_browser_node( + row['task_id'], + row['chain_id'], + row['task_name'], + icon="icon-pgt_chaintask", + enabled=True, + kind=row['kind'], + description=row['task_name'] + ) + ) + + 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 job step node. + + Args: + gid: Server Group ID + sid: Server ID + chain_id: Job 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 update the data for the selected job step node. + + Args: + gid: Server Group ID + sid: Server ID + chain_id: Job ID + """ + data = {} + if request.args: + 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 + else: + data = json.loads(request.data.decode()) + + if 'parameters' in data: + params_raw = data.get('parameters', []) + 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( + "Job step creation failed." + ) + ) + row = res['rows'][0] + return jsonify( + node=self.blueprint.generate_browser_node( + row['task_id'], + row['chain_id'], + row['task_name'], + icon="icon-pgt_chaintask" + ) + ) + + @check_precondition + def update(self, gid, sid, chain_id, task_id): + """ + This function will update the data for the selected job step node. + + Args: + gid: Server Group ID + sid: Server ID + chain_id: Job 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, 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._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( + "Job step update failed." + ) + ) + row = res['rows'][0] + return jsonify( + node=self.blueprint.generate_browser_node( + task_id, + chain_id, + row['task_name'], + icon="icon-pgt_chaintask", + description=row['task_name'] + ) + ) + + @check_precondition + def delete(self, gid, sid, chain_id, task_id=None): + """Delete the Job step.""" + + 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 + job step node. + + Args: + gid: Server Group ID + sid: Server ID + chain_id: Job ID + task_id: Job Step ID + """ + data = {} + sql = '' + for k, v in request.args.items(): + try: + data[k] = json.loads(v) + except ValueError: + data[k] = v + + 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( + 'pgagent_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..27f36514c7f --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/static/js/pgt_chaintask.ui.js @@ -0,0 +1,249 @@ +////////////////////////////////////////////////////////////// +// +// 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 idAttribute() { return 'order_id'; } + get baseFields() { + return [ + { 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 idAttribute() { return 'order_id'; } + get baseFields() { + return [ + { 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, + columns: ['order_id', 'value'], + depChange: (state, source, topState, actionObj) => { + if (actionObj.type === SCHEMA_STATE_ACTIONS.ADD_ROW && state?.parameters) { + const params = state.parameters; + const lastOrder = params.reduce((max, p) => Math.max(max, p.order_id || 0), 0); + params[params.length - 1].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..bcf809a184b --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/tests/tasks_test_data.json @@ -0,0 +1,313 @@ +{ + "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 + } + ], + "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..09178761d74 --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/tests/test_pgt_task_delete.py @@ -0,0 +1,63 @@ +########################################################################## +# +# 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 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): + 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") + + 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..d5c6f96206e --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/tests/test_pgt_task_get_msql.py @@ -0,0 +1,51 @@ +########################################################################## +# +# 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): + 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..e75e291b68e --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/tests/test_pgt_task_put.py @@ -0,0 +1,63 @@ +########################################################################## +# +# 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) + + 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..bd53d50f724 --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/templates/macros/pgt_chaintask.macros @@ -0,0 +1,72 @@ +{% macro INSERT(has_connstr, chain_id, data, conn) -%} +INSERT INTO timetable.task( + chain_id, task_name, task_order, command, kind, database_connection + {% 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 and data.database_connection %}, + {{ data.database_connection|qtLiteral(conn) }}::text{% else %}NULL{% 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 '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 '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; +{% for param in data.parameters %} +INSERT INTO timetable.parameter(task_id, order_id, value) +VALUES ({{ 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 %}); +{% endfor %} +{% 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, + COALESCE( + ( + SELECT jsonb_agg( + jsonb_build_object( + 'order_id', p.order_id, + 'value', p.value #>> '{}' + ) + ORDER BY p.order_id + ) + FROM timetable.parameter p + WHERE p.task_id = t.task_id + ), + '[]'::jsonb + ) 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_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..30998bd85ff --- /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 %}, + {{ data.client_name|qtLiteral(conn) }}::text, + {% if data.live %}true{% else %}false{% endif %}, + {% if data.max_instances is not none %}{{ data.max_instances|qtLiteral(conn) }}::integer{% else %}NULL{% endif %}, + {% if 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/chains/sql/default/delete.sql b/web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chain/sql/default/delete.sql similarity index 100% rename from web/pgadmin/browser/server_groups/servers/pg_timetable/chains/sql/default/delete.sql rename to web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chain/sql/default/delete.sql 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..018aa3f2d87 --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chain/sql/default/run_now.sql @@ -0,0 +1,8 @@ +SELECT timetable.notify_chain_start( + {{ chain_id|qtLiteral(conn) }}::bigint, + ( + SELECT client_name + FROM timetable.chain + WHERE chain_id = {{ chain_id|qtLiteral(conn) }}::integer + ) +) AS result; 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..4ff05d07171 --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/templates/pgt_chaintask/sql/default/create.sql @@ -0,0 +1,21 @@ +{% import 'macros/pgt_chaintask.macros' as TASK %} +WITH tid AS ( + {{ TASK.INSERT(has_connstr, chain_id, data, conn) }} +) +{% 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..a5414816331 --- /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, + CASE WHEN kind::text = 'SQL' THEN true ELSE false END 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; 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..97f797aabd9 --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/tests/pgt_timetable_test_data.json @@ -0,0 +1,447 @@ +{ + "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, + "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..8bedfb5769e --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/tests/test_pgt_chain_delete.py @@ -0,0 +1,57 @@ +########################################################################## +# +# 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): + 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..f0a8b6041d3 --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/tests/test_pgt_chain_get_msql.py @@ -0,0 +1,48 @@ +########################################################################## +# +# 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): + 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..726d2fb3abc --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/tests/test_pgt_chain_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 . 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): + 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..731abfbe160 --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/tests/test_pgt_chain_sql.py @@ -0,0 +1,59 @@ +########################################################################## +# +# 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): + 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..a6645520dd0 --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/tests/utils.py @@ -0,0 +1,320 @@ +########################################################################## +# +# 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) + + +def create_pgtimetable_chain(self, name): + 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() + connection.close() + return chain_id[0] + except Exception: + traceback.print_exc(file=sys.stderr) + + +def delete_pgtimetable_chain(self, chain_id=None): + if chain_id is None: + chain_id = self.chain_id + 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() + connection.close() + except Exception: + traceback.print_exc(file=sys.stderr) + + +def verify_pgtimetable_chain(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() + pg_cursor.execute( + "SELECT COUNT(*) FROM timetable.chain " + "WHERE chain_id = '%s'::integer;" % self.chain_id + ) + result = pg_cursor.fetchone() + count = result[0] + connection.close() + return count is not None and int(count) != 0 + except Exception: + traceback.print_exc(file=sys.stderr) + + +def create_pgtimetable_task(self, task_name, chain_id): + 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() + connection.close() + return task_id[0] + except Exception: + traceback.print_exc(file=sys.stderr) + + +def delete_pgtimetable_task(self, task_id=None): + if task_id is None: + task_id = self.task_id + 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() + connection.close() + except Exception: + traceback.print_exc(file=sys.stderr) + + +def verify_pgtimetable_task(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() + pg_cursor.execute( + "SELECT COUNT(*) FROM timetable.task " + "WHERE task_id = '%s'::integer;" % self.task_id + ) + result = pg_cursor.fetchone() + count = result[0] + connection.close() + return count is not None and int(count) != 0 + except Exception: + traceback.print_exc(file=sys.stderr) 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'), From c34bb52d2886d72efce1d7c1c1178dffc651509a Mon Sep 17 00:00:00 2001 From: Regina Obe Date: Tue, 14 Jul 2026 01:51:58 -0400 Subject: [PATCH 03/14] Fix coderrabbit raised issues - Fix: Unguarded query status before indexing res['rows'][0] - Rollback when the post-create lookup fails. - Fix: database_connection update isn't gated by has_connstr - Add a pg_timetable specific row level threshold --- .../browser/register_browser_preferences.py | 11 +++++++++++ .../servers/pg_timetable/__init__.py | 15 +++++++++++---- .../servers/pg_timetable/tasks/__init__.py | 2 +- 3 files changed, 23 insertions(+), 5 deletions(-) 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/pg_timetable/__init__.py b/web/pgadmin/browser/server_groups/servers/pg_timetable/__init__.py index 2d41a7e4563..1bcdca5242f 100644 --- a/web/pgadmin/browser/server_groups/servers/pg_timetable/__init__.py +++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/__init__.py @@ -74,6 +74,8 @@ def backend_supported(self, manager, **kwargs): column_name='database_connection' ) has_connstr""") + if not status: + return False manager.db_info['timetable'] = res['rows'][0] return True return False @@ -329,10 +331,14 @@ def create(self, gid, sid): ) ) - self.conn.execute_void('END') 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( @@ -407,6 +413,7 @@ def _process_ctasks(self, chain_id, ctasks): (tid, chain_id) ) + has_connstr = self.manager.db_info['timetable']['has_connstr'] for task in ctasks.get('changed', []): if not isinstance(task, dict): continue @@ -419,9 +426,10 @@ def _process_ctasks(self, chain_id, ctasks): 'task_name': 'task_name', 'task_order': 'task_order', 'command': 'command', - 'database_connection': 'database_connection', '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']) @@ -436,7 +444,6 @@ def _process_ctasks(self, chain_id, ctasks): if 'parameters' in task: self._upsert_task_params(tid, task['parameters']) - has_connstr = self.manager.db_info['timetable']['has_connstr'] for task in ctasks.get('added', []): if not isinstance(task, dict): continue @@ -544,7 +551,7 @@ def statistics(self, gid, sid, chain_id): """ pref = Preferences.module('browser') rows_threshold = pref.preference( - 'pgagent_row_threshold' + 'pgtimetable_row_threshold' ) status, res = self.conn.execute_dict( 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 index 7d4940c813d..c691071a6a8 100644 --- a/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/__init__.py +++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/__init__.py @@ -575,7 +575,7 @@ def statistics(self, gid, sid, chain_id, task_id): """ pref = Preferences.module('browser') rows_threshold = pref.preference( - 'pgagent_row_threshold' + 'pgtimetable_row_threshold' ) status, res = self.conn.execute_dict( From cbc371ed8878de547e80f1834f44a08afb3cf83f Mon Sep 17 00:00:00 2001 From: Regina Obe Date: Tue, 14 Jul 2026 02:49:22 -0400 Subject: [PATCH 04/14] More fixes addressing coderabbitai review - Address status failures on tuple updates in _process_ctasks(), _upsert_task_params(), _upsert_task_params() - Do not return ok on partial chain-field UPDATE failures - Unguarded query status before indexing res['rows'][0] in check_precondition() - kind should not be represented as boolean, should be cast to text - Only insert to database_connection column if has_connstr --- .../servers/pg_timetable/__init__.py | 52 +++++++++++++++---- .../servers/pg_timetable/tasks/__init__.py | 4 +- .../templates/macros/pgt_chaintask.macros | 24 ++------- .../pgt_chaintask/sql/default/nodes.sql | 2 +- 4 files changed, 50 insertions(+), 32 deletions(-) diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/__init__.py b/web/pgadmin/browser/server_groups/servers/pg_timetable/__init__.py index 1bcdca5242f..44cfad99aa7 100644 --- a/web/pgadmin/browser/server_groups/servers/pg_timetable/__init__.py +++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/__init__.py @@ -359,6 +359,10 @@ def update(self, gid, sid, chain_id): 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 = [] @@ -374,9 +378,13 @@ def update(self, gid, sid, 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) - self._process_ctasks(chain_id, data.get('ctasks', {})) + 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( @@ -386,8 +394,13 @@ def update(self, gid, sid, chain_id): ) 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( @@ -403,15 +416,17 @@ def update(self, gid, sid, chain_id): def _process_ctasks(self, chain_id, ctasks): if not isinstance(ctasks, dict): - return + return True, None for task in ctasks.get('deleted', []): tid = task.get('task_id') if isinstance(task, dict) else task if tid: - self.conn.execute_void( + 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', []): @@ -440,9 +455,13 @@ def _process_ctasks(self, chain_id, ctasks): if sets: params.extend([tid, chain_id]) sql = f"UPDATE timetable.task SET {', '.join(sets)} WHERE task_id = %s AND chain_id = %s" - self.conn.execute_void(sql, params) + status, res = self.conn.execute_void(sql, params) + if not status: + return status, res if 'parameters' in task: - self._upsert_task_params(tid, task['parameters']) + 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): @@ -461,19 +480,27 @@ def _process_ctasks(self, chain_id, ctasks): 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 status and tid: - self._upsert_task_params(tid, task.get('parameters', [])) + 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 + return True, None if isinstance(parameters, dict): parameters = parameters.get('added', []) + parameters.get('changed', []) if not parameters: - return - self.conn.execute_void( + return True, None + status, res = self.conn.execute_void( "DELETE FROM timetable.parameter WHERE task_id = %s", (task_id,) ) + if not status: + return status, res for idx, param in enumerate(parameters): if not isinstance(param, dict): param = {'order_id': idx + 1, 'value': str(param)} @@ -490,7 +517,10 @@ def _upsert_task_params(self, task_id, parameters): 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) - self.conn.execute_void(sql, params) + status, res = self.conn.execute_void(sql, params) + if not status: + return status, res + return True, None @check_precondition def delete(self, gid, sid, chain_id=None): 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 index c691071a6a8..b4f0eedab4a 100644 --- a/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/__init__.py +++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/__init__.py @@ -201,7 +201,7 @@ def wrap(*args, **kwargs): self.template_path = 'pgt_chaintask/sql/default' if 'timetable' not in self.manager.db_info: - _, res = self.conn.execute_dict(""" + status, res = self.conn.execute_dict(""" SELECT EXISTS( SELECT 1 FROM information_schema.columns WHERE @@ -209,6 +209,8 @@ def wrap(*args, **kwargs): 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) 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 index bd53d50f724..5ac73949542 100644 --- 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 @@ -1,18 +1,17 @@ {% macro INSERT(has_connstr, chain_id, data, conn) -%} INSERT INTO timetable.task( - chain_id, task_name, task_order, command, kind, database_connection + 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 and data.database_connection %}, - {{ data.database_connection|qtLiteral(conn) }}::text{% else %}NULL{% endif %} + {{ 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; +) 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 'database_connection' in data %} @@ -47,20 +46,7 @@ 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, - COALESCE( - ( - SELECT jsonb_agg( - jsonb_build_object( - 'order_id', p.order_id, - 'value', p.value #>> '{}' - ) - ORDER BY p.order_id - ) - FROM timetable.parameter p - WHERE p.task_id = t.task_id - ), - '[]'::jsonb - ) AS parameters + NULL AS parameters FROM timetable.task t WHERE 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 index a5414816331..a37c9983f98 100644 --- 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 @@ -1,6 +1,6 @@ SELECT task_id, chain_id, task_name, task_order, - CASE WHEN kind::text = 'SQL' THEN true ELSE false END AS kind + kind::text AS kind FROM timetable.task WHERE From 76d6e52a6f2b04942d7cd4ac38361dd3376722e6 Mon Sep 17 00:00:00 2001 From: Regina Obe Date: Tue, 14 Jul 2026 14:06:08 -0400 Subject: [PATCH 05/14] Coderabbitai review bug and other fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix the conditional commas in the INSERT values list of chain.tasks - _init_ is not __init__ — this initializer never runs. - Prevent updating non-existent database_connection column - Another unguarded check in pg_timetable init - Change terminology from pgAgent Jobs/steps to pgTimetable Chain/tasks - Make task ordering less fragile - Fix some issues with parameter update when reordering --- .../servers/pg_timetable/__init__.py | 42 +++--- .../pg_timetable/static/js/pgt_chain.ui.js | 10 +- .../servers/pg_timetable/tasks/__init__.py | 120 ++++++++++-------- .../tasks/static/js/pgt_chaintask.ui.js | 17 ++- .../templates/macros/pgt_chaintask.macros | 16 ++- .../pgt_chaintask/sql/default/create.sql | 3 +- 6 files changed, 128 insertions(+), 80 deletions(-) diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/__init__.py b/web/pgadmin/browser/server_groups/servers/pg_timetable/__init__.py index 44cfad99aa7..68a30a2f6b8 100644 --- a/web/pgadmin/browser/server_groups/servers/pg_timetable/__init__.py +++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/__init__.py @@ -170,7 +170,7 @@ def wrap(self, *args, **kwargs): self.template_path = 'pgt_chain/sql/default' if 'timetable' not in self.manager.db_info: - _, res = self.conn.execute_dict(""" + status, res = self.conn.execute_dict(""" SELECT EXISTS( SELECT 1 FROM information_schema.columns WHERE @@ -178,6 +178,8 @@ def wrap(self, *args, **kwargs): 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) @@ -271,7 +273,7 @@ def properties(self, gid, sid, chain_id=None): @check_precondition def create(self, gid, sid): - """Create the pgAgent job.""" + """Create the pgTimeTable chain.""" required_args = [ 'chain_name' ] @@ -492,21 +494,15 @@ def _process_ctasks(self, chain_id, ctasks): def _upsert_task_params(self, task_id, parameters): if not parameters: return True, None - if isinstance(parameters, dict): - parameters = parameters.get('added', []) + parameters.get('changed', []) - if not parameters: - return True, None - status, res = self.conn.execute_void( - "DELETE FROM timetable.parameter WHERE task_id = %s", (task_id,) - ) - if not status: - return status, res - for idx, param in enumerate(parameters): + + 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 = '' @@ -517,14 +513,28 @@ def _upsert_task_params(self, task_id, parameters): 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) - status, res = self.conn.execute_void(sql, params) + 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 pgAgent Job.""" + """Delete the pgTimeTable chain.""" if chain_id is None: data = request.form if request.form else json.loads( @@ -646,8 +656,8 @@ def sql(self, gid, sid, chain_id): @check_precondition def run_now(self, gid, sid, chain_id): """ - This function will set the next run to now, to inform the pgAgent to - run the job now. + 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( 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 index 4f5824540c3..a86783742cb 100644 --- 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 @@ -109,8 +109,14 @@ export default class PgtChainSchema extends BaseUISchema { depChange: (state, source, topState, actionObj) => { if (actionObj.type === SCHEMA_STATE_ACTIONS.ADD_ROW && state?.ctasks) { const tasks = state.ctasks; - const lastOrder = tasks.reduce((max, t) => Math.max(max, t.task_order || 0), 0); - tasks[tasks.length - 1].task_order = lastOrder + 10; + 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 index b4f0eedab4a..a57a02471a0 100644 --- a/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/__init__.py +++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/__init__.py @@ -7,7 +7,7 @@ # ########################################################################## -"""Implements pgAgent Job Step Node""" +"""Implements pgTimeTable Chain Task Node""" import json from functools import wraps @@ -107,7 +107,7 @@ 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 job step node, showing properties, showing sql in sql pane. + updating chain task, showing properties, showing sql in sql pane. Methods: ------- @@ -120,29 +120,29 @@ class ChainTaskView(PGChildNodeView) manager,conn & template_path properties to self * list() - - This function is used to list all the job step nodes within that + - 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 job step node. + 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 job step node + - 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 job step node + - 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 - job step node + chain task * sql(gid, sid, chain_id, jscid) - Dummy response for sql panel * delete(gid, sid, chain_id, jscid) - - Drops job step + - Drops chain task """ node_type = blueprint.node_type @@ -168,7 +168,7 @@ class ChainTaskView(PGChildNodeView) 'stats': [{'get': 'statistics'}] }) - def _init_(self, **kwargs): + def __init__(self, **kwargs): """ Method is used to initialize the ChainTaskView and its base view. Initialize all the variables create/used dynamically like conn, @@ -220,13 +220,13 @@ def wrap(*args, **kwargs): @check_precondition def list(self, gid, sid, chain_id): """ - This function is used to list all the job step nodes within + This function is used to list all the chain tasks within that collection. Args: gid: Server Group ID sid: Server ID - chain_id: Job ID + chain_id: Chain ID """ sql = render_template( "/".join([self.template_path, self._PROPERTIES_SQL]), @@ -253,12 +253,12 @@ 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 job step nodes. + Here it will create all the chain tasks. Args: gid: Server Group ID sid: Server ID - chain_id: Job ID + chain_id: Chain ID """ res = [] sql = render_template( @@ -311,12 +311,12 @@ def nodes(self, gid, sid, chain_id, task_id=None): @check_precondition def properties(self, gid, sid, chain_id, task_id): """ - This function will show the properties of the selected job step node. + This function will show the properties of the selected chain task. Args: gid: Server Group ID sid: Server ID - chain_id: Job ID + chain_id: Chain ID task_id: ChainTask ID """ sql = render_template( @@ -346,27 +346,16 @@ def properties(self, gid, sid, chain_id, task_id): @check_precondition def create(self, gid, sid, chain_id): """ - This function will update the data for the selected job step node. - - Args: - gid: Server Group ID - sid: Server ID - chain_id: Job ID + This function will create the chain task. """ - data = {} - if request.args: - 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 - else: - data = json.loads(request.data.decode()) + 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 = [] @@ -409,7 +398,7 @@ def create(self, gid, sid, chain_id): if len(res['rows']) == 0: return gone( errormsg=gettext( - "Job step creation failed." + "Chain task creation failed." ) ) row = res['rows'][0] @@ -425,12 +414,12 @@ def create(self, gid, sid, chain_id): @check_precondition def update(self, gid, sid, chain_id, task_id): """ - This function will update the data for the selected job step node. + This function will update the data for the selected chain task. Args: gid: Server Group ID sid: Server ID - chain_id: Job ID + chain_id: Chain ID task_id: ChainTask ID """ data = request.form if request.form else json.loads( @@ -439,19 +428,26 @@ def update(self, gid, sid, chain_id, task_id): 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', []) + params_list = params_raw.get('changed', []) + params_raw.get('added', []) + else: + params_list = params_raw + cleaned = [] - for idx, param in enumerate(params_raw): + for idx, param in enumerate(params_list): 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) + 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( @@ -482,7 +478,7 @@ def update(self, gid, sid, chain_id, task_id): if len(res['rows']) == 0: return gone( errormsg=gettext( - "Job step update failed." + "Chain task update failed." ) ) row = res['rows'][0] @@ -498,7 +494,7 @@ def update(self, gid, sid, chain_id, task_id): @check_precondition def delete(self, gid, sid, chain_id, task_id=None): - """Delete the Job step.""" + """Delete the chain task.""" if task_id is None: data = request.form if request.form else json.loads( @@ -523,13 +519,13 @@ def delete(self, gid, sid, chain_id, task_id=None): def msql(self, gid, sid, chain_id, task_id=None): """ This function is used to return modified SQL for the selected - job step node. + chain task. Args: gid: Server Group ID sid: Server ID - chain_id: Job ID - task_id: Job Step ID + chain_id: Chain ID + task_id: Chain Task ID """ data = {} sql = '' @@ -539,6 +535,30 @@ def msql(self, gid, sid, chain_id, task_id=None): 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]), 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 index 27f36514c7f..c123d92a230 100644 --- 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 @@ -18,9 +18,9 @@ export function getNodePgtChainTaskSchema(treeNodeInfo, itemNodeData) { constructor() { super({ order_id: null, value: '' }); } - get idAttribute() { return 'order_id'; } 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' }, ]; @@ -75,9 +75,9 @@ export default class PgtChainTaskSchema extends BaseUISchema { constructor() { super({ order_id: null, value: '' }); } - get idAttribute() { return 'order_id'; } 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' }, ]; @@ -166,12 +166,21 @@ For more information, please see the documentation on 0 %}{{ data.run_at|qtLiteral(conn) }}::text{% else %}NULL{% endif %}, - {{ data.client_name|qtLiteral(conn) }}::text, + {% 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 not none %}{{ data.max_instances|qtLiteral(conn) }}::integer{% else %}NULL{% endif %}, - {% if data.timeout is not none %}{{ data.timeout|qtLiteral(conn) }}::integer{% else %}0{% 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 %} 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 index 76bd63be4f4..352a3bcdf79 100644 --- 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 @@ -1,6 +1,6 @@ {% import 'macros/pgt_chaintask.macros' as TASK %} WITH tid AS ( - {{ TASK.INSERT(has_connstr, chain_id, data, conn) }} + {{ TASK.INSERT(has_connstr, chain_id, data, conn) | replace(';', '') }} ) {% if 'parameters' in data and data.parameters|length > 0 %} , ins AS ( 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 index a37c9983f98..5ec42b8f519 100644 --- 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 @@ -8,4 +8,4 @@ WHERE task_id = {{ task_id|qtLiteral(conn) }}::integer AND {% endif %} chain_id = {{ chain_id|qtLiteral(conn) }}::integer -ORDER BY task_order; +ORDER BY task_order, task_name; 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 index 97f797aabd9..00f243eb8f9 100644 --- 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 @@ -68,6 +68,7 @@ "test_data": { "chain_name": "test_chain_add%s", "live": true, + "client_name": "", "ctasks": [ { "task_name": "test_task_1", 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 index 8bedfb5769e..10e7c77e1da 100644 --- 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 @@ -19,6 +19,7 @@ class PgtChainDeleteTestCase(BaseTestGenerator): 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) 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 index f0a8b6041d3..035e7c890f6 100644 --- 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 @@ -21,6 +21,7 @@ class PgtChainGetMsqlTestCase(BaseTestGenerator): 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) 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 index 726d2fb3abc..972eacebf99 100644 --- 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 @@ -21,6 +21,7 @@ class PgtChainGetNodesTestCase(BaseTestGenerator): 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) 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 index 731abfbe160..246aa3e34b8 100644 --- 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 @@ -21,6 +21,7 @@ class PgtChainSqlTestCase(BaseTestGenerator): 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) From e45687df638a6d8baaecf94d5fdfe1e31e51ad43 Mon Sep 17 00:00:00 2001 From: Regina Obe Date: Wed, 15 Jul 2026 16:23:01 -0400 Subject: [PATCH 07/14] More fixes - Force tasks to be sorted by task_order using custom label that prefixes with task_order - Regress tests add super().setUp() to 6 test files that were missing it: test_pgt_chain_delete.py, test_pgt_chain_get_msql.py, test_pgt_chain_get_nodes.py, test_pgt_chain_sql.py, test_pgt_task_delete.py, test_pgt_task_get_msql.py - Guard undefined fields when inserting chains --- .../browser/server_groups/servers/pg_timetable/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/__init__.py b/web/pgadmin/browser/server_groups/servers/pg_timetable/__init__.py index b8bff6c52d6..e5568763ede 100644 --- a/web/pgadmin/browser/server_groups/servers/pg_timetable/__init__.py +++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/__init__.py @@ -369,8 +369,8 @@ def update(self, gid, sid, chain_id): if chain_fields: sets = [] params = [] - bool_keys = {'live', 'self_destruct', 'exclusive_execution'} - null_if_empty = {'run_at', 'client_name', 'on_error'} + 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: From fab63d8f25245067b3692e5a291a42a2ce24d0a1 Mon Sep 17 00:00:00 2001 From: Regina Obe Date: Thu, 16 Jul 2026 09:12:57 -0400 Subject: [PATCH 08/14] Fix test issues - Close the connection if it exists even when there is a failure - Always assign a response output even if mocking is not required --- .../tasks/tests/test_pgt_task_put.py | 2 ++ .../servers/pg_timetable/tests/utils.py | 30 +++++++++++++++---- 2 files changed, 26 insertions(+), 6 deletions(-) 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 index e75e291b68e..75777c476cb 100644 --- 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 @@ -54,6 +54,8 @@ def runTest(self): with patch(self.mock_data["function_name"], side_effect=[eval(self.mock_data["return_value"])]): response = tasks_utils.api_put(self) + else + response = task_utils.api_put(self) utils.assert_status_code(self, response) utils.assert_error_message(self, response) 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 index a6645520dd0..87432e45efc 100644 --- a/web/pgadmin/browser/server_groups/servers/pg_timetable/tests/utils.py +++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/tests/utils.py @@ -154,6 +154,7 @@ def is_pgtimetable_installed_on_server(self): def create_pgtimetable_chain(self, name): + connection = None try: connection = utils.get_db_connection( self.server['db'], @@ -178,15 +179,18 @@ def create_pgtimetable_chain(self, name): chain_id = pg_cursor.fetchone() utils.set_isolation_level(connection, old_isolation_level) connection.commit() - connection.close() 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'], @@ -209,12 +213,15 @@ def delete_pgtimetable_chain(self, chain_id=None): ) utils.set_isolation_level(connection, old_isolation_level) connection.commit() - connection.close() 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'], @@ -231,13 +238,16 @@ def verify_pgtimetable_chain(self): ) result = pg_cursor.fetchone() count = result[0] - connection.close() 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'], @@ -261,15 +271,18 @@ def create_pgtimetable_task(self, task_name, chain_id): task_id = pg_cursor.fetchone() utils.set_isolation_level(connection, old_isolation_level) connection.commit() - connection.close() 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'], @@ -292,12 +305,15 @@ def delete_pgtimetable_task(self, task_id=None): ) utils.set_isolation_level(connection, old_isolation_level) connection.commit() - connection.close() 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'], @@ -314,7 +330,9 @@ def verify_pgtimetable_task(self): ) result = pg_cursor.fetchone() count = result[0] - connection.close() return count is not None and int(count) != 0 except Exception: traceback.print_exc(file=sys.stderr) + finally: + if connection: + connection.close() From 29162cbc22cc6fcf0cf9d22827e98c16e5ce3ea4 Mon Sep 17 00:00:00 2001 From: Regina Obe Date: Thu, 16 Jul 2026 16:12:40 -0400 Subject: [PATCH 09/14] Take care of issues raised during code review - run_now - when no client_name specified in the chain, run on all active agents - if no active agent and no client_name specified in chain, raise an error - fix missing : - fix parsing / deparsing of json data in timetable.parameter --- .../servers/pg_timetable/__init__.py | 15 ++++++++ .../tasks/tests/test_pgt_task_put.py | 2 +- .../pgt_chain/sql/default/run_now.sql | 34 ++++++++++++++----- 3 files changed, 42 insertions(+), 9 deletions(-) diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/__init__.py b/web/pgadmin/browser/server_groups/servers/pg_timetable/__init__.py index e5568763ede..9b613aabc7b 100644 --- a/web/pgadmin/browser/server_groups/servers/pg_timetable/__init__.py +++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/__init__.py @@ -572,6 +572,21 @@ def msql(self, gid, sid, chain_id=None): 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([ 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 index 75777c476cb..4f9ffe05bdc 100644 --- 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 @@ -54,7 +54,7 @@ def runTest(self): with patch(self.mock_data["function_name"], side_effect=[eval(self.mock_data["return_value"])]): response = tasks_utils.api_put(self) - else + else: response = task_utils.api_put(self) utils.assert_status_code(self, response) 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 index 018aa3f2d87..e08ff800457 100644 --- 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 @@ -1,8 +1,26 @@ -SELECT timetable.notify_chain_start( - {{ chain_id|qtLiteral(conn) }}::bigint, - ( - SELECT client_name - FROM timetable.chain - WHERE chain_id = {{ chain_id|qtLiteral(conn) }}::integer - ) -) AS result; +DO $$ +DECLARE + _client_name text; + _chain_id bigint := {{ chain_id|qtLiteral(conn) }}::bigint; + _found boolean := false; +BEGIN + SELECT client_name INTO _client_name + FROM timetable.chain + WHERE chain_id = _chain_id; + + IF _client_name IS NULL OR _client_name = '' THEN + FOR _client_name IN + SELECT DISTINCT client_name FROM timetable.active_session + LOOP + _found := true; + PERFORM timetable.notify_chain_start(_chain_id, _client_name); + END LOOP; + + IF NOT _found THEN + RAISE EXCEPTION 'no client_name specified and no active sessions'; + END IF; + ELSE + PERFORM timetable.notify_chain_start(_chain_id, _client_name); + END IF; +END +$$; From f664a8b60f6a8f5d9c4513044d5a928104c965ee Mon Sep 17 00:00:00 2001 From: Regina Obe Date: Thu, 16 Jul 2026 23:28:35 -0400 Subject: [PATCH 10/14] More issues raised in code review - utils is_pgtimetable_installed_on_server should return exception message and format of output should be same structure as the success case --- .../browser/server_groups/servers/pg_timetable/tests/utils.py | 1 + 1 file changed, 1 insertion(+) 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 index 87432e45efc..da2ba04a18b 100644 --- a/web/pgadmin/browser/server_groups/servers/pg_timetable/tests/utils.py +++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/tests/utils.py @@ -151,6 +151,7 @@ def is_pgtimetable_installed_on_server(self): return True, None except Exception: traceback.print_exc(file=sys.stderr) + return False, "Error checking pgTimetable installation." def create_pgtimetable_chain(self, name): From b5c4ffb6c0a508ca439b12e9dc64b33ca1980bd9 Mon Sep 17 00:00:00 2001 From: Regina Obe Date: Thu, 16 Jul 2026 23:59:37 -0400 Subject: [PATCH 11/14] - Fix typo on tasks_utils in tests - Change task label from " " to ": " - Change run_now so it doesn't run on all active agents. Should only run on one that is active. Change wording from active session to active agent --- .../servers/pg_timetable/tasks/__init__.py | 8 ++++---- .../tasks/tests/test_pgt_task_put.py | 2 +- .../templates/pgt_chain/sql/default/run_now.sql | 17 ++++++----------- 3 files changed, 11 insertions(+), 16 deletions(-) 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 index 4481254116f..335fe903f41 100644 --- a/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/__init__.py +++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/tasks/__init__.py @@ -278,7 +278,7 @@ def nodes(self, gid, sid, chain_id, task_id=None): return gone(errormsg=self.STEP_NOT_FOUND) row = result['rows'][0] - label = f"{int(row['task_order']):04d} {row['task_name']}" + label = f"{int(row['task_order']):04d}: {row['task_name']}" node = self.blueprint.generate_browser_node( row['task_id'], row['chain_id'], @@ -291,7 +291,7 @@ def nodes(self, gid, sid, chain_id, task_id=None): return make_json_response(node) for row in result['rows']: - label = f"{int(row['task_order']):04d} {row['task_name']}" + label = f"{int(row['task_order']):04d}: {row['task_name']}" node = self.blueprint.generate_browser_node( row['task_id'], row['chain_id'], @@ -402,7 +402,7 @@ def create(self, gid, sid, chain_id): ) ) row = res['rows'][0] - label = f"{int(row['task_order']):04d} {row['task_name']}" + label = f"{int(row['task_order']):04d}: {row['task_name']}" node = self.blueprint.generate_browser_node( row['task_id'], row['chain_id'], @@ -482,7 +482,7 @@ def update(self, gid, sid, chain_id, task_id): ) ) row = res['rows'][0] - label = f"{int(row['task_order']):04d} {row['task_name']}" + label = f"{int(row['task_order']):04d}: {row['task_name']}" node = self.blueprint.generate_browser_node( task_id, chain_id, 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 index 4f9ffe05bdc..79b52887c4a 100644 --- 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 @@ -55,7 +55,7 @@ def runTest(self): side_effect=[eval(self.mock_data["return_value"])]): response = tasks_utils.api_put(self) else: - response = task_utils.api_put(self) + response = tasks_utils.api_put(self) utils.assert_status_code(self, response) utils.assert_error_message(self, response) 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 index e08ff800457..d1f7f15fcd3 100644 --- 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 @@ -2,25 +2,20 @@ DO $$ DECLARE _client_name text; _chain_id bigint := {{ chain_id|qtLiteral(conn) }}::bigint; - _found boolean := false; BEGIN SELECT client_name INTO _client_name FROM timetable.chain WHERE chain_id = _chain_id; IF _client_name IS NULL OR _client_name = '' THEN - FOR _client_name IN - SELECT DISTINCT client_name FROM timetable.active_session - LOOP - _found := true; - PERFORM timetable.notify_chain_start(_chain_id, _client_name); - END LOOP; + 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 sessions'; + IF NOT FOUND THEN + RAISE EXCEPTION 'no client_name specified and no active agents'; END IF; - ELSE - PERFORM timetable.notify_chain_start(_chain_id, _client_name); END IF; + + PERFORM timetable.notify_chain_start(_chain_id, _client_name); END $$; From 3f0c1c5657c7e01b81000e7137c1793162a3c72a Mon Sep 17 00:00:00 2001 From: Regina Obe Date: Fri, 17 Jul 2026 02:47:01 -0400 Subject: [PATCH 12/14] - Fix test logic for negative scenarios and missing negative test handling. - Add negative tests --- .../tasks/tests/tasks_test_data.json | 18 ++++++++++++++++++ .../tasks/tests/test_pgt_task_delete.py | 18 ++++++++++++++---- 2 files changed, 32 insertions(+), 4 deletions(-) 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 index bcf809a184b..1017e50461f 100644 --- 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 @@ -99,6 +99,24 @@ "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": [ 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 index 78d9bc43556..bd5c81e4b99 100644 --- 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 @@ -8,6 +8,8 @@ ########################################################################## 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 \ @@ -54,10 +56,18 @@ def runTest(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") + 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""" From 7e1fcbdc150bb412d7bdcd12cf31a734fa63cd99 Mon Sep 17 00:00:00 2001 From: Regina Obe Date: Fri, 17 Jul 2026 03:32:03 -0400 Subject: [PATCH 13/14] pgTimeTable docs --- .../images/pgtimetable_chain_general.png | Bin 0 -> 57141 bytes .../images/pgtimetable_chain_properties.png | Bin 0 -> 29790 bytes docs/en_US/images/pgtimetable_task_code.png | Bin 0 -> 15341 bytes .../en_US/images/pgtimetable_task_general.png | Bin 0 -> 48578 bytes .../images/pgtimetable_task_parameters.png | Bin 0 -> 18567 bytes .../images/pgtimetable_task_statistics.png | Bin 0 -> 18530 bytes docs/en_US/index.rst | 1 + docs/en_US/pgtimetable_chains.rst | 241 ++++++++++++++++++ 8 files changed, 242 insertions(+) create mode 100644 docs/en_US/images/pgtimetable_chain_general.png create mode 100644 docs/en_US/images/pgtimetable_chain_properties.png create mode 100644 docs/en_US/images/pgtimetable_task_code.png create mode 100644 docs/en_US/images/pgtimetable_task_general.png create mode 100644 docs/en_US/images/pgtimetable_task_parameters.png create mode 100644 docs/en_US/images/pgtimetable_task_statistics.png create mode 100644 docs/en_US/pgtimetable_chains.rst 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 0000000000000000000000000000000000000000..4cde1249072e441b1b0c09dcf08b6b6549fc56ab GIT binary patch literal 57141 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 KelF{r5}E*2Yfwc1 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..3ca9b61918118fed3bf9e221a6fd9ac0ec63ffb1 GIT binary patch literal 29790 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..928c276e3dd25ae7d0b2d688343b37c214fb8a23 GIT binary patch literal 15341 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..e5a5f9e49499700af281057fa13e0fa2b4ac0958 GIT binary patch literal 48578 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 RxcgN>in6LQ`_. + +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 From c551e3934e35e44092d67879609cf46964a8dca0 Mon Sep 17 00:00:00 2001 From: Regina Obe Date: Sat, 18 Jul 2026 03:09:58 -0400 Subject: [PATCH 14/14] Fix grammar and spelling --- docs/en_US/pgtimetable_chains.rst | 27 +++++++++++++-------------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/docs/en_US/pgtimetable_chains.rst b/docs/en_US/pgtimetable_chains.rst index c2e857a2dd6..07e2ed2fbd6 100644 --- a/docs/en_US/pgtimetable_chains.rst +++ b/docs/en_US/pgtimetable_chains.rst @@ -5,25 +5,24 @@ `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. +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 be executed in the order 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. +through the *Properties* context menu) allow you to modify a chain or disable the whole chain, +as well as modify 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. +and the *Statistics* tab will display details about the chain's execution. There are also statistics about each task. -pgTimeTable supports ability to have more than one job agent running at a time. +pgTimeTable supports the 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. @@ -38,8 +37,8 @@ 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. +To enable viewing of the *pgTimeTable Chains* node, enable it on the File -> Preferences -> Nodes. +It will only show if the schema *timetable* and associated tables exist in your maintenance database. Creating a Chain **************** @@ -98,10 +97,10 @@ chain: * *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. + The format consists of a single field with 5 slots. As with regular cron, + you can use a comma (`,`) for multiple values and an asterisk (`*`) as a 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. + The timezone is always the timezone of the server the pgTimeTable database resides on. .. code-block:: text @@ -230,10 +229,10 @@ 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. +for the kind of parameters expected for each type. -Both the Chain and Tasks have a statistics tab, which shows results of past run of tasks. +Both the Chain and Tasks have a statistics tab, which show the results of past runs of tasks. .. image:: images/pgtimetable_task_statistics.png