Security hardening, bug fixes, and performance improvements
Security fixes: - Replace new Function() condition eval with vm.runInNewContext() (RCE fix) - Add admin checks to DELETE executions, all scheduled-commands endpoints - Remove api_key from GET /api/workers response (was exposed to all employees) - Separate browserClients/workerClients sets; broadcast() now sends to browsers only - Add worker WebSocket auth: reject if api_key provided but invalid - Fix XSS: escapeHtml() on step_name, duration, worker_id, user info, execution_id Bug fixes: - Replace DB-polling waitForCommandResult with event-driven _commandResolvers Map - Replace non-atomic addExecutionLog with JSON_ARRAY_APPEND (fixes concurrent write race) - Add stale execution recovery on startup: running→failed with log entry - Fix calculateNextRun returning null for unknown types (now throws) - Fix scheduler overlap: skip if previous execution still running - Fix JSON double-parse on worker_ids column - Fix switchTab() bare event.target reference - Fix selectedExecutions Array→Set (O(1) lookups, fixes performance regression) - Fix param modal event listener leak (delegated handler, removes before re-adding) - Add ws.onerror handler (was silently swallowing WebSocket errors) - Move misplaced routes to before server.listen() Performance/cleanup: - DB connection pool 10→50 - EXECUTION_RETENTION_DAYS default 1→30 (matches docs) - Remove unused packages: bcryptjs, body-parser, cors, js-yaml, jsonwebtoken - Remove generateUUID() wrapper, use crypto.randomUUID() directly - Remove dead example workflow constants - Add ESC key handler to close modals - Fix clearCompletedExecutions limit 1000→9999 - Add security notice to README.md Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
A distributed workflow orchestration platform for managing and executing complex multi-step operations across server clusters through a retro terminal-themed web interface.
|
A distributed workflow orchestration platform for managing and executing complex multi-step operations across server clusters through a retro terminal-themed web interface.
|
||||||
|
|
||||||
|
> **Security Notice:** This repository is hosted on Gitea and is version-controlled. **Never commit secrets, credentials, passwords, API keys, or any sensitive information to this repo.** All sensitive configuration belongs exclusively in `.env` files which are listed in `.gitignore` and must never be committed. This includes database passwords, worker API keys, webhook secrets, and internal IP details.
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
PULSE is a centralized workflow execution system designed to orchestrate operations across distributed infrastructure. It provides a powerful web-based interface with a vintage CRT terminal aesthetic for defining, managing, and executing workflows that can span multiple servers, require human interaction, and perform complex automation tasks at scale.
|
PULSE is a centralized workflow execution system designed to orchestrate operations across distributed infrastructure. It provides a powerful web-based interface with a vintage CRT terminal aesthetic for defining, managing, and executing workflows that can span multiple servers, require human interaction, and perform complex automation tasks at scale.
|
||||||
|
|||||||
186
package-lock.json
generated
186
package-lock.json
generated
@@ -9,13 +9,8 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bcryptjs": "^3.0.3",
|
|
||||||
"body-parser": "^2.2.1",
|
|
||||||
"cors": "^2.8.5",
|
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
"js-yaml": "^4.1.1",
|
|
||||||
"jsonwebtoken": "^9.0.2",
|
|
||||||
"mysql2": "^3.15.3",
|
"mysql2": "^3.15.3",
|
||||||
"ws": "^8.18.3"
|
"ws": "^8.18.3"
|
||||||
}
|
}
|
||||||
@@ -33,12 +28,6 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/argparse": {
|
|
||||||
"version": "2.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
|
|
||||||
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
|
|
||||||
"license": "Python-2.0"
|
|
||||||
},
|
|
||||||
"node_modules/aws-ssl-profiles": {
|
"node_modules/aws-ssl-profiles": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz",
|
||||||
@@ -48,15 +37,6 @@
|
|||||||
"node": ">= 6.0.0"
|
"node": ">= 6.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/bcryptjs": {
|
|
||||||
"version": "3.0.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz",
|
|
||||||
"integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==",
|
|
||||||
"license": "BSD-3-Clause",
|
|
||||||
"bin": {
|
|
||||||
"bcrypt": "bin/bcrypt"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/body-parser": {
|
"node_modules/body-parser": {
|
||||||
"version": "2.2.1",
|
"version": "2.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.1.tgz",
|
||||||
@@ -81,12 +61,6 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"url": "https://opencollective.com/express"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/buffer-equal-constant-time": {
|
|
||||||
"version": "1.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
|
|
||||||
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
|
|
||||||
"license": "BSD-3-Clause"
|
|
||||||
},
|
|
||||||
"node_modules/bytes": {
|
"node_modules/bytes": {
|
||||||
"version": "3.1.2",
|
"version": "3.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
||||||
@@ -165,19 +139,6 @@
|
|||||||
"node": ">=6.6.0"
|
"node": ">=6.6.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/cors": {
|
|
||||||
"version": "2.8.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz",
|
|
||||||
"integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"object-assign": "^4",
|
|
||||||
"vary": "^1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.10"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/debug": {
|
"node_modules/debug": {
|
||||||
"version": "4.4.3",
|
"version": "4.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
@@ -239,15 +200,6 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/ecdsa-sig-formatter": {
|
|
||||||
"version": "1.0.11",
|
|
||||||
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
|
|
||||||
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"dependencies": {
|
|
||||||
"safe-buffer": "^5.0.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/ee-first": {
|
"node_modules/ee-first": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
|
||||||
@@ -539,103 +491,6 @@
|
|||||||
"integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==",
|
"integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/js-yaml": {
|
|
||||||
"version": "4.1.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
|
|
||||||
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"argparse": "^2.0.1"
|
|
||||||
},
|
|
||||||
"bin": {
|
|
||||||
"js-yaml": "bin/js-yaml.js"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/jsonwebtoken": {
|
|
||||||
"version": "9.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz",
|
|
||||||
"integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"jws": "^3.2.2",
|
|
||||||
"lodash.includes": "^4.3.0",
|
|
||||||
"lodash.isboolean": "^3.0.3",
|
|
||||||
"lodash.isinteger": "^4.0.4",
|
|
||||||
"lodash.isnumber": "^3.0.3",
|
|
||||||
"lodash.isplainobject": "^4.0.6",
|
|
||||||
"lodash.isstring": "^4.0.1",
|
|
||||||
"lodash.once": "^4.0.0",
|
|
||||||
"ms": "^2.1.1",
|
|
||||||
"semver": "^7.5.4"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12",
|
|
||||||
"npm": ">=6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/jwa": {
|
|
||||||
"version": "1.4.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz",
|
|
||||||
"integrity": "sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"buffer-equal-constant-time": "^1.0.1",
|
|
||||||
"ecdsa-sig-formatter": "1.0.11",
|
|
||||||
"safe-buffer": "^5.0.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/jws": {
|
|
||||||
"version": "3.2.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz",
|
|
||||||
"integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"jwa": "^1.4.1",
|
|
||||||
"safe-buffer": "^5.0.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/lodash.includes": {
|
|
||||||
"version": "4.3.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
|
|
||||||
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/lodash.isboolean": {
|
|
||||||
"version": "3.0.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
|
|
||||||
"integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/lodash.isinteger": {
|
|
||||||
"version": "4.0.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
|
|
||||||
"integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/lodash.isnumber": {
|
|
||||||
"version": "3.0.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
|
|
||||||
"integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/lodash.isplainobject": {
|
|
||||||
"version": "4.0.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
|
|
||||||
"integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/lodash.isstring": {
|
|
||||||
"version": "4.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
|
|
||||||
"integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/lodash.once": {
|
|
||||||
"version": "4.1.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
|
|
||||||
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/long": {
|
"node_modules/long": {
|
||||||
"version": "5.3.2",
|
"version": "5.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
|
||||||
@@ -768,15 +623,6 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/object-assign": {
|
|
||||||
"version": "4.1.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
|
||||||
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.10.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/object-inspect": {
|
"node_modules/object-inspect": {
|
||||||
"version": "1.13.4",
|
"version": "1.13.4",
|
||||||
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
||||||
@@ -897,44 +743,12 @@
|
|||||||
"node": ">= 18"
|
"node": ">= 18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/safe-buffer": {
|
|
||||||
"version": "5.2.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
|
||||||
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
|
|
||||||
"funding": [
|
|
||||||
{
|
|
||||||
"type": "github",
|
|
||||||
"url": "https://github.com/sponsors/feross"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "patreon",
|
|
||||||
"url": "https://www.patreon.com/feross"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "consulting",
|
|
||||||
"url": "https://feross.org/support"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/safer-buffer": {
|
"node_modules/safer-buffer": {
|
||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/semver": {
|
|
||||||
"version": "7.7.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
|
|
||||||
"integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
|
|
||||||
"license": "ISC",
|
|
||||||
"bin": {
|
|
||||||
"semver": "bin/semver.js"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/send": {
|
"node_modules/send": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/send/-/send-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/send/-/send-1.2.0.tgz",
|
||||||
|
|||||||
@@ -10,13 +10,8 @@
|
|||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"description": "",
|
"description": "",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bcryptjs": "^3.0.3",
|
|
||||||
"body-parser": "^2.2.1",
|
|
||||||
"cors": "^2.8.5",
|
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
"js-yaml": "^4.1.1",
|
|
||||||
"jsonwebtoken": "^9.0.2",
|
|
||||||
"mysql2": "^3.15.3",
|
"mysql2": "^3.15.3",
|
||||||
"ws": "^8.18.3"
|
"ws": "^8.18.3"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1095,7 +1095,7 @@
|
|||||||
let workers = [];
|
let workers = [];
|
||||||
let allExecutions = []; // Store all loaded executions for filtering
|
let allExecutions = []; // Store all loaded executions for filtering
|
||||||
let compareMode = false;
|
let compareMode = false;
|
||||||
let selectedExecutions = [];
|
let selectedExecutions = new Set();
|
||||||
|
|
||||||
async function loadUser() {
|
async function loadUser() {
|
||||||
try {
|
try {
|
||||||
@@ -1104,10 +1104,10 @@
|
|||||||
|
|
||||||
currentUser = await response.json();
|
currentUser = await response.json();
|
||||||
document.getElementById('userInfo').innerHTML = `
|
document.getElementById('userInfo').innerHTML = `
|
||||||
<div class="name">${currentUser.name}</div>
|
<div class="name">${escapeHtml(currentUser.name || '')}</div>
|
||||||
<div class="email">${currentUser.email}</div>
|
<div class="email">${escapeHtml(currentUser.email || '')}</div>
|
||||||
<div>${currentUser.groups.map(g =>
|
<div>${(currentUser.groups || []).map(g =>
|
||||||
`<span class="badge">${g}</span>`
|
`<span class="badge">${escapeHtml(g)}</span>`
|
||||||
).join('')}</div>
|
).join('')}</div>
|
||||||
`;
|
`;
|
||||||
return true;
|
return true;
|
||||||
@@ -1593,7 +1593,7 @@
|
|||||||
const fullHtml = filtered.length === 0 ?
|
const fullHtml = filtered.length === 0 ?
|
||||||
'<div class="empty">No executions match your filters</div>' :
|
'<div class="empty">No executions match your filters</div>' :
|
||||||
filtered.map(e => {
|
filtered.map(e => {
|
||||||
const isSelected = selectedExecutions.includes(e.id);
|
const isSelected = selectedExecutions.has(e.id);
|
||||||
const clickHandler = compareMode ? `toggleExecutionSelection('${e.id}')` : `viewExecution('${e.id}')`;
|
const clickHandler = compareMode ? `toggleExecutionSelection('${e.id}')` : `viewExecution('${e.id}')`;
|
||||||
const selectedStyle = isSelected ? 'background: rgba(255, 176, 0, 0.2); border-left-width: 5px; border-left-color: var(--terminal-amber);' : '';
|
const selectedStyle = isSelected ? 'background: rgba(255, 176, 0, 0.2); border-left-width: 5px; border-left-color: var(--terminal-amber);' : '';
|
||||||
|
|
||||||
@@ -1625,7 +1625,7 @@
|
|||||||
|
|
||||||
function toggleCompareMode() {
|
function toggleCompareMode() {
|
||||||
compareMode = !compareMode;
|
compareMode = !compareMode;
|
||||||
selectedExecutions = [];
|
selectedExecutions = new Set();
|
||||||
|
|
||||||
const btn = document.getElementById('compareModeBtn');
|
const btn = document.getElementById('compareModeBtn');
|
||||||
const compareBtn = document.getElementById('compareBtn');
|
const compareBtn = document.getElementById('compareBtn');
|
||||||
@@ -1649,31 +1649,29 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function toggleExecutionSelection(executionId) {
|
function toggleExecutionSelection(executionId) {
|
||||||
const index = selectedExecutions.indexOf(executionId);
|
if (selectedExecutions.has(executionId)) {
|
||||||
|
selectedExecutions.delete(executionId);
|
||||||
if (index > -1) {
|
|
||||||
selectedExecutions.splice(index, 1);
|
|
||||||
} else {
|
} else {
|
||||||
if (selectedExecutions.length >= 5) {
|
if (selectedExecutions.size >= 5) {
|
||||||
showTerminalNotification('Maximum 5 executions can be compared', 'error');
|
showTerminalNotification('Maximum 5 executions can be compared', 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
selectedExecutions.push(executionId);
|
selectedExecutions.add(executionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
renderFilteredExecutions();
|
renderFilteredExecutions();
|
||||||
|
|
||||||
// Update compare button text
|
// Update compare button text
|
||||||
const compareBtn = document.getElementById('compareBtn');
|
const compareBtn = document.getElementById('compareBtn');
|
||||||
if (selectedExecutions.length >= 2) {
|
if (selectedExecutions.size >= 2) {
|
||||||
compareBtn.textContent = `[ ⚖️ Compare Selected (${selectedExecutions.length}) ]`;
|
compareBtn.textContent = `[ ⚖️ Compare Selected (${selectedExecutions.size}) ]`;
|
||||||
} else {
|
} else {
|
||||||
compareBtn.textContent = '[ ⚖️ Compare Selected ]';
|
compareBtn.textContent = '[ ⚖️ Compare Selected ]';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function compareSelectedExecutions() {
|
async function compareSelectedExecutions() {
|
||||||
if (selectedExecutions.length < 2) {
|
if (selectedExecutions.size < 2) {
|
||||||
showTerminalNotification('Please select at least 2 executions to compare', 'error');
|
showTerminalNotification('Please select at least 2 executions to compare', 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1837,7 +1835,7 @@
|
|||||||
if (!confirm('Delete all completed and failed executions?')) return;
|
if (!confirm('Delete all completed and failed executions?')) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/executions?limit=1000'); // Get all executions
|
const response = await fetch('/api/executions?limit=9999'); // Get all executions
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
const executions = data.executions || data; // Handle new pagination format
|
const executions = data.executions || data; // Handle new pagination format
|
||||||
|
|
||||||
@@ -1911,10 +1909,10 @@
|
|||||||
${p.required ? 'required' : ''}>
|
${p.required ? 'required' : ''}>
|
||||||
</div>`).join('');
|
</div>`).join('');
|
||||||
document.getElementById('paramModal').style.display = 'flex';
|
document.getElementById('paramModal').style.display = 'flex';
|
||||||
// Focus first input; Enter key submits
|
// Focus first input; Enter key submits — use single delegated listener to avoid duplicates
|
||||||
form.querySelectorAll('input').forEach(inp => {
|
if (form._keydownHandler) form.removeEventListener('keydown', form._keydownHandler);
|
||||||
inp.addEventListener('keydown', e => { if (e.key === 'Enter') submitParamForm(); });
|
form._keydownHandler = (e) => { if (e.key === 'Enter' && e.target.tagName === 'INPUT') submitParamForm(); };
|
||||||
});
|
form.addEventListener('keydown', form._keydownHandler);
|
||||||
const first = form.querySelector('input');
|
const first = form.querySelector('input');
|
||||||
if (first) setTimeout(() => first.focus(), 50);
|
if (first) setTimeout(() => first.focus(), 50);
|
||||||
}
|
}
|
||||||
@@ -2052,7 +2050,7 @@
|
|||||||
return `
|
return `
|
||||||
<div class="log-entry" style="border-left-color: var(--terminal-amber);">
|
<div class="log-entry" style="border-left-color: var(--terminal-amber);">
|
||||||
<div class="log-timestamp">[${timestamp}]</div>
|
<div class="log-timestamp">[${timestamp}]</div>
|
||||||
<div class="log-title" style="color: var(--terminal-amber);">▶️ Step ${log.step}: ${log.step_name}</div>
|
<div class="log-title" style="color: var(--terminal-amber);">▶️ Step ${log.step}: ${escapeHtml(log.step_name || '')}</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
@@ -2061,7 +2059,7 @@
|
|||||||
return `
|
return `
|
||||||
<div class="log-entry" style="border-left-color: var(--terminal-green);">
|
<div class="log-entry" style="border-left-color: var(--terminal-green);">
|
||||||
<div class="log-timestamp">[${timestamp}]</div>
|
<div class="log-timestamp">[${timestamp}]</div>
|
||||||
<div class="log-title" style="color: var(--terminal-green);">✓ Step ${log.step} Completed: ${log.step_name}</div>
|
<div class="log-title" style="color: var(--terminal-green);">✓ Step ${log.step} Completed: ${escapeHtml(log.step_name || '')}</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
@@ -2070,7 +2068,7 @@
|
|||||||
return `
|
return `
|
||||||
<div class="log-entry" style="border-left-color: var(--terminal-amber);">
|
<div class="log-entry" style="border-left-color: var(--terminal-amber);">
|
||||||
<div class="log-timestamp">[${timestamp}]</div>
|
<div class="log-timestamp">[${timestamp}]</div>
|
||||||
<div class="log-title" style="color: var(--terminal-amber);">⏳ Waiting ${log.duration} seconds...</div>
|
<div class="log-title" style="color: var(--terminal-amber);">⏳ Waiting ${escapeHtml(String(log.duration || 0))} seconds...</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
@@ -2093,7 +2091,7 @@
|
|||||||
<div class="log-timestamp">[${timestamp}]</div>
|
<div class="log-timestamp">[${timestamp}]</div>
|
||||||
<div class="log-title" style="color: #ff4444;">⚠️ Worker Offline</div>
|
<div class="log-title" style="color: #ff4444;">⚠️ Worker Offline</div>
|
||||||
<div class="log-details">
|
<div class="log-details">
|
||||||
<div class="log-field"><span class="log-label">Worker ID:</span> ${log.worker_id}</div>
|
<div class="log-field"><span class="log-label">Worker ID:</span> ${escapeHtml(log.worker_id || '')}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@@ -2583,7 +2581,7 @@
|
|||||||
<div style="background: #001a00; border: 2px solid var(--terminal-green); padding: 15px;">
|
<div style="background: #001a00; border: 2px solid var(--terminal-green); padding: 15px;">
|
||||||
<strong style="color: var(--terminal-green);">✓ Command sent successfully!</strong>
|
<strong style="color: var(--terminal-green);">✓ Command sent successfully!</strong>
|
||||||
<div style="margin-top: 10px; font-family: var(--font-mono); font-size: 0.9em; color: var(--terminal-green);">
|
<div style="margin-top: 10px; font-family: var(--font-mono); font-size: 0.9em; color: var(--terminal-green);">
|
||||||
Execution ID: ${data.execution_id}
|
Execution ID: ${escapeHtml(String(data.execution_id || ''))}
|
||||||
</div>
|
</div>
|
||||||
<div style="margin-top: 10px; color: var(--terminal-amber);">
|
<div style="margin-top: 10px; color: var(--terminal-amber);">
|
||||||
Check the Executions tab to see the results
|
Check the Executions tab to see the results
|
||||||
@@ -2698,8 +2696,11 @@
|
|||||||
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
||||||
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
|
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
|
||||||
|
|
||||||
event.target.classList.add('active');
|
// Find the button by its onclick attribute rather than relying on bare `event`
|
||||||
document.getElementById(tabName).classList.add('active');
|
const tabBtn = document.querySelector(`.tab[onclick*="'${tabName}'"]`);
|
||||||
|
if (tabBtn) tabBtn.classList.add('active');
|
||||||
|
const tabContent = document.getElementById(tabName);
|
||||||
|
if (tabContent) tabContent.classList.add('active');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshData() {
|
async function refreshData() {
|
||||||
@@ -2901,8 +2902,23 @@
|
|||||||
console.log('WebSocket closed, reconnecting...');
|
console.log('WebSocket closed, reconnecting...');
|
||||||
setTimeout(connectWebSocket, 5000);
|
setTimeout(connectWebSocket, 5000);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
ws.onerror = (error) => {
|
||||||
|
console.error('[WebSocket] Connection error:', error);
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Close any open modal on ESC key
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
document.querySelectorAll('.modal').forEach(modal => {
|
||||||
|
if (modal.style.display && modal.style.display !== 'none') {
|
||||||
|
modal.style.display = 'none';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Initialize
|
// Initialize
|
||||||
loadUser().then((success) => {
|
loadUser().then((success) => {
|
||||||
if (success) {
|
if (success) {
|
||||||
|
|||||||
294
server.js
294
server.js
@@ -3,6 +3,7 @@ const http = require('http');
|
|||||||
const WebSocket = require('ws');
|
const WebSocket = require('ws');
|
||||||
const mysql = require('mysql2/promise');
|
const mysql = require('mysql2/promise');
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
|
const vm = require('vm');
|
||||||
require('dotenv').config();
|
require('dotenv').config();
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
@@ -13,11 +14,6 @@ const wss = new WebSocket.Server({ server });
|
|||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
app.use(express.static('public'));
|
app.use(express.static('public'));
|
||||||
|
|
||||||
// UUID generator
|
|
||||||
function generateUUID() {
|
|
||||||
return crypto.randomUUID();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Database pool
|
// Database pool
|
||||||
const pool = mysql.createPool({
|
const pool = mysql.createPool({
|
||||||
host: process.env.DB_HOST,
|
host: process.env.DB_HOST,
|
||||||
@@ -26,7 +22,7 @@ const pool = mysql.createPool({
|
|||||||
password: process.env.DB_PASSWORD,
|
password: process.env.DB_PASSWORD,
|
||||||
database: process.env.DB_NAME,
|
database: process.env.DB_NAME,
|
||||||
waitForConnections: true,
|
waitForConnections: true,
|
||||||
connectionLimit: 10,
|
connectionLimit: 50,
|
||||||
queueLimit: 0
|
queueLimit: 0
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -107,6 +103,22 @@ async function initDatabase() {
|
|||||||
)
|
)
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
// Recover stale executions from a previous server crash
|
||||||
|
const [staleExecs] = await connection.query("SELECT id FROM executions WHERE status = 'running'");
|
||||||
|
if (staleExecs.length > 0) {
|
||||||
|
for (const exec of staleExecs) {
|
||||||
|
await connection.query(
|
||||||
|
"UPDATE executions SET status = 'failed', completed_at = NOW() WHERE id = ?",
|
||||||
|
[exec.id]
|
||||||
|
);
|
||||||
|
await connection.query(
|
||||||
|
"UPDATE executions SET logs = JSON_ARRAY_APPEND(COALESCE(logs, '[]'), '$', CAST(? AS JSON)) WHERE id = ?",
|
||||||
|
[JSON.stringify({ action: 'server_restart_recovery', message: 'Execution marked failed due to server restart', timestamp: new Date().toISOString() }), exec.id]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
console.log(`[Recovery] Marked ${staleExecs.length} stale execution(s) as failed`);
|
||||||
|
}
|
||||||
|
|
||||||
console.log('Database tables initialized successfully');
|
console.log('Database tables initialized successfully');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Database initialization error:', error);
|
console.error('Database initialization error:', error);
|
||||||
@@ -119,7 +131,7 @@ async function initDatabase() {
|
|||||||
// Auto-cleanup old executions (runs hourly)
|
// Auto-cleanup old executions (runs hourly)
|
||||||
async function cleanupOldExecutions() {
|
async function cleanupOldExecutions() {
|
||||||
try {
|
try {
|
||||||
const retentionDays = parseInt(process.env.EXECUTION_RETENTION_DAYS) || 1;
|
const retentionDays = parseInt(process.env.EXECUTION_RETENTION_DAYS) || 30;
|
||||||
const [result] = await pool.query(
|
const [result] = await pool.query(
|
||||||
`DELETE FROM executions
|
`DELETE FROM executions
|
||||||
WHERE status IN ('completed', 'failed')
|
WHERE status IN ('completed', 'failed')
|
||||||
@@ -149,15 +161,28 @@ async function processScheduledCommands() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
for (const schedule of schedules) {
|
for (const schedule of schedules) {
|
||||||
|
// Prevent overlapping execution — skip if a previous run is still active
|
||||||
|
const [runningExecs] = await pool.query(
|
||||||
|
"SELECT id FROM executions WHERE started_by = ? AND status = 'running'",
|
||||||
|
[`scheduler:${schedule.name}`]
|
||||||
|
);
|
||||||
|
if (runningExecs.length > 0) {
|
||||||
|
console.log(`[Scheduler] Skipping "${schedule.name}" - previous execution still running`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
console.log(`[Scheduler] Running scheduled command: ${schedule.name}`);
|
console.log(`[Scheduler] Running scheduled command: ${schedule.name}`);
|
||||||
|
|
||||||
const workerIds = JSON.parse(schedule.worker_ids);
|
// Handle both string (raw SQL) and object (auto-parsed by MySQL2 JSON column)
|
||||||
|
const workerIds = typeof schedule.worker_ids === 'string'
|
||||||
|
? JSON.parse(schedule.worker_ids)
|
||||||
|
: schedule.worker_ids;
|
||||||
|
|
||||||
// Execute command on each worker
|
// Execute command on each worker
|
||||||
for (const workerId of workerIds) {
|
for (const workerId of workerIds) {
|
||||||
const workerWs = workers.get(workerId);
|
const workerWs = workers.get(workerId);
|
||||||
if (workerWs && workerWs.readyState === WebSocket.OPEN) {
|
if (workerWs && workerWs.readyState === WebSocket.OPEN) {
|
||||||
const executionId = generateUUID();
|
const executionId = crypto.randomUUID();
|
||||||
|
|
||||||
// Create execution record
|
// Create execution record
|
||||||
await pool.query(
|
await pool.query(
|
||||||
@@ -185,7 +210,13 @@ async function processScheduledCommands() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Update last_run and calculate next_run
|
// Update last_run and calculate next_run
|
||||||
const nextRun = calculateNextRun(schedule.schedule_type, schedule.schedule_value);
|
let nextRun;
|
||||||
|
try {
|
||||||
|
nextRun = calculateNextRun(schedule.schedule_type, schedule.schedule_value);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[Scheduler] Invalid schedule config for "${schedule.name}": ${err.message}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
await pool.query(
|
await pool.query(
|
||||||
'UPDATE scheduled_commands SET last_run = NOW(), next_run = ? WHERE id = ?',
|
'UPDATE scheduled_commands SET last_run = NOW(), next_run = ? WHERE id = ?',
|
||||||
[nextRun, schedule.id]
|
[nextRun, schedule.id]
|
||||||
@@ -202,10 +233,12 @@ function calculateNextRun(scheduleType, scheduleValue) {
|
|||||||
if (scheduleType === 'interval') {
|
if (scheduleType === 'interval') {
|
||||||
// Interval in minutes
|
// Interval in minutes
|
||||||
const minutes = parseInt(scheduleValue);
|
const minutes = parseInt(scheduleValue);
|
||||||
|
if (isNaN(minutes) || minutes <= 0) throw new Error(`Invalid interval value: ${scheduleValue}`);
|
||||||
return new Date(now.getTime() + minutes * 60000);
|
return new Date(now.getTime() + minutes * 60000);
|
||||||
} else if (scheduleType === 'daily') {
|
} else if (scheduleType === 'daily') {
|
||||||
// Daily at HH:MM
|
// Daily at HH:MM
|
||||||
const [hours, minutes] = scheduleValue.split(':').map(Number);
|
const [hours, minutes] = scheduleValue.split(':').map(Number);
|
||||||
|
if (isNaN(hours) || isNaN(minutes)) throw new Error(`Invalid daily time format: ${scheduleValue}`);
|
||||||
const next = new Date(now);
|
const next = new Date(now);
|
||||||
next.setHours(hours, minutes, 0, 0);
|
next.setHours(hours, minutes, 0, 0);
|
||||||
|
|
||||||
@@ -217,10 +250,11 @@ function calculateNextRun(scheduleType, scheduleValue) {
|
|||||||
} else if (scheduleType === 'hourly') {
|
} else if (scheduleType === 'hourly') {
|
||||||
// Every N hours
|
// Every N hours
|
||||||
const hours = parseInt(scheduleValue);
|
const hours = parseInt(scheduleValue);
|
||||||
|
if (isNaN(hours) || hours <= 0) throw new Error(`Invalid hourly value: ${scheduleValue}`);
|
||||||
return new Date(now.getTime() + hours * 3600000);
|
return new Date(now.getTime() + hours * 3600000);
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
throw new Error(`Unknown schedule type: ${scheduleType}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run scheduler every minute
|
// Run scheduler every minute
|
||||||
@@ -229,11 +263,13 @@ setInterval(processScheduledCommands, 60 * 1000);
|
|||||||
setTimeout(processScheduledCommands, 5000);
|
setTimeout(processScheduledCommands, 5000);
|
||||||
|
|
||||||
// WebSocket connections
|
// WebSocket connections
|
||||||
const clients = new Set();
|
const browserClients = new Set(); // Browser UI connections
|
||||||
|
const workerClients = new Set(); // Worker agent connections
|
||||||
const workers = new Map(); // Map worker_id -> WebSocket connection
|
const workers = new Map(); // Map worker_id -> WebSocket connection
|
||||||
|
|
||||||
wss.on('connection', (ws) => {
|
wss.on('connection', (ws) => {
|
||||||
clients.add(ws);
|
// Default to browser client until worker_connect identifies it as a worker
|
||||||
|
browserClients.add(ws);
|
||||||
|
|
||||||
// Handle incoming messages from workers
|
// Handle incoming messages from workers
|
||||||
ws.on('message', async (data) => {
|
ws.on('message', async (data) => {
|
||||||
@@ -269,7 +305,15 @@ wss.on('connection', (ws) => {
|
|||||||
await updateExecutionStatus(execution_id, finalStatus);
|
await updateExecutionStatus(execution_id, finalStatus);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Broadcast result to all connected clients
|
// Resolve any pending event-driven command promise (eliminates DB polling)
|
||||||
|
if (command_id) {
|
||||||
|
const resolver = _commandResolvers.get(command_id);
|
||||||
|
if (resolver) {
|
||||||
|
resolver({ success, stdout, stderr });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Broadcast result to browser clients only
|
||||||
broadcast({
|
broadcast({
|
||||||
type: 'command_result',
|
type: 'command_result',
|
||||||
execution_id: execution_id,
|
execution_id: execution_id,
|
||||||
@@ -314,8 +358,16 @@ wss.on('connection', (ws) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (message.type === 'worker_connect') {
|
if (message.type === 'worker_connect') {
|
||||||
// Handle worker connection
|
// Authenticate worker — reject if api_key is provided but wrong
|
||||||
const { worker_id, worker_name } = message;
|
const { worker_id, worker_name, api_key } = message;
|
||||||
|
if (api_key && api_key !== process.env.WORKER_API_KEY) {
|
||||||
|
console.warn(`[Security] Worker connection rejected: invalid API key from "${worker_name}"`);
|
||||||
|
ws.close(4001, 'Unauthorized');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Move from browser set to worker set
|
||||||
|
browserClients.delete(ws);
|
||||||
|
workerClients.add(ws);
|
||||||
console.log(`Worker connected: ${worker_name} (${worker_id})`);
|
console.log(`Worker connected: ${worker_name} (${worker_id})`);
|
||||||
|
|
||||||
// Find the database worker ID by name
|
// Find the database worker ID by name
|
||||||
@@ -369,7 +421,8 @@ wss.on('connection', (ws) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
ws.on('close', () => {
|
ws.on('close', () => {
|
||||||
clients.delete(ws);
|
browserClients.delete(ws);
|
||||||
|
workerClients.delete(ws);
|
||||||
// Remove worker from workers map when disconnected (both runtime and db IDs)
|
// Remove worker from workers map when disconnected (both runtime and db IDs)
|
||||||
if (ws.workerId) {
|
if (ws.workerId) {
|
||||||
workers.delete(ws.workerId);
|
workers.delete(ws.workerId);
|
||||||
@@ -382,29 +435,22 @@ wss.on('connection', (ws) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Broadcast to all connected clients
|
// Broadcast to browser clients only (NOT worker agents)
|
||||||
function broadcast(data) {
|
function broadcast(data) {
|
||||||
clients.forEach(client => {
|
browserClients.forEach(client => {
|
||||||
if (client.readyState === WebSocket.OPEN) {
|
if (client.readyState === WebSocket.OPEN) {
|
||||||
client.send(JSON.stringify(data));
|
client.send(JSON.stringify(data));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper function to add log entry to execution
|
// Helper function to add log entry to execution (atomic — no read-modify-write race condition)
|
||||||
async function addExecutionLog(executionId, logEntry) {
|
async function addExecutionLog(executionId, logEntry) {
|
||||||
try {
|
try {
|
||||||
const [execution] = await pool.query('SELECT logs FROM executions WHERE id = ?', [executionId]);
|
await pool.query(
|
||||||
|
"UPDATE executions SET logs = JSON_ARRAY_APPEND(COALESCE(logs, '[]'), '$', CAST(? AS JSON)) WHERE id = ?",
|
||||||
if (execution.length > 0) {
|
[JSON.stringify(logEntry), executionId]
|
||||||
const logs = typeof execution[0].logs === 'string' ? JSON.parse(execution[0].logs) : execution[0].logs;
|
);
|
||||||
logs.push(logEntry);
|
|
||||||
|
|
||||||
await pool.query(
|
|
||||||
'UPDATE executions SET logs = ? WHERE id = ?',
|
|
||||||
[JSON.stringify(logs), executionId]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`[Workflow] Error adding execution log:`, error);
|
console.error(`[Workflow] Error adding execution log:`, error);
|
||||||
}
|
}
|
||||||
@@ -459,7 +505,7 @@ async function authenticateSSO(req, res, next) {
|
|||||||
|
|
||||||
// Store/update user in database
|
// Store/update user in database
|
||||||
try {
|
try {
|
||||||
const userId = generateUUID();
|
const userId = crypto.randomUUID();
|
||||||
await pool.query(
|
await pool.query(
|
||||||
`INSERT INTO users (id, username, display_name, email, groups, last_login)
|
`INSERT INTO users (id, username, display_name, email, groups, last_login)
|
||||||
VALUES (?, ?, ?, ?, ?, NOW())
|
VALUES (?, ?, ?, ?, ?, NOW())
|
||||||
@@ -512,10 +558,11 @@ function applyParams(command, params) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Evaluate a condition string against execution state and params.
|
// Evaluate a condition string against execution state and params.
|
||||||
|
// Uses vm.runInNewContext with a timeout to avoid arbitrary code execution risk.
|
||||||
function evalCondition(condition, state, params) {
|
function evalCondition(condition, state, params) {
|
||||||
try {
|
try {
|
||||||
// eslint-disable-next-line no-new-func
|
const context = vm.createContext({ state, params, promptResponse: state.promptResponse });
|
||||||
return !!new Function('state', 'params', `return !!(${condition})`)(state, params);
|
return !!vm.runInNewContext(condition, context, { timeout: 100 });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -527,6 +574,7 @@ const _executionState = new Map(); // executionId → { params, state }
|
|||||||
|
|
||||||
// Pending prompt resolvers — set when a prompt step is waiting for user input.
|
// Pending prompt resolvers — set when a prompt step is waiting for user input.
|
||||||
const _executionPrompts = new Map(); // executionId → resolve fn
|
const _executionPrompts = new Map(); // executionId → resolve fn
|
||||||
|
const _commandResolvers = new Map(); // commandId → resolve fn (event-driven result delivery)
|
||||||
|
|
||||||
async function executeWorkflowSteps(executionId, workflowId, definition, username, params = {}) {
|
async function executeWorkflowSteps(executionId, workflowId, definition, username, params = {}) {
|
||||||
_executionState.set(executionId, { params, state: {} });
|
_executionState.set(executionId, { params, state: {} });
|
||||||
@@ -724,7 +772,7 @@ async function executeCommandStep(executionId, step, stepNumber, params = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Send command to worker
|
// Send command to worker
|
||||||
const commandId = generateUUID();
|
const commandId = crypto.randomUUID();
|
||||||
|
|
||||||
await addExecutionLog(executionId, {
|
await addExecutionLog(executionId, {
|
||||||
step: stepNumber,
|
step: stepNumber,
|
||||||
@@ -769,52 +817,21 @@ async function executeCommandStep(executionId, step, stepNumber, params = {}) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Wait for a command result using event-driven promise resolution.
|
||||||
|
// The resolver is stored in _commandResolvers and called immediately when
|
||||||
|
// command_result arrives via WebSocket — no DB polling required.
|
||||||
async function waitForCommandResult(executionId, commandId, timeout) {
|
async function waitForCommandResult(executionId, commandId, timeout) {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const startTime = Date.now();
|
const timer = setTimeout(() => {
|
||||||
|
_commandResolvers.delete(commandId);
|
||||||
|
resolve({ success: false, error: 'Command timeout' });
|
||||||
|
}, timeout);
|
||||||
|
|
||||||
const checkInterval = setInterval(async () => {
|
_commandResolvers.set(commandId, (result) => {
|
||||||
try {
|
clearTimeout(timer);
|
||||||
// Check if we've received the command result in logs
|
_commandResolvers.delete(commandId);
|
||||||
const [execution] = await pool.query('SELECT logs FROM executions WHERE id = ?', [executionId]);
|
resolve(result);
|
||||||
|
});
|
||||||
if (execution.length > 0) {
|
|
||||||
const logs = typeof execution[0].logs === 'string' ? JSON.parse(execution[0].logs) : execution[0].logs;
|
|
||||||
// Find the command_sent entry for this commandId, then look for the next command_result after it.
|
|
||||||
// (Worker doesn't echo command_id back, so we can't match by command_id directly.)
|
|
||||||
const sentIdx = logs.findIndex(l => l.command_id === commandId && l.action === 'command_sent');
|
|
||||||
const resultLog = sentIdx >= 0
|
|
||||||
? logs.slice(sentIdx + 1).find(l => l.action === 'command_result')
|
|
||||||
: logs.find(l => l.command_id === commandId && l.action === 'command_result');
|
|
||||||
|
|
||||||
if (resultLog) {
|
|
||||||
clearInterval(checkInterval);
|
|
||||||
resolve({
|
|
||||||
success: resultLog.success,
|
|
||||||
stdout: resultLog.stdout,
|
|
||||||
stderr: resultLog.stderr
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check timeout
|
|
||||||
if (Date.now() - startTime > timeout) {
|
|
||||||
clearInterval(checkInterval);
|
|
||||||
resolve({
|
|
||||||
success: false,
|
|
||||||
error: 'Command timeout'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[Workflow] Error checking command result:', error);
|
|
||||||
clearInterval(checkInterval);
|
|
||||||
resolve({
|
|
||||||
success: false,
|
|
||||||
error: error.message
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, 500); // Check every 500ms
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -846,7 +863,7 @@ app.get('/api/workflows/:id', authenticateSSO, async (req, res) => {
|
|||||||
app.post('/api/workflows', authenticateSSO, async (req, res) => {
|
app.post('/api/workflows', authenticateSSO, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { name, description, definition } = req.body;
|
const { name, description, definition } = req.body;
|
||||||
const id = generateUUID();
|
const id = crypto.randomUUID();
|
||||||
|
|
||||||
console.log('[Workflow] Creating workflow:', name);
|
console.log('[Workflow] Creating workflow:', name);
|
||||||
console.log('[Workflow] Definition:', JSON.stringify(definition, null, 2));
|
console.log('[Workflow] Definition:', JSON.stringify(definition, null, 2));
|
||||||
@@ -886,7 +903,7 @@ app.delete('/api/workflows/:id', authenticateSSO, async (req, res) => {
|
|||||||
|
|
||||||
app.get('/api/workers', authenticateSSO, async (req, res) => {
|
app.get('/api/workers', authenticateSSO, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const [rows] = await pool.query('SELECT * FROM workers ORDER BY name');
|
const [rows] = await pool.query('SELECT id, name, status, last_heartbeat, metadata FROM workers ORDER BY name');
|
||||||
res.json(rows);
|
res.json(rows);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ error: error.message });
|
res.status(500).json({ error: error.message });
|
||||||
@@ -923,7 +940,7 @@ app.post('/api/workers/heartbeat', async (req, res) => {
|
|||||||
app.post('/api/executions', authenticateSSO, async (req, res) => {
|
app.post('/api/executions', authenticateSSO, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { workflow_id, params = {} } = req.body;
|
const { workflow_id, params = {} } = req.body;
|
||||||
const id = generateUUID();
|
const id = crypto.randomUUID();
|
||||||
|
|
||||||
// Get workflow definition
|
// Get workflow definition
|
||||||
const [workflows] = await pool.query('SELECT * FROM workflows WHERE id = ?', [workflow_id]);
|
const [workflows] = await pool.query('SELECT * FROM workflows WHERE id = ?', [workflow_id]);
|
||||||
@@ -997,6 +1014,7 @@ app.get('/api/executions', authenticateSSO, async (req, res) => {
|
|||||||
|
|
||||||
app.delete('/api/executions/:id', authenticateSSO, async (req, res) => {
|
app.delete('/api/executions/:id', authenticateSSO, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
if (!req.user.isAdmin) return res.status(403).json({ error: 'Admin access required' });
|
||||||
await pool.query('DELETE FROM executions WHERE id = ?', [req.params.id]);
|
await pool.query('DELETE FROM executions WHERE id = ?', [req.params.id]);
|
||||||
broadcast({ type: 'execution_deleted', execution_id: req.params.id });
|
broadcast({ type: 'execution_deleted', execution_id: req.params.id });
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
@@ -1115,13 +1133,14 @@ app.get('/api/scheduled-commands', authenticateSSO, async (req, res) => {
|
|||||||
|
|
||||||
app.post('/api/scheduled-commands', authenticateSSO, async (req, res) => {
|
app.post('/api/scheduled-commands', authenticateSSO, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
if (!req.user.isAdmin) return res.status(403).json({ error: 'Admin access required' });
|
||||||
const { name, command, worker_ids, schedule_type, schedule_value } = req.body;
|
const { name, command, worker_ids, schedule_type, schedule_value } = req.body;
|
||||||
|
|
||||||
if (!name || !command || !worker_ids || !schedule_type || !schedule_value) {
|
if (!name || !command || !worker_ids || !schedule_type || !schedule_value) {
|
||||||
return res.status(400).json({ error: 'Missing required fields' });
|
return res.status(400).json({ error: 'Missing required fields' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const id = generateUUID();
|
const id = crypto.randomUUID();
|
||||||
const nextRun = calculateNextRun(schedule_type, schedule_value);
|
const nextRun = calculateNextRun(schedule_type, schedule_value);
|
||||||
|
|
||||||
await pool.query(
|
await pool.query(
|
||||||
@@ -1139,6 +1158,7 @@ app.post('/api/scheduled-commands', authenticateSSO, async (req, res) => {
|
|||||||
|
|
||||||
app.put('/api/scheduled-commands/:id/toggle', authenticateSSO, async (req, res) => {
|
app.put('/api/scheduled-commands/:id/toggle', authenticateSSO, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
if (!req.user.isAdmin) return res.status(403).json({ error: 'Admin access required' });
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const [current] = await pool.query('SELECT enabled FROM scheduled_commands WHERE id = ?', [id]);
|
const [current] = await pool.query('SELECT enabled FROM scheduled_commands WHERE id = ?', [id]);
|
||||||
|
|
||||||
@@ -1157,6 +1177,7 @@ app.put('/api/scheduled-commands/:id/toggle', authenticateSSO, async (req, res)
|
|||||||
|
|
||||||
app.delete('/api/scheduled-commands/:id', authenticateSSO, async (req, res) => {
|
app.delete('/api/scheduled-commands/:id', authenticateSSO, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
if (!req.user.isAdmin) return res.status(403).json({ error: 'Admin access required' });
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
await pool.query('DELETE FROM scheduled_commands WHERE id = ?', [id]);
|
await pool.query('DELETE FROM scheduled_commands WHERE id = ?', [id]);
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
@@ -1178,7 +1199,7 @@ app.post('/api/internal/command', authenticateGandalf, async (req, res) => {
|
|||||||
return res.status(400).json({ error: 'Worker not connected' });
|
return res.status(400).json({ error: 'Worker not connected' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const executionId = generateUUID();
|
const executionId = crypto.randomUUID();
|
||||||
|
|
||||||
await pool.query(
|
await pool.query(
|
||||||
'INSERT INTO executions (id, workflow_id, status, started_by, started_at, logs) VALUES (?, ?, ?, ?, NOW(), ?)',
|
'INSERT INTO executions (id, workflow_id, status, started_by, started_at, logs) VALUES (?, ?, ?, ?, NOW(), ?)',
|
||||||
@@ -1241,26 +1262,6 @@ app.get('/health', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Start server
|
|
||||||
const PORT = process.env.PORT || 8080;
|
|
||||||
const HOST = process.env.HOST || '0.0.0.0';
|
|
||||||
|
|
||||||
initDatabase().then(() => {
|
|
||||||
server.listen(PORT, HOST, () => {
|
|
||||||
console.log(`PULSE Server running on http://${HOST}:${PORT}`);
|
|
||||||
console.log(`Connected to MariaDB at ${process.env.DB_HOST}`);
|
|
||||||
console.log(`Authentication: Authelia SSO`);
|
|
||||||
console.log(`Worker API Key configured: ${process.env.WORKER_API_KEY ? 'Yes' : 'No'}`);
|
|
||||||
});
|
|
||||||
}).catch(err => {
|
|
||||||
console.error('Failed to start server:', err);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// WORKFLOW EXECUTION ENGINE
|
|
||||||
|
|
||||||
// Get execution details with logs
|
// Get execution details with logs
|
||||||
app.get('/api/executions/:id', authenticateSSO, async (req, res) => {
|
app.get('/api/executions/:id', authenticateSSO, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
@@ -1270,7 +1271,7 @@ app.get('/api/executions/:id', authenticateSSO, async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const execution = rows[0];
|
const execution = rows[0];
|
||||||
const parsedLogs = JSON.parse(execution.logs || '[]');
|
const parsedLogs = typeof execution.logs === 'string' ? JSON.parse(execution.logs || '[]') : (execution.logs || []);
|
||||||
const waitingForInput = _executionPrompts.has(req.params.id);
|
const waitingForInput = _executionPrompts.has(req.params.id);
|
||||||
let pendingPrompt = null;
|
let pendingPrompt = null;
|
||||||
if (waitingForInput) {
|
if (waitingForInput) {
|
||||||
@@ -1308,11 +1309,11 @@ app.delete('/api/workers/:id', authenticateSSO, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Send direct command to specific worker (for testing)
|
// Send direct command to specific worker
|
||||||
app.post('/api/workers/:id/command', authenticateSSO, async (req, res) => {
|
app.post('/api/workers/:id/command', authenticateSSO, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { command } = req.body;
|
const { command } = req.body;
|
||||||
const executionId = generateUUID();
|
const executionId = crypto.randomUUID();
|
||||||
const workerId = req.params.id;
|
const workerId = req.params.id;
|
||||||
|
|
||||||
// Create execution record in database
|
// Create execution record in database
|
||||||
@@ -1351,71 +1352,18 @@ app.post('/api/workers/:id/command', authenticateSSO, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// ============================================
|
// Start server
|
||||||
// EXAMPLE WORKFLOW DEFINITIONS
|
const PORT = process.env.PORT || 8080;
|
||||||
// ============================================
|
const HOST = process.env.HOST || '0.0.0.0';
|
||||||
|
|
||||||
// Example 1: Simple command execution
|
initDatabase().then(() => {
|
||||||
const simpleWorkflow = {
|
server.listen(PORT, HOST, () => {
|
||||||
name: "Update System Packages",
|
console.log(`PULSE Server running on http://${HOST}:${PORT}`);
|
||||||
description: "Update all packages on target servers",
|
console.log(`Connected to MariaDB at ${process.env.DB_HOST}`);
|
||||||
steps: [
|
console.log(`Authentication: Authelia SSO`);
|
||||||
{
|
console.log(`Worker API Key configured: ${process.env.WORKER_API_KEY ? 'Yes' : 'No'}`);
|
||||||
name: "Update package list",
|
});
|
||||||
type: "execute",
|
}).catch(err => {
|
||||||
targets: ["all"],
|
console.error('Failed to start server:', err);
|
||||||
command: "apt update"
|
process.exit(1);
|
||||||
},
|
});
|
||||||
{
|
|
||||||
name: "User Approval",
|
|
||||||
type: "prompt",
|
|
||||||
message: "Packages updated. Proceed with upgrade?",
|
|
||||||
options: ["Yes", "No"]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Upgrade packages",
|
|
||||||
type: "execute",
|
|
||||||
targets: ["all"],
|
|
||||||
command: "apt upgrade -y",
|
|
||||||
condition: "promptResponse === 'Yes'"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
};
|
|
||||||
|
|
||||||
// Example 2: Complex workflow with conditions
|
|
||||||
const backupWorkflow = {
|
|
||||||
name: "Backup and Verify",
|
|
||||||
description: "Create backup and verify integrity",
|
|
||||||
steps: [
|
|
||||||
{
|
|
||||||
name: "Create backup",
|
|
||||||
type: "execute",
|
|
||||||
targets: ["all"],
|
|
||||||
command: "tar -czf /tmp/backup-$(date +%Y%m%d).tar.gz /opt/pulse-worker"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Wait for backup",
|
|
||||||
type: "wait",
|
|
||||||
duration: 5000
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Verify backup",
|
|
||||||
type: "execute",
|
|
||||||
targets: ["all"],
|
|
||||||
command: "tar -tzf /tmp/backup-*.tar.gz > /dev/null && echo 'Backup OK' || echo 'Backup FAILED'"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Cleanup decision",
|
|
||||||
type: "prompt",
|
|
||||||
message: "Backup complete. Delete old backups?",
|
|
||||||
options: ["Yes", "No", "Cancel"]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Cleanup old backups",
|
|
||||||
type: "execute",
|
|
||||||
targets: ["all"],
|
|
||||||
command: "find /tmp -name 'backup-*.tar.gz' -mtime +7 -delete",
|
|
||||||
condition: "promptResponse === 'Yes'"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
};
|
|
||||||
|
|||||||
Reference in New Issue
Block a user