diff --git a/bin/docker-plugin.js b/bin/docker-plugin.js index 7b6b207..18e2e2d 100644 --- a/bin/docker-plugin.js +++ b/bin/docker-plugin.js @@ -62,6 +62,7 @@ const docker = new Docker(dockerOpts) const ENTRYPOINT_PATH = process.env['ENTRYPOINT_PATH'] || '/cronicle.sh' const cname = 'cronicle-' + (process.env['JOB_ID'] || process.pid) let imageName = process.env['IMAGE'] || 'alpine' +let network = process.env['NETWORK'] let script = process.env['SCRIPT'] ?? "#!/bin/sh\necho 'No script specified'" const autoPull = !!parseInt(process.env['PULL_IMAGE']) const autoRemove = !parseInt(process.env['KEEP_CONTAINER']) @@ -140,6 +141,8 @@ if (!keepEntrypoint) { createOptions.WorkingDir = path.dirname(ENTRYPOINT_PATH) } +if(network) createOptions.HostConfig['NetworkMode'] = network + // ----------------RUNNING CONTAINER -------- // diff --git a/bin/manager b/bin/manager index 57c8cd4..8b3c771 100755 --- a/bin/manager +++ b/bin/manager @@ -3,6 +3,8 @@ HOMEDIR="$(dirname "$(cd -- "$(dirname "$(readlink -f "$0")")" && (pwd -P 2>/dev usage() { echo "USAGE: ./manager [ --port 3012 ] [ --storage /path/to/storage.json ] [ --key secreKey ] [ --color ] " + echo " [ --reset ] # set current host as manager" + echo " [ --cluster "server1,server2" ] # add extra workers on setup" exit 1 } @@ -15,6 +17,8 @@ while (( "$#" )); do --key ) shift; key=$1 ;; --storage ) shift; storage=$1 ;; --sqlite ) shift; sqlite=$1 ;; + --reset ) shift; reset=1 ;; + --cluster ) shift; cluster=$1 ;; --help ) usage ;; -*) echo "invalid parameter: $1"; usage ;; esac @@ -44,12 +48,29 @@ if [[ $key ]]; then echo "Custom secret key set: *****" fi +if [[ $cluster ]]; then + export CRONICLE_cluster="$cluster" + echo "Following nodes will be added on setup: $cluster" +fi + # pull data from git if needed # if [ ! -d data/global ] && [ -v GIT_REPO ]; then # git clone $GIT_REPO $HOMEDIR/data # fi -$HOMEDIR/bin/control.sh setup +# check for custom nodejs binary +if [ -f $HOMEDIR/nodejs/bin/node ]; then + export PATH="$HOMEDIR/nodejs/bin:$PATH" + echo "using custom node version: $(node -v)" +fi + + +# setup storage OR make current host the primary manager if needed +if [ "$reset" = 1 ]; then + node $HOMEDIR/bin/storage-cli.js reset +else $HOMEDIR/bin/control.sh setup +fi + if [ -f "$HOMEDIR/logs/cronicled.pid" ]; then echo 'removing old pid file' @@ -63,12 +84,6 @@ if [ -f "$HOMEDIR/data/backup.json" ]; then rm "$HOMEDIR/data/backup.json" fi -# check for custom nodejs binary -if [ -f $HOMEDIR/nodejs/bin/node ]; then - export PATH="$HOMEDIR/nodejs/bin:$PATH" - echo "using custom node version: $(node -v)" -fi - BINARY="node $HOMEDIR/lib/main.js" # check if bundle exist if [ -f "$HOMEDIR/bin/cronicle.js" ]; then diff --git a/bin/manager.bat b/bin/manager.bat index 6e209a1..713a006 100644 --- a/bin/manager.bat +++ b/bin/manager.bat @@ -43,8 +43,22 @@ if /I "%1"=="--port" ( echo Using sqlite as storage: %~f2 shift shift +) else if /I "%1"=="--cluster" ( + if "%2"=="" ( + echo Missing cluster value. Specify comma-separatd hostnames + exit + ) + set CRONICLE_cluster=%2 + echo These servers will be added on setup: %2 + shift + shift +) else if /I "%1"=="--reset" ( + set CRONICLE_RESET=1 + shift ) else if /I "%1"=="--help" ( - echo Usage: .\manager [--port port] [ --storage /path/to/storage.json] + echo Usage: .\manager [--port port] [ --storage /path/to/storage.json] + echo [ --reset ] # make current host the manager + echo [ --cluster "server1,server2"] # add extra workers on setup shift ) else (exit) @@ -59,11 +73,12 @@ IF EXIST "%~dp0..\nodejs\node.exe" ( SET "PATH=%~dp0..\nodejs;%PATH%" ) -node .\storage-cli.js setup - -if not "%~1"=="" ( - set "CRONICLE_WebServer__http_port=%1" - echo CRONICLE_http_port is set to %1 +REM setup or reset manager +if "%CRONICLE_RESET%"=="1" ( + node .\storage-cli.js reset + echo Croncile manager was reset to current host +) else ( + node .\storage-cli.js setup ) node .\cronicle.js --manager --echo --foreground --color diff --git a/bin/sshx-plugin.js b/bin/sshx-plugin.js index d991f0d..980fd6d 100644 --- a/bin/sshx-plugin.js +++ b/bin/sshx-plugin.js @@ -9,7 +9,10 @@ const fs = require('fs') // read job info from stdin (sent by Cronicle engine) const job = JSON.parse(fs.readFileSync(process.stdin.fd)) -const print = (text) => process.stdout.write(text + EOL) +let pref = '' +if(job.params.annotate) pref = `[${new Date().toISOString()}] ` + +const print = (text) => process.stdout.write(pref + text + EOL) const printInfo = (text) => process.stdout.write(`[INFO] \x1b[32m${text}\x1b[0m` + EOL) const printWarning = (text) => process.stdout.write(`[INFO] \x1b[33m${text}\x1b[0m` + EOL) const printError = (text) => process.stdout.write(`\x1b[31m${text}\x1b[0m` + EOL) diff --git a/bin/storage-cli.js b/bin/storage-cli.js index 6e7ed85..7e44a99 100755 --- a/bin/storage-cli.js +++ b/bin/storage-cli.js @@ -10,6 +10,7 @@ var os = require('os'); var fs = require('fs'); var async = require('async'); var bcrypt = require('bcrypt-node'); +var dns = require('dns') var Args = require('pixl-args'); var Tools = require('pixl-tools'); @@ -155,6 +156,27 @@ for (var env_key in process.env) { } } +// helper function to resolve IPs for CRONICLE_cluster +const getIPsForHostnames = async (hostnames) => { + const ipPromises = hostnames.map(hostname => { + return new Promise((resolve, reject) => { + dns.lookup(hostname.trim(), (err, ip) => { + if (err) resolve({ hostname: hostname.trim(), ip: null }); + else resolve({ hostname: hostname.trim(), ip }); + }); + }); + }); + + try { + // Wait for all DNS lookups to finish + const ips = await Promise.all(ipPromises); + return ips; + } catch (error) { + console.error("Error fetching IP addresses:", error); + return []; + } +}; + // construct standalone storage server var storage = new StandaloneStorage(config.Storage, function (err) { if (err) throw err; @@ -188,12 +210,19 @@ var storage = new StandaloneStorage(config.Storage, function (err) { // make sure this is only run once // changing exit code to 0, so it won't break docker entry point - storage.get('global/users', function (err) { + storage.get('global/users', async function (err) { if (!err) { print("Storage has already been set up. There is no need to run this command again.\n\n"); process.exit(0); } + if(process.env['CRONICLE_cluster']) { + let servers = await getIPsForHostnames(process.env['CRONICLE_cluster'].split(',')) + servers.forEach(server =>{ + setup.storage.push(["listPush", "global/servers", server]) + }) + } + async.eachSeries(setup.storage, function (params, callback) { verbose("Executing: " + JSON.stringify(params) + "\n"); @@ -230,6 +259,34 @@ var storage = new StandaloneStorage(config.Storage, function (err) { }); break; + case 'reset': + + let newGroup = { regexp: '^(' + Tools.escapeRegExp(hostname) + ')$' } + + storage.listFindUpdate('global/server_groups', { id: "maingrp" }, newGroup, function (err) { + if (err) throw err; + print(`Main group regex is set to [ ${newGroup.regexp} ]`); + print("\n"); + + storage.listFind("global/servers", { hostname: hostname }, function (err, item) { + // already exist? + if (item) { + print(`${hostname} already exist in server list\n`); + storage.shutdown(function () { process.exit(1); }); + } + else { + storage.listPush("global/servers", { hostname: hostname, ip: ip }, function (err) { + if (err) throw err; + print(`Added ${hostname} to server list (remove old servers from UI as needed)\n`); + storage.shutdown(function () { process.exit(0); }); + }) + } + + }) + + }); + break; + case 'admin': // create or replace admin account // Usage: ./storage-cli.js admin USERNAME PASSWORD [EMAIL] diff --git a/htdocs/css/style.css b/htdocs/css/style.css index c25a957..3a8683d 100644 --- a/htdocs/css/style.css +++ b/htdocs/css/style.css @@ -650,7 +650,6 @@ td.table_label { tr.focus td { animation: 4s focus; - } body.dark span.color_label.gray { @@ -749,6 +748,10 @@ td.table_label { } span.red2 { + background-color: #d9667a; + } + + body.dark span.red2 { background-color: #ac394d; } @@ -762,7 +765,7 @@ td.table_label { } .wflog.grid-item { - border: 1px solid #ccc; + border: 0.1px solid rgba(48,48,48,1); padding: 10px; word-wrap: break-word; overflow: hidden; @@ -772,4 +775,315 @@ td.table_label { .wflog.grid-title { /* font-weight: bold; */ font-size: 1.6em; - } \ No newline at end of file + } + + .flex-container { + display: flex; + white-space: nowrap; + flex-wrap: nowrap; + justify-content: space-between; + /* align-items: center; */ + } + + .flex-container.widget { + align-items: center; + justify-content:space-between; + } + + .flex-container-stats { + display: flex; + white-space: nowrap; + flex-wrap: wrap; + justify-content: space-around; + /* align-items: center; */ + } + + .upcoming.grid-container { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(290px, 1fr)); + gap: 12px; + padding: 10px; + direction: ltr; + } + + .upcoming.schedule.grid-container { + grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); + padding-top: 0px; + gap: 14px; + + } + + .stats.grid-container { + display: grid; + background-color: var(--box-background-color); + border: 1px solid var(--border-color); + border-right: 0px; + /* grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); */ + grid-template-columns: repeat(10, auto); + align-items: center; + gap: 0px; + padding: 0px; + direction: ltr; + /* background-color: #80808005; */ + /* border-radius: 6px; */ + + } + + .stats.grid-item { + border: 0px; + border-right: 1px solid var(--border-color); + /* border-top: 1px solid var(--border-color); */ + /* border-bottom: 1px solid var(--border-color); */ + background-color: var(--box-background-color); + /* background-color: #FAFAFA; */ + /* transition: border-width 0.3s; */ + padding: 6px; + border-radius: 0px; + /* border-top-left-radius: 6px; */ + /* border-bottom-left-radius: 6px; */ + word-wrap: break-word; + white-space: wrap; + overflow: hidden; + /* white-space: normal; */ + direction: ltr; + /* box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); */ + /* vertical-align: middle; */ + } + + /* body.dark .stats.grid-item { + border: 1px solid #cccccc26; + } + body.dark .stats.grid-item { + box-shadow: 0px 4px 8px 0px rgba(0,0,0,0.75); + } */ + + body.dark .stats.grid-item:hover { + color: #FFF; + background-color: var(--background-color); + } + + .stats.grid-item:hover { + /* border: 1px solid #cccccc26; */ + background-color: #cccccc26; + } + + /* body.dark .stats.grid-item { + background-color: initial; + } + body.dark .stats.grid-item:hover { + color: #FFF; + background-color: initial; + } + .stats.grid-item:hover { + background-color: rgba(128,128,128, 0.1); + } */ + + @media (max-width: 1200px) { + .stats.grid-container { + grid-template-columns: repeat(5, 1fr); + } + } + + .job-details.grid-container { + display: grid; + /* grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); */ + grid-template-columns: repeat(7, auto); + align-items: center; + gap: 0px; + padding: 0px; + /* padding-bottom: 12px; */ + margin-top: 12px; + margin-bottom: 12px; + background-color: var(--background-color); + border: 1px solid var( --border-color); + border-bottom: 0px; + border-left: 0px; + border-radius: 0px; + } + + .job-details.grid-container.running { + grid-template-columns: repeat(6, auto); + } + + body.dark .job-details.grid-container { + background-color: var(--background-color); + } + + .job-details.grid-item { + display: flex; + align-items: baseline; + justify-content: space-evenly; + border-bottom: 1px solid var( --border-color); + border-left: 1px solid var( --border-color); + background-color: var(--background-color); + flex-wrap: wrap; + padding-top: 8px; + /* border-radius: 6px; */ + } + + body.dark .job-details.grid-item:hover div.info_value { + color: white; + text-shadow: none; + } + + .upcoming.grid-item { + border: 1px solid #ccccccde; + /* transition: border-width 0.3s; */ + padding: 8px; + border-radius: 12px; + word-wrap: break-word; + white-space: wrap; + overflow: hidden; + /* white-space: normal; */ + direction: ltr; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + /* vertical-align: middle; */ + } + + .upcoming.grid-item.focus { + animation: 4s focus; + } + + + body.dark .upcoming.schedule.grid-item { + background-color: #44444444 + /* color: #fff */ + } + + .upcoming.schedule.grid-item { + /* background-color: #fafafa */ + background-color: var(--background-color); + /* color: #fff */ + } + + .event-error { + color: #bb4444cc; + } + .event-success { + color: #44bb4460; + } + .event-warning { + color: #dbdb34bb; + } + .event-none { + color: #44444400; + } + + .section-divider { + grid-column: 1 / -1; + text-align: left; + /* color: initial; */ + font-size: 1.4em; + font-weight: bold; + } + + .upcoming.warning.grid-item { + background-color: #f5e05870;; + } + + .upcoming.error.grid-item { + background-color: pink; + } + + body.dark .upcoming.grid-item { + box-shadow: 0px 4px 8px 0px rgba(0,0,0,0.75); + } + + .upcoming.minute.grid-item { + background-color: #b7ffb770 + } + + body.dark .upcoming.minute.grid-item { + background-color: #2c702cDD; + transition: background-color 3s; + color: #d3c7c7 + } + + .upcoming.grid-item a { + font-size: 1.1em; + } + + body.dark .upcoming.grid-item a { + color: #3a639b; + } + + body.dark .upcoming.minute.grid-item a { + color: #d3c7c7 !important; + font-size: 1.2em; + } + + body.dark .upcoming.soon.grid-item a { + color: #d3c7c7 !important; + font-size: 1.2em; + } + + .upcoming.minute.grid-item a { + color: rgb(84, 84, 84) !important; + font-size: 1.2em; + } + + .upcoming.soon.grid-item a { + color: rgb(84, 84, 84) !important; + font-size: 1.2em; + } + + .upcoming.soon.grid-item { + background-color: #f5e05870; + } + + body.dark .upcoming.soon.grid-item { + background-color: rgba(255, 115, 0, 0.7); + color: #d3c7c7; + } + + .upcoming.grid-item:hover { + border-color: rgba(128,128,128,0.5) + /* background-color:rgb(0, 255, 0) */ + } + + body.dark .upcoming.grid-item:hover:not(.schedule) { + /* border-color: rgba(46, 38, 12, 0.4); */ + color: #FFF; + } + + .event-action { + font-weight: bold; + color:#817c7c; + text-decoration: none; + } + + .event-action:hover { + text-decoration: underline; + } + + .search { + width : 100px; + background-color:gray; + -webkit-transition: all 0.3s linear; + -moz-transition: all 0.3s linear; + border-radius : 15px; + outline:none; + } + + body.dark .event-action:hover { + font-weight: bold; + color:#555; + } + + body.dark .upcoming.grid-item { + border: 1px solid #cccccc26; + } + + .upcoming.grid-title { + /* font-weight: bold; */ + font-size: 1.2em; + } + + .event-search { + padding-left:30px; + border:0.3px solid #ccccccaa; + border-radius:8px; + font-size: 1.2em; + } + + diff --git a/htdocs/js/app.js b/htdocs/js/app.js index 1c9f46b..4ba5340 100644 --- a/htdocs/js/app.js +++ b/htdocs/js/app.js @@ -819,6 +819,18 @@ function get_pretty_int_list(arr, ranges) { return arr.slice(0, arr.length - 1).join(', ') + ' and ' + arr[ arr.length - 1 ]; } +function summarize_event_timing_short(timing) { + if(!timing) return "On Demand" + let type = 'Hourly' + let total = (timing.minutes || []).length || 60 + if(timing.hours) { total = total*(timing.hours.length || 24); type = 'Daily'} else { total = total*24} + if(timing.weekdays) { total = total*(timing.weekdays.length || 1); type = 'Weekly'} + if(timing.days) { total = total*(timing.days.length || 1); type = 'Monthly'} + if(timing.months) { total = total*(timing.months.length || 1); type = 'Yearly'} + if(timing.years) { total = total*(timing.years.length || 1); type = 'Custom'} + return `${type} :: ${total}` +} + function summarize_event_timing(timing, timezone, extra) { // summarize event timing into human-readable string if (!timing && extra) { diff --git a/htdocs/js/pages/Base.class.js b/htdocs/js/pages/Base.class.js index 192f69a..1a8bc59 100644 --- a/htdocs/js/pages/Base.class.js +++ b/htdocs/js/pages/Base.class.js @@ -60,18 +60,36 @@ Class.subclass(Page, "Page.Base", { return '
- | + | ` } @@ -956,13 +1071,24 @@ Class.subclass(Page.Base, "Page.Schedule", { update_job_last_runs: function () { // update last run state for all jobs, called when state is updated if (!app.state.jobCodes) return; + let isGrid = app.getPref('event_view') === 'grid' || app.getPref('event_view') == 'gridall' for (var event_id in app.state.jobCodes) { - var last_code = app.state.jobCodes[event_id]; - var status_html = last_code ? ' Error' : ' Success'; - if (last_code == 255) status_html = ' Warning' - // this.div.find('#ss_' + event_id).html(status_html); - document.getElementById('ss_' + event_id).innerHTML = status_html + let last_code = app.state.jobCodes[event_id]; + let status_html; + + if (isGrid) { + status_html = last_code ? 'event-error' : 'event-success' + if (last_code == 255) status_html = 'event-warning' + status_html = `` + } + else { + status_html = last_code ? ' Error' : ' Success'; + if (last_code == 255) status_html = ' Warning' + } + + let statusIcon = document.getElementById('ss_' + event_id) + if (statusIcon) statusIcon.innerHTML = status_html } }, @@ -990,6 +1116,14 @@ Class.subclass(Page.Base, "Page.Schedule", { this.gosub_events(this.args); }, + change_event_view: function (view_type) { + // if( ['Grid', 'Details', 'Grid-All'].indexOf(view_type) < 0 ) view_type = 'Details' + if (['details', 'grid', 'gridall'].indexOf(view_type) < 0) view_type = 'details' + app.setPref('event_view', view_type) + this.gosub_events(this.args); + + }, + toggle_group_by: function () { let args = this.args args.collapse ^= true @@ -1524,7 +1658,7 @@ Class.subclass(Page.Base, "Page.Schedule", { } // check for update delete this.old_event; - if (event.secret_value && typeof event.secret_value === 'string' ) { + if (event.secret_value && typeof event.secret_value === 'string') { delete event.secret_value $('#fe_ee_secret').val('').attr('placeholder', '[*****]') } @@ -1734,9 +1868,9 @@ Class.subclass(Page.Base, "Page.Schedule", { } // Secret - let sph = event.secret_preview ? '[' + event.secret_preview + ']' : ''; + let sph = event.secret_preview ? '[' + event.secret_preview + ']' : ''; html += get_form_table_row('Secret', ``); - html += get_form_table_caption("Specify KEY=VALUE pairs to be mounted as env variables (to this job process)"); + html += get_form_table_caption("Specify KEY=VALUE pairs to be mounted as env variables (to this job process). When using Docker plugin, KEY should be prefixed with DOCKER_ to pass custom variable to docker container. When using SSH plugin, KEY should be prefixed with SSH_ to pass to remote machine."); html += get_form_table_spacer(); // max children @@ -2537,13 +2671,13 @@ Class.subclass(Page.Base, "Page.Schedule", { this.refresh_plugin_params(); }, - - setScriptEditor: function() { - + + setScriptEditor: function () { + let params = this.event.params || {} let el = document.getElementById("fe_ee_pp_script") - if(!el) return + if (!el) return let privs = app.user.privileges; let canEdit = privs.admin || privs.edit_events || privs.create_events; @@ -2585,45 +2719,45 @@ Class.subclass(Page.Base, "Page.Schedule", { gutters: [gutter], lint: lint, extraKeys: { - "F11": (cm) => cm.setOption("fullScreen", !cm.getOption("fullScreen")), - "Esc": (cm) => cm.getOption("fullScreen") ? cm.setOption("fullScreen", false) : null, - "Ctrl-/": (cm) => cm.execCommand('toggleComment') - } - }); + "F11": (cm) => cm.setOption("fullScreen", !cm.getOption("fullScreen")), + "Esc": (cm) => cm.getOption("fullScreen") ? cm.setOption("fullScreen", false) : null, + "Ctrl-/": (cm) => cm.execCommand('toggleComment') + } + }); editor.on('change', (cm) => { el.value = cm.getValue() }) // syntax selector - document.getElementById("fe_ee_pp_lang").addEventListener("change", function(){ + document.getElementById("fe_ee_pp_lang").addEventListener("change", function () { let ln = this.options[this.selectedIndex].value; editor.setOption("gutters", ['']); editor.setOption("lint", false) - if(ln == 'java') {ln = 'text/x-java'} - if(ln == 'scala') {ln = 'text/x-scala'} - if(ln == 'csharp') {ln = 'text/x-csharp'} + if (ln == 'java') { ln = 'text/x-java' } + if (ln == 'scala') { ln = 'text/x-scala' } + if (ln == 'csharp') { ln = 'text/x-csharp' } if (ln == 'sql') { ln = 'text/x-sql' } if (ln == 'dockerfile') { ln = 'text/x-dockerfile' } if (ln == 'toml') { ln = 'text/x-toml' } - if (ln == 'json') { + if (ln == 'json') { ln = 'application/json' editor.setOption("lint", CodeMirror.lint.json) - } + } if (ln == 'props') { ln = 'text/x-properties' } if (ln == 'yaml') { ln = 'text/x-yaml' editor.setOption("gutters", ['CodeMirror-lint-markers']); editor.setOption("lint", CodeMirror.lint.yaml) } - editor.setOption("mode", ln); + editor.setOption("mode", ln); }); // theme - document.getElementById("fe_ee_pp_theme").addEventListener("change", function(){ + document.getElementById("fe_ee_pp_theme").addEventListener("change", function () { var thm = this.options[this.selectedIndex].value; - if(thm === 'default' && app.getPref('theme') === 'dark') thm = 'gruvbox-dark'; - if(thm === 'light') thm = 'default'; + if (thm === 'default' && app.getPref('theme') === 'dark') thm = 'gruvbox-dark'; + if (thm === 'light') thm = 'default'; editor.setOption("theme", thm); }); }, @@ -2725,7 +2859,7 @@ Class.subclass(Page.Base, "Page.Schedule", { refresh_plugin_params: function () { // redraw plugin param area after change $('#d_ee_plugin_params').html(this.get_plugin_params_html()); - this.setScriptEditor(); + this.setScriptEditor(); }, get_random_event: function () { diff --git a/package.json b/package.json index 7d27132..1127cb0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cronicle-edge", - "version": "1.8.2", + "version": "1.9.0", "description": "A simple, distributed task scheduler and runner with a web based UI.", "author": "Joseph Huckaby |