diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..52fde4a --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +/node_modules +/work +/logs +/queue +/data +/conf + diff --git a/.npmignore b/.npmignore new file mode 100644 index 0000000..b3c056b --- /dev/null +++ b/.npmignore @@ -0,0 +1,7 @@ +.gitignore +/node_modules +/work +/logs +/queue +/data +/conf diff --git a/Docker/Dockerfile b/Docker/Dockerfile new file mode 100644 index 0000000..c510fd9 --- /dev/null +++ b/Docker/Dockerfile @@ -0,0 +1,43 @@ +# build: docker build -t cronicle:test -f Dockerfile --build-arg branch=master --build-arg echo=1 . + +FROM mcr.microsoft.com/powershell:alpine-3.11 +RUN apk add --no-cache nodejs npm git tini util-linux bash neovim openssl krb5 procps coreutils curl acl highlight jq + +# optional java 15 +# RUN apk add openjdk15-jdk --repository=http://dl-cdn.alpinelinux.org/alpine/edge/testing +# ENV JAVA_HOME=/usr/lib/jvm/java-15-openjdk +# ENV PATH="$JAVA_HOME/bin:${PATH}" + +# optional mc s3 client (+20MB) +# RUN wget -O /usr/bin/mc http://dl.min.io/client/mc/release/linux-amd64/mc && chmod +x /usr/bin/mc + +# optional - set up custom CA cert +# COPY myCA.cer /usr/local/share/ca-certificates/myCA.crt +# RUN apk add --no-cache ca-certificates +# RUN update-ca-certificates +# ENV NODE_EXTRA_CA_CERTS=/usr/local/share/ca-certificates/myCA.crt + +ENV CRONICLE_foreground=1 +ENV CRONICLE_echo=1 +ENV TZ=America/New_York +ENV EDITOR=nvim + +ENV PATH "/opt/cronicle/bin:${PATH}" + +# non root user for shell plugin +ARG CRONICLE_UID=1007 +ARG CRONICLE_GID=1099 +RUN addgroup cronicle --gid $CRONICLE_GID && adduser -D -h /opt/cronicle -u $CRONICLE_UID -G cronicle cronicle + +# protect sensitive folders +RUN mkdir -p /jars /opt/cronicle/data /opt/cronicle/config && chmod 700 /opt/cronicle/data /opt/cronicle/config + +WORKDIR /opt/cronicle +ARG echo +RUN echo $echo +ARG branch=main +RUN git clone https://github/cronicle-edge/cronicle-edge.git /opt/cronicle +RUN git checkout ${branch} +RUN npm audit fix --force; npm install; node bin/build dist + +ENTRYPOINT ["/sbin/tini", "--"] diff --git a/Docker/LocalCluster.yaml b/Docker/LocalCluster.yaml new file mode 100644 index 0000000..0385560 --- /dev/null +++ b/Docker/LocalCluster.yaml @@ -0,0 +1,70 @@ +# Docker swarm demo for cronicle cluster + +# sample secret: +# docker secret create cronicle.env /path/to/cronicle.env + +# start cluster: docker stack deploy --compose-file LocalCluster.yaml cron_stack +# check status: docker stack ps cron_stack +# check logs on error: docker service logs cron_stack_manager1 +# remove cluster: docker stack rm cron_stack + +version: '3.8' +services: + worker1: + image: cronicle:latest + hostname: worker1 + ports: + - "3013:3012" + networks: + - cw + deploy: + replicas: 1 + entrypoint: worker + secrets: + - cronicle.env + environment: + - "CRONICLE_secret_key=cronicle_demo" + - "ENV_FILE=/run/secrets/cronicle.env" + + worker2: + image: cronicle:latest + hostname: worker2 + ports: + - "3014:3012" + networks: + - cw + deploy: + replicas: 1 + entrypoint: worker + secrets: + - cronicle.env + environment: + - "CRONICLE_secret_key=cronicle_demo" + - "ENV_FILE=/run/secrets/cronicle.env" + + manager1: + image: cronicle:latest + hostname: manager1 + ports: + - "3012:3012" + networks: + - cw + deploy: + replicas: 1 + entrypoint: manager + secrets: + - cronicle.env + environment: + - "CRONICLE_secret_key=cronicle_demo" + - "ENV_FILE=/run/secrets/cronicle.env" + +networks: + cw: + driver: overlay + attachable: true + +secrets: + cronicle.env: + external: true + +# once manager1 is up and running go to Add Server menu and add worker1 and worker2 \ No newline at end of file diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..48cad40 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,11 @@ +# License + +The MIT License (MIT) + +Copyright (c) 2015 - 2018 Joseph Huckaby + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/bin/build-tools.js b/bin/build-tools.js new file mode 100644 index 0000000..bd80fc1 --- /dev/null +++ b/bin/build-tools.js @@ -0,0 +1,342 @@ +#!/usr/bin/env node + +// Build Tools Module -- included by build.js. +// Copies, symlinks and compresses files into the right locations. +// Can also compact & bundle JS/CSS together for distribution. +// Copyright (c) 2015 Joseph Huckaby, MIT License. + +var path = require('path'); +var fs = require('fs'); +var zlib = require('zlib'); +var util = require('util'); +var os = require('os'); +var cp = require('child_process'); + +var mkdirp = require('mkdirp'); +var async = require('async'); +var glob = require('glob'); +var UglifyJS = require("uglify-js"); +var Tools = require('pixl-tools'); + +var fileStatSync = function(file) { + // no-throw version of fs.statSync + var stats = null; + try { stats = fs.statSync(file); } + catch (err) { return false; } + return stats; +}; + +var fileExistsSync = function(file) { + // replacement for fs.existsSync which is being removed + return !!fileStatSync(file); +}; + +var symlinkFile = exports.symlinkFile = function symlinkFile( old_file, new_file, callback ) { + // create symlink to file + // Note: 'new_file' is the object that will be created on the filesystem + // 'old_file' should already exist, and is the file being pointed to + if (new_file.match(/\/$/)) new_file += path.basename(old_file); + + // if target exists and is not a symlink, skip this + try { + var stats = fs.lstatSync(new_file); + if (!stats.isSymbolicLink()) return callback(); + } + catch (e) {;} + + console.log( "Symlink: " + old_file + " --> " + new_file ); + + try { fs.unlinkSync( new_file ); } + catch (e) {;} + + if (!fileExistsSync( path.dirname(new_file) )) { + mkdirp.sync( path.dirname(new_file) ); + } + + // fs.symlink takes a STRING (not a file path per se) as the old (existing) file, + // and it needs to be relative from new_file. So we need to resolve some things. + var sym_new = path.resolve( new_file ); + var sym_old = path.relative( path.dirname(sym_new), path.resolve(old_file) ); + + fs.symlink( sym_old, sym_new, callback ); +}; + +var copyFile = exports.copyFile = function copyFile( old_file, new_file, callback ) { + // copy file + if (new_file.match(/\/$/)) new_file += path.basename(old_file); + console.log( "Copy: " + old_file + " --> " + new_file ); + + try { fs.unlinkSync( new_file ); } + catch (e) {;} + + if (!fileExistsSync( path.dirname(new_file) )) { + mkdirp.sync( path.dirname(new_file) ); + } + + var inp = fs.createReadStream(old_file); + var outp = fs.createWriteStream(new_file); + inp.on('end', callback ); + inp.pipe( outp ); +}; + +var copyFiles = exports.copyFiles = function copyFiles( src_spec, dest_dir, callback ) { + // copy multiple files to destination directory using filesystem globbing + dest_dir = dest_dir.replace(/\/$/, ''); + + glob(src_spec, {}, function (err, files) { + // got files + if (files && files.length) { + async.eachSeries( files, function(src_file, callback) { + // foreach file + var stats = fileStatSync(src_file); + if (stats && stats.isFile()) { + copyFile( src_file, dest_dir + '/', callback ); + } + else callback(); + }, callback ); + } // got files + else { + callback( err || new Error("No files found matching: " + src_spec) ); + } + } ); +}; + +var compressFile = exports.compressFile = function compressFile( src_file, gz_file, callback ) { + // gzip compress file + console.log( "Compress: " + src_file + " --> " + gz_file ); + + if (fileExistsSync(gz_file)) { + fs.unlinkSync( gz_file ); + } + + var gzip = zlib.createGzip(); + var inp = fs.createReadStream( src_file ); + var outp = fs.createWriteStream( gz_file ); + inp.on('end', callback ); + inp.pipe(gzip).pipe(outp); +}; + +var copyCompress = exports.copyCompress = function copyCompress( old_file, new_file, callback ) { + // copy file and create gzip version as well + if (new_file.match(/\/$/)) new_file += path.basename(old_file); + + copyFile( old_file, new_file, function(err) { + if (err) return callback(err); + + // Make a compressed copy, so node-static will serve it up to browsers + compressFile( old_file, new_file + '.gz', callback ); + } ); +}; + +var symlinkCompress = exports.symlinkCompress = function symlinkCompress( old_file, new_file, callback ) { + // symlink file and create gzip version as well + if (new_file.match(/\/$/)) new_file += path.basename(old_file); + + symlinkFile( old_file, new_file, function(err) { + if (err) return callback(err); + + // Make a compressed copy, so node-static will serve it up to browsers + compressFile( old_file, new_file + '.gz', callback ); + } ); +}; + +var deleteFile = exports.deleteFile = function deleteFile( file, callback ) { + // delete file + console.log( "Delete: " + file ); + + if (fileExistsSync(file)) { + fs.unlink( file, callback ); + } + else callback(); +}; + +var deleteFiles = exports.deleteFiles = function deleteFiles( spec, callback ) { + // delete multiple files using filesystem globbing + glob(spec, {}, function (err, files) { + // got files + if (files && files.length) { + async.eachSeries( files, function(file, callback) { + // foreach file + deleteFile( file, callback ); + }, callback ); + } // got files + else { + callback( err ); + } + } ); +}; + +var chmodFiles = exports.chmodFiles = function chmodFiles( mode, spec, callback ) { + // chmod multiple files to specified mode using filesystem globbing + glob(spec, {}, function (err, files) { + // got files + if (files && files.length) { + async.eachSeries( files, function(file, callback) { + // foreach file + fs.chmod( file, mode, callback ); + }, callback ); + } // got files + else { + callback( err || new Error("No files found matching: " + spec) ); + } + } ); +}; + +var copyDir = exports.copyDir = function copyDir( src_dir, dest_dir, exclusive, callback ) { + // recursively copy dir and contents, optionally with exclusive mode + // symlinks are followed, and the target is copied instead + var src_spec = src_dir + '/*'; + + // exclusive means skip if dest exists (do not replace) + if (exclusive && fileExistsSync(dest_dir)) return callback(); + + mkdirp.sync( dest_dir ); + + glob(src_spec, {}, function (err, files) { + // got files + if (files && files.length) { + async.eachSeries( files, function(src_file, callback) { + // foreach file + var stats = fs.statSync(src_file); + + if (stats.isFile()) { + copyFile( src_file, dest_dir + '/', callback ); + } + else if (stats.isDirectory()) { + copyDir( src_file, dest_dir + '/' + path.basename(src_file), exclusive, callback ); + } + }, callback ); + } // got files + else { + callback( err ); + } + } ); +}; + +var bundleCompress = exports.bundleCompress = function bundleCompress( args, callback ) { + // compress bundle of files + var html_file = args.html_file; + var html_dir = path.dirname( html_file ); + + if (!fileExistsSync( path.dirname(args.dest_bundle) )) { + mkdirp.sync( path.dirname(args.dest_bundle) ); + } + + var raw_html = fs.readFileSync( html_file, 'utf8' ); + var regexp = new RegExp( "\\<\\!\\-\\-\\s+BUILD\\:\\s*"+args.match_key+"_START\\s*\\-\\-\\>([^]+)\\<\\!\\-\\-\\s*BUILD\\:\\s+"+args.match_key+"_END\\s*\\-\\-\\>" ); + + if (raw_html.match(regexp)) { + var files_raw = RegExp.$1; + var files = []; + + files_raw.replace( /\b(src|href)\=[\"\']([^\"\']+)[\"\']/ig, function(m_all, m_g1, m_g2) { + files.push( path.join(html_dir, m_g2) ); + } ); + + if (files.length) { + console.log("Bundling files: ", files); + var raw_output = ''; + + // optionally add file header + if (args.header) { + raw_output += args.header.trim() + "\n"; + } + + if (args.uglify) { + console.log("Running UglifyJS..."); + var result = UglifyJS.minify( files ); + if (!result || !result.code) return callback( new Error("Failed to bundle script: Uglify failure") ); + raw_output += result.code; + } + else { + for (var idx = 0, len = files.length; idx < len; idx++) { + var file = files[idx]; + raw_output += fs.readFileSync( file, 'utf8' ) + "\n"; + } + } + + // optionally strip source map links + // /*# sourceMappingURL=materialdesignicons.min.css.map */ + if (args.strip_source_maps) { + raw_output = raw_output.replace(/sourceMappingURL\=\S+/g, ""); + } + + // write out our bundle + console.log(" --> " + args.dest_bundle); + fs.writeFileSync(args.dest_bundle, raw_output); + + // swap a ref link into a copy of the HTML + console.log(" --> " + html_file ); + raw_html = raw_html.replace( regexp, args.dest_bundle_tag ); + fs.writeFileSync(html_file, raw_html); + + // now make a compressed version of the bundle + compressFile( args.dest_bundle, args.dest_bundle + '.gz', function(err) { + if (err) return callback(err); + + // and compress the final HTML as well + compressFile( html_file, html_file + '.gz', callback ); + }); + + } // found files + else { + callback( new Error("Could not locate any file references: " + args.src_html + ": " + args.match_key) ); + } + } + else { + callback( new Error("Could not locate match in HTML source: " + args.src_html + ": " + args.match_key) ); + } +}; + +var generateSecretKey = exports.generateSecretKey = function generateSecretKey( args, callback ) { + // generate random secret key for a specified JSON config file + // use regular expression to preserve natural file format + var file = args.file; + var key = args.key; + var regex = new RegExp('(\\"'+Tools.escapeRegExp(key)+'\\"\\s*\\:\\s*\\")(.*?)(\\")'); + var secret_key = Tools.generateUniqueID(32); + + fs.readFile(file, 'utf8', function(err, text) { + if (err) return callback(err); + if (!text.match(regex)) return callback( new Error("Could not locate key to replace: " + file + ": " + key) ); + + text = text.replace(regex, '$1' + secret_key + '$3'); + fs.writeFile( file, text, callback ); + }); +}; + +var addToServerStartup = exports.addToServerStartup = function addToServerStartup( args, callback ) { + // add service to init.d + // (RedHat and Ubuntu only -- do nothing on OS X) + var src_file = args.src_file; + var dest_dir = args.dest_dir; + var service_name = args.service_name; + var dest_file = dest_dir + '/' + service_name; + + // shell command that activates service on redhat or ubuntu + var cmd = 'chkconfig '+service_name+' on || update-rc.d '+service_name+' defaults'; + + // skip on os x, and if init dir is missing + if (os.platform() == 'darwin') return callback(); + if (!fileExistsSync(dest_dir)) return callback( new Error("Cannot locate init.d directory: " + dest_dir) ); + if (process.getuid() != 0) return callback( new Error("Must be root to add services to server startup system.") ); + + // copy file into place + copyFile( src_file, dest_file, function(err) { + if (err) return callback(err); + + // must be executable + fs.chmod( dest_file, "775", function(err) { + if (err) return callback(err); + + // exec shell command + cp.exec( cmd, callback ); + } ); + } ); +}; + +var printMessage = exports.printMessage = function printMessage( args, callback ) { + // output a message to the console + // use process.stdout.write because console.log has been redirected to a file. + process.stdout.write( "\n" + args.lines.join("\n") + "\n\n" ); +}; diff --git a/bin/build.js b/bin/build.js new file mode 100644 index 0000000..8cca77c --- /dev/null +++ b/bin/build.js @@ -0,0 +1,71 @@ +#!/usr/bin/env node + +// Simple Node Project Builder +// Copies, symlinks and compresses files into the right locations. +// Can also compact & bundle JS/CSS together for distribution. +// Copyright (c) 2015 Joseph Huckaby, MIT License. + +var fs = require('fs'); +var path = require('path'); +var util = require('util'); +var async = require('async'); +var mkdirp = require('mkdirp'); + +var BuildTools = require('./build-tools.js'); +var setup = require('../sample_conf/setup.json'); + +var mode = 'dist'; +if (process.argv.length > 2) mode = process.argv[2]; + +var steps = setup.build.common || []; +if (setup.build[mode]) { + steps = steps.concat( setup.build[mode] ); +} + +// chdir to the proper server root dir +process.chdir( path.dirname( __dirname ) ); + +// make sure we have a logs dir +mkdirp.sync( 'logs' ); +fs.chmodSync( 'logs', "755" ); + +// log to file instead of console +console.log = function(msg, data) { + if (data) msg += ' ' + JSON.stringify(data); + fs.appendFile( 'logs/install.log', msg + "\n", function() {} ); +}; + +console.log("\nBuilding project ("+mode+")...\n"); + +async.eachSeries( steps, function(step, callback) { + // foreach step + + // util.isArray is DEPRECATED??? Nooooooooode! + var isArray = Array.isArray || util.isArray; + + if (isArray(step)) { + // [ "symlinkFile", "node_modules/pixl-webapp/js", "htdocs/js/common" ], + var func = step.shift(); + console.log( func + ": " + JSON.stringify(step)); + step.push( callback ); + BuildTools[func].apply( null, step ); + } + else { + // { "action": "bundleCompress", ... } + var func = step.action; + delete step.action; + console.log( func + ": " + JSON.stringify(step)); + BuildTools[func].apply( null, [step, callback] ); + } +}, +function(err) { + // done with iteration, check for error + if (err) { + console.error("\nBuild Error: " + err + "\n"); + } + else { + console.log("\nBuild complete.\n"); + } +} ); + +// End diff --git a/bin/cms.sh b/bin/cms.sh new file mode 100644 index 0000000..8d07e7e --- /dev/null +++ b/bin/cms.sh @@ -0,0 +1,23 @@ +HOMEDIR=$(dirname $(readlink -f $0)) +input=${2:--} +key_file=${key_file:-$HOMEDIR/../conf/key.pem} + +if [ "$1" == "e" ] || [ "$1" == "encrypt" ] ; then + cat $input | openssl cms -encrypt -outform PEM $key_file +elif [ "$1" == "d" ] || [ "$1" == "decrypt" ] ; then + cat $input | openssl cms -decrypt -inform PEM -inkey $key_file -passin pass:$pass +elif [ "$1" == "m" ] || [ "$1" == "message" ]; then + cat $input | openssl cms -cmsout -inform PEM -outform DER | openssl pkcs7 -inform DER -print -noout +elif [ "$1" == "serial" ]; then + cat $input | openssl x509 -noout -serial | sed 's/serial=//' +elif [ $1 == "subj" ] || [ $1 == "subject" ]; then + cat $input | openssl x509 -noout -subject | sed 's/subject=CN = //' +elif [ "$1" == "new" ]; then + if [ -z $2 ]; then + echo 'must specify cert name' + exit 1 + fi + openssl req -x509 -sha256 -nodes -days 365 -keyout - -newkey rsa:4096 -subj "/CN=$2" -addext extendedKeyUsage=1.3.6.1.4.1.311.80.1 -addext keyUsage=keyEncipherment 2>/dev/null +else + echo 'invalid command' +fi \ No newline at end of file diff --git a/bin/control.sh b/bin/control.sh new file mode 100644 index 0000000..8f25299 --- /dev/null +++ b/bin/control.sh @@ -0,0 +1,220 @@ +#!/bin/sh +# +# Control script designed to allow an easy command line interface +# to controlling any binary. Written by Marc Slemko, 1997/08/23 +# Modified for Cronicle, Joe Huckaby, 2015/08/11 +# +# The exit codes returned are: +# 0 - operation completed successfully +# 2 - usage error +# 3 - binary could not be started +# 4 - binary could not be stopped +# 8 - configuration syntax error +# +# When multiple arguments are given, only the error from the _last_ +# one is reported. Run "*ctl help" for usage info +# +# +# |||||||||||||||||||| START CONFIGURATION SECTION |||||||||||||||||||| +# -------------------- -------------------- +# +# the name of your binary +NAME="Cronicle Daemon" +# +# home directory +#HOMEDIR="$(dirname "$(cd -- "$(dirname "$0")" && (pwd -P 2>/dev/null || pwd))")" +HOMEDIR="$(dirname "$(cd -- "$(dirname "$(readlink -f "$0")")" && (pwd -P 2>/dev/null || pwd))")" +cd $HOMEDIR +# +# the path to your binary, including options if necessary +BINARY="node $HOMEDIR/lib/main.js" +# +# the path to your PID file +PIDFILE=$HOMEDIR/logs/cronicled.pid +# +# -------------------- -------------------- +# |||||||||||||||||||| END CONFIGURATION SECTION |||||||||||||||||||| + +ERROR=0 +ARGV="$@" +if [ "x$ARGV" = "x" ] ; then + ARGS="help" +fi + +for ARG in $@ $ARGS +do + # check for pidfile + if [ -f $PIDFILE ] ; then + PID=`cat $PIDFILE` + if [ "x$PID" != "x" ] && kill -0 $PID 2>/dev/null ; then + STATUS="$NAME running (pid $PID)" + RUNNING=1 + else + STATUS="$NAME not running (pid $PID?)" + RUNNING=0 + fi + else + STATUS="$NAME not running (no pid file)" + RUNNING=0 + fi + + case $ARG in + start) + if [ $RUNNING -eq 1 ]; then + echo "$ARG: $NAME already running (pid $PID)" + continue + fi + echo "$0 $ARG: Starting up $NAME..." + if $BINARY ; then + echo "$0 $ARG: $NAME started" + else + echo "$0 $ARG: $NAME could not be started" + ERROR=3 + fi + ;; + stop) + if [ $RUNNING -eq 0 ]; then + echo "$ARG: $STATUS" + continue + fi + if kill $PID ; then + while [ "x$PID" != "x" ] && kill -0 $PID 2>/dev/null ; do + sleep 1; + done + echo "$0 $ARG: $NAME stopped" + else + echo "$0 $ARG: $NAME could not be stopped" + ERROR=4 + fi + ;; + restart) + $0 stop start + ;; + cycle) + $0 stop start + ;; + status) + echo "$ARG: $STATUS" + ;; + setup) + node $HOMEDIR/bin/storage-cli.js setup + #exit # do not exit to run setup and start in one shot + ;; + maint) + node $HOMEDIR/bin/storage-cli.js maint $2 + exit + ;; + admin) + node $HOMEDIR/bin/storage-cli.js admin $2 $3 + exit + ;; + export) + node $HOMEDIR/bin/storage-cli.js export $2 $3 $4 + exit + ;; + import) + if [ $RUNNING -eq 1 ]; then + $0 stop + fi + node $HOMEDIR/bin/storage-cli.js import $2 $3 $4 + exit + ;; + upgrade) + node $HOMEDIR/bin/install.js $2 || exit 1 + exit + ;; + migrate) + node $HOMEDIR/bin/storage-migrate.js $2 $3 $4 + exit + ;; + version) + PACKAGE_VERSION=$(node -p -e "require('./package.json').version") + echo "$PACKAGE_VERSION" + exit + ;; + *) + echo "usage: $0 (start|stop|cycle|status|setup|maint|admin|export|import|upgrade|help)" + cat <. +## +## Portions of this software are based upon public domain software +## originally written at the National Center for Supercomputing Applications, +## University of Illinois, Urbana-Champaign. +## +# diff --git a/bin/cronicled.init b/bin/cronicled.init new file mode 100644 index 0000000..7a20647 --- /dev/null +++ b/bin/cronicled.init @@ -0,0 +1,20 @@ +#!/bin/sh +# +# init.d script for Cronicle Scheduler +# +# chkconfig: 345 90 10 +# description: Cronicle Scheduler + +### BEGIN INIT INFO +# Provides: cronicled +# Required-Start: $local_fs $remote_fs $network $syslog $named +# Required-Stop: $local_fs $remote_fs $network $syslog $named +# Default-Start: 2 3 4 5 +# Default-Stop: 0 1 6 +# X-Interactive: true +# Short-Description: Start/Stop Cronicle Scheduler +### END INIT INFO + +PATH=/sbin:/bin:/usr/bin:/usr/sbin:/usr/local/bin:/usr/local/sbin:$PATH + +/opt/cronicle/bin/control.sh $1 diff --git a/bin/debug.sh b/bin/debug.sh new file mode 100644 index 0000000..c3117ca --- /dev/null +++ b/bin/debug.sh @@ -0,0 +1,10 @@ +#!/bin/sh + +# Start Cronicle in debug mode +# No daemon fork, and all logs emitted to stdout +# Add --manager to force instant manager on startup + +HOMEDIR="$(dirname "$(cd -- "$(dirname "$0")" && (pwd -P 2>/dev/null || pwd))")" + +cd $HOMEDIR +node --trace-warnings $HOMEDIR/lib/main.js --debug --echo "$@" diff --git a/bin/importkey.sh b/bin/importkey.sh new file mode 100644 index 0000000..1c1ec5f --- /dev/null +++ b/bin/importkey.sh @@ -0,0 +1,14 @@ +#!/bin/bash +HOMEDIR="$(dirname "$(cd -- "$(dirname "$(readlink -f "$0")")" && (pwd -P 2>/dev/null || pwd))")" +PS_STORE="$HOME/.dotnet/corefx/cryptography/x509stores/my" +mkdir -p $PS_STORE +input=${1:-$HOMEDIR/*.pfx} + +cat $input | openssl pkcs12 -clcerts -nodes | tee "$HOMEDIR/conf/key.pem" | openssl pkcs12 -export -out "$PS_STORE/key.pfx" -passout pass: + + + + + + + diff --git a/bin/install.js b/bin/install.js new file mode 100644 index 0000000..5ab312d --- /dev/null +++ b/bin/install.js @@ -0,0 +1,255 @@ +// Cronicle Auto Installer +// Copyright (c) 2015 - 2019 Joseph Huckaby, MIT License. +// https://github.com/jhuckaby/Cronicle + +// To install, issue this command as root: +// curl -s "https://raw.githubusercontent.com/jhuckaby/Cronicle/manager/bin/install.js" | node + +var path = require('path'); +var fs = require('fs'); +var util = require('util'); +var os = require('os'); +var cp = require('child_process'); + +var installer_version = '1.3'; +var base_dir = '/opt/cronicle'; +var log_dir = base_dir + '/logs'; +var log_file = ''; +var gh_repo_url = 'http://github.com/jhuckaby/Cronicle'; +var gh_releases_url = 'https://api.github.com/repos/jhuckaby/Cronicle/releases'; +var gh_head_tarball_url = 'https://github.com/jhuckaby/Cronicle/archive/manager.tar.gz'; + +// don't allow npm to delete these (ugh) +var packages_to_check = ['couchbase', 'aws-sdk', 'redis']; +var packages_to_rescue = {}; + +var restore_packages = function() { + // restore packages that npm killed during upgrade + var cmd = "npm install"; + for (var pkg in packages_to_rescue) { + cmd += ' ' + pkg + '@' + packages_to_rescue[pkg]; + } + if (log_file) { + fs.appendFileSync(log_file, "\nExecuting npm command to restore lost packages: " + cmd + "\n"); + cmd += ' >>' + log_file + ' 2>&1'; + } + cp.execSync(cmd); +}; + +var print = function(msg) { + process.stdout.write(msg); + if (log_file) fs.appendFileSync(log_file, msg); +}; +var warn = function(msg) { + process.stderr.write(msg); + if (log_file) fs.appendFileSync(log_file, msg); +}; +var die = function(msg) { + warn( "\nERROR: " + msg.trim() + "\n\n" ); + process.exit(1); +}; +var logonly = function(msg) { + if (log_file) fs.appendFileSync(log_file, msg); +}; + +if (process.getuid() != 0) { + die( "The Cronicle auto-installer must be run as root." ); +} + +// create base and log directories +try { cp.execSync( "mkdir -p " + base_dir + " && chmod 775 " + base_dir ); } +catch (err) { die("Failed to create base directory: " + base_dir + ": " + err); } + +try { cp.execSync( "mkdir -p " + log_dir + " && chmod 777 " + log_dir ); } +catch (err) { die("Failed to create log directory: " + log_dir + ": " + err); } + +// start logging from this point onward +log_file = log_dir + '/install.log'; +logonly( "\nStarting install run: " + (new Date()).toString() + "\n" ); + +print( + "\nCronicle Installer v" + installer_version + "\n" + + "Copyright (c) 2015 - 2018 PixlCore.com. MIT Licensed.\n" + + "Log File: " + log_file + "\n\n" +); + +process.chdir( base_dir ); + +var is_preinstalled = false; +var cur_version = ''; +var new_version = process.argv[2] || ''; + +try { + var stats = fs.statSync( base_dir + '/package.json' ); + var json = require( base_dir + '/package.json' ); + if (json && json.version) { + cur_version = json.version; + is_preinstalled = true; + } +} +catch (err) {;} + +var is_running = false; +if (is_preinstalled) { + var pid_file = log_dir + '/cronicled.pid'; + try { + var pid = fs.readFileSync(pid_file, { encoding: 'utf8' }); + is_running = process.kill( pid, 0 ); + } + catch (err) {;} +} + +print( "Fetching release list...\n"); +logonly( "Releases URL: " + gh_releases_url + "\n" ); + +cp.exec('curl -s ' + gh_releases_url, function (err, stdout, stderr) { + if (err) { + print( stdout.toString() ); + warn( stderr.toString() ); + die("Failed to fetch release list: " + gh_releases_url + ": " + err); + } + + var releases = null; + try { releases = JSON.parse( stdout.toString() ); } + catch (err) { + die("Failed to parse JSON from GitHub: " + gh_releases_url + ": " + err); + } + + // util.isArray is DEPRECATED??? Nooooooooode! + var isArray = Array.isArray || util.isArray; + if (!isArray(releases)) die("Unexpected response from GitHub Releases API: " + gh_releases_url + ": Not an array"); + + var release = null; + for (var idx = 0, len = releases.length; idx < len; idx++) { + var rel = releases[idx]; + var ver = rel.tag_name.replace(/^\D+/, ''); + rel.version = ver; + + if (!new_version || (ver == new_version)) { + release = rel; + new_version = ver; + idx = len; + } + } // foreach release + + if (!release) { + // no release found -- use HEAD rev? + if (!new_version || new_version.match(/HEAD/i)) { + release = { + version: 'HEAD', + tarball_url: gh_head_tarball_url + }; + } + else { + die("Release not found: " + new_version); + } + } + + // sanity check + if (is_preinstalled && (cur_version == new_version)) { + if (process.argv[2]) print( "\nVersion " + cur_version + " is already installed.\n\n" ); + else print( "\nVersion " + cur_version + " is already installed, and is the latest.\n\n" ); + process.exit(0); + } + + // proceed with installation + if (is_preinstalled) print("Upgrading Cronicle from v"+cur_version+" to v"+new_version+"...\n"); + else print("Installing Cronicle v"+new_version+"...\n"); + + if (is_running) { + print("\n"); + try { cp.execSync( base_dir + "/bin/control.sh stop", { stdio: 'inherit' } ); } + catch (err) { die("Failed to stop Cronicle: " + err); } + print("\n"); + } + + // download tarball and expand into current directory + var tarball_url = release.tarball_url; + logonly( "Tarball URL: " + tarball_url + "\n" ); + + cp.exec('curl -L ' + tarball_url + ' | tar zxf - --strip-components 1', function (err, stdout, stderr) { + if (err) { + print( stdout.toString() ); + warn( stderr.toString() ); + die("Failed to download release: " + tarball_url + ": " + err); + } + else { + logonly( stdout.toString() + stderr.toString() ); + } + + try { + var stats = fs.statSync( base_dir + '/package.json' ); + var json = require( base_dir + '/package.json' ); + } + catch (err) { + die("Failed to download package: " + tarball_url + ": " + err); + } + + print( is_preinstalled ? "Updating dependencies...\n" : "Installing dependencies...\n"); + + var npm_cmd = is_preinstalled ? "npm update --unsafe-perm" : "npm install --unsafe-perm"; + logonly( "Executing command: " + npm_cmd + "\n" ); + + // temporarily stash add-on modules that were installed separately (thanks npm) + if (is_preinstalled) packages_to_check.forEach( function(pkg) { + if (fs.existsSync('node_modules/' + pkg)) { + packages_to_rescue[pkg] = JSON.parse( fs.readFileSync('node_modules/' + pkg + '/package.json', 'utf8') ).version; + } + }); + + // install dependencies via npm + cp.exec(npm_cmd, function (err, stdout, stderr) { + if (err) { + print( stdout.toString() ); + warn( stderr.toString() ); + if (is_preinstalled) restore_packages(); + die("Failed to install dependencies: " + err); + } + else { + logonly( stdout.toString() + stderr.toString() ); + } + + print("Running post-install script...\n"); + logonly( "Executing command: node bin/build.js dist\n" ); + + // finally, run postinstall script + cp.exec('node bin/build.js dist', function (err, stdout, stderr) { + if (is_preinstalled) { + // for upgrades only print output on error + if (err) { + print( stdout.toString() ); + warn( stderr.toString() ); + if (is_preinstalled) restore_packages(); + die("Failed to run post-install: " + err); + } + else { + if (is_preinstalled) restore_packages(); + print("Upgrade complete.\n\n"); + + if (is_running) { + try { cp.execSync( base_dir + "/bin/control.sh start", { stdio: 'inherit' } ); } + catch (err) { die("Failed to start Cronicle: " + err); } + print("\n"); + } + } + } // upgrade + else { + // first time install, always print output + print( stdout.toString() ); + warn( stderr.toString() ); + + if (err) { + die("Failed to run post-install: " + err); + } + else { + print("Installation complete.\n\n"); + } + } // first install + + logonly( "Completed install run: " + (new Date()).toString() + "\n" ); + + process.exit(0); + } ); // build.js + } ); // npm + } ); // download +} ); // releases api diff --git a/bin/jars/Readme.md b/bin/jars/Readme.md new file mode 100644 index 0000000..48b68d9 --- /dev/null +++ b/bin/jars/Readme.md @@ -0,0 +1,2 @@ +# JARS + This is a default classpath location for running java plugin (along with /jars/). You can put here jar/class file dependencies for your java plugin jobs. \ No newline at end of file diff --git a/bin/java-plugin.js b/bin/java-plugin.js new file mode 100644 index 0000000..99b0bc5 --- /dev/null +++ b/bin/java-plugin.js @@ -0,0 +1,189 @@ +#!/usr/bin/env node + +// Shell Script Runner for Cronicle +// Invoked via the 'Shell Script' Plugin +// Copyright (c) 2015 Joseph Huckaby +// Released under the MIT License + +var fs = require('fs'); +var os = require('os'); +var cp = require('child_process'); +var path = require('path'); +var JSONStream = require('pixl-json-stream'); +var Tools = require('pixl-tools'); +var rimraf = require("rimraf"); +var dotenv = require('dotenv'); + +var env_cms_file = process.env.ENV_CMS_FILE || 'env.cms' +var env_file = process.env.ENV_FILE || '.env' + +if (fs.existsSync(env_cms_file)) { + try { + var env = require('dotenv').parse(cp.execSync(`bin/cms.sh d ${env_cms_file}`)) + for (const k in env) { + process.env[k] = env[k] + } + } catch { }; +} +else { + dotenv.config({ path: env_file}); +} + +// setup stdin / stdout streams +process.stdin.setEncoding('utf8'); +process.stdout.setEncoding('utf8'); + +var stream = new JSONStream( process.stdin, process.stdout ); +stream.on('json', function(job) { + // got job from parent + var commentRg = /\/\*[^]*?\*\/\s*/g ; + var classNameRg = /(?<=^\s*(?:public\s+)?class\s+)(\w+?)(?=[\s|{])/gm + // get class name from parameter, then autodetect from script then event name + var className = job.params.class_name || (job.params.script.replace(commentRg, '').match(classNameRg) || [job.event_title])[0]; + var tmpDir = fs.mkdtempSync(path.join(os.tmpdir(),job.id)) + var classPath = path.join(__dirname, 'jars/*') + ':/jars/*:' + tmpDir; + if(job.params.cp_param) { classPath = job.params.cp_param + ":" + classPath} + if(process.env['CLASSPATH']) {classPath = process.env['CLASSPATH'] + ":" + classPath; } + process.env['CLASSPATH'] = classPath; + var java_file = path.join( tmpDir, className + '.java') + var script_file = path.join(tmpDir, 'run.sh' ); + var java = "java" + var javac = "javac" + if(job.params.jh_param) { + javac = path.join(job.params.jh_param, 'bin', 'javac') + java = path.join(job.params.jh_param, 'bin', 'java') + } + var script = `#!/bin/sh + ${javac} ${java_file} + ${java} ${className} + ` + fs.writeFileSync( java_file, job.params.script, { mode: "775" } ); + fs.writeFileSync( script_file, script, { mode: "775" } ); + + + if (job.params.tty) { // execute thru script tool + var child = cp.spawn("/usr/bin/script", ["-qec", script_file, "--flush"], { + stdio: ['pipe', 'pipe', 'pipe'] + }); + } + else { + var child = cp.spawn(script_file, [], { + stdio: ['pipe', 'pipe', 'pipe'] + }); + } + + var kill_timer = null; + var stderr_buffer = ''; + + // if tty option is checked do not pass stdin (to avoid it popping up in the log) + if (job.params.tty) { var cstream = new JSONStream(child.stdout); } + else { var cstream = new JSONStream(child.stdout, child.stdin); } + + cstream.recordRegExp = /^\s*\{.+\}\s*$/; + + cstream.on('json', function(data) { + // received JSON data from child, pass along to Cronicle or log + if (job.params.json) stream.write(data); + else cstream.emit('text', JSON.stringify(data) + "\n"); + } ); + + cstream.on('text', function(line) { + // received non-json text from child + // look for plain number from 0 to 100, treat as progress update + if (line.match(/^\s*(\d+)\%\s*$/)) { + var progress = Math.max( 0, Math.min( 100, parseInt( RegExp.$1 ) ) ) / 100; + stream.write({ + progress: progress + }); + } + else { + // otherwise just log it + if (job.params.annotate) { + var dargs = Tools.getDateArgs( new Date() ); + line = '[' + dargs.yyyy_mm_dd + ' ' + dargs.hh_mi_ss + '] ' + line; + } + fs.appendFileSync(job.log_file, line); + } + } ); + + cstream.on('error', function(err, text) { + // Probably a JSON parse error (child emitting garbage) + if (text) fs.appendFileSync(job.log_file, text + "\n"); + } ); + + child.on('error', function (err) { + // child error + stream.write({ + complete: 1, + code: 1, + description: "Script failed: " + Tools.getErrorDescription(err) + }); + + fs.unlink( script_file, function(err) {;} ); + } ); + + child.on('exit', function (code, signal) { + // child exited + if (kill_timer) clearTimeout(kill_timer); + code = (code || signal || 0); + + var data = { + complete: 1, + code: code, + description: code ? ("Script exited with code: " + code) : "" + }; + + if (stderr_buffer.length && stderr_buffer.match(/\S/)) { + data.html = { + title: "Error Output", + content: "
" + stderr_buffer.replace(/"
+			};
+			
+			if (code) {
+				// possibly augment description with first line of stderr, if not too insane
+				var stderr_line = stderr_buffer.trim().split(/\n/).shift();
+				if (stderr_line.length < 256) data.description += ": " + stderr_line;
+			}
+		}
+		
+		stream.write(data);
+		//fs.unlink( script_file, function(err) {;} );
+		rimraf(tmpDir, function(err){
+			if(err) {console.log('Warning - error occured while removing tmp files:\n', err);}
+		});
+	} ); // exit
+	
+	// silence EPIPE errors on child STDIN
+	child.stdin.on('error', function(err) {
+		// ignore
+	} );
+	
+	// track stderr separately for display purposes
+	child.stderr.setEncoding('utf8');
+	child.stderr.on('data', function(data) {
+		// keep first 32K in RAM, but log everything
+		if (stderr_buffer.length < 32768) stderr_buffer += data;
+		else if (!stderr_buffer.match(/\.\.\.$/)) stderr_buffer += '...';
+		
+		fs.appendFileSync(job.log_file, data);
+	});
+	
+	// pass job down to child process (harmless for shell, useful for php/perl/node)
+	cstream.write( job );
+	child.stdin.end();
+	
+	// Handle shutdown
+	process.on('SIGTERM', function() { 
+		console.log("Caught SIGTERM, killing child: " + child.pid);
+		
+		kill_timer = setTimeout( function() {
+			// child didn't die, kill with prejudice
+			console.log("Child did not exit, killing harder: " + child.pid);
+			child.kill('SIGKILL');
+		}, 9 * 1000 );
+		
+		// try killing nicely first
+		child.kill('SIGTERM');
+	} );
+	
+} ); // stream
diff --git a/bin/manager b/bin/manager
new file mode 100644
index 0000000..b54a9c2
--- /dev/null
+++ b/bin/manager
@@ -0,0 +1,27 @@
+#!/bin/bash
+HOMEDIR="$(dirname "$(cd -- "$(dirname "$(readlink -f "$0")")" && (pwd -P 2>/dev/null || pwd))")"
+
+# 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
+
+if [ -f "$HOMEDIR/logs/cronicled.pid" ]; then
+  echo 'removing old pid file'
+  rm "$HOMEDIR/logs/cronicled.pid"
+fi
+
+# try to import data (schedules, users, categories) from backup.json. Ignore server info (to keep current server as manager)
+# to generate backup.json: /opt/cronicle/bin/control.sh export > backup.json
+if [ -f "$HOMEDIR/data/backup.json" ]; then
+  cat "$HOMEDIR/data/backup.json" | grep -v 'global/server' |  $HOMEDIR/bin/control.sh import || echo 'failed to import from backup'
+  rm "$HOMEDIR/data/backup.json"
+fi
+
+#$HOMEDIR/bin/control.sh start
+exec node $HOMEDIR/lib/main.js
+
+
+
diff --git a/bin/run-detached.js b/bin/run-detached.js
new file mode 100644
index 0000000..101e61e
--- /dev/null
+++ b/bin/run-detached.js
@@ -0,0 +1,142 @@
+#!/usr/bin/env node
+
+// Detached Plugin Runner for Cronicle
+// Copyright (c) 2015 - 2017 Joseph Huckaby
+// Released under the MIT License
+
+var fs = require('fs');
+var os = require('os');
+var cp = require('child_process');
+var path = require('path');
+var sqparse = require('shell-quote').parse;
+var JSONStream = require('pixl-json-stream');
+var Tools = require('pixl-tools');
+
+var args = process.argv.slice(-2);
+if (!args[1] || !args[1].match(/\.json$/)) {
+	throw new Error("Usage: ./run-detached.js detached /PATH/TO/JSON/FILE.json");
+}
+
+var job_file = args[1];
+var job = require( job_file );
+fs.unlink( job_file, function(err) {;} );
+
+var child_cmd = job.command;
+var child_args = [];
+
+// if command has cli args, parse using shell-quote
+if (child_cmd.match(/\s+(.+)$/)) {
+	var cargs_raw = RegExp.$1;
+	child_cmd = child_cmd.replace(/\s+(.+)$/, '');
+	child_args = sqparse( cargs_raw, process.env );
+}
+
+var child = cp.spawn( child_cmd, child_args, { 
+	stdio: ['pipe', 'pipe', fs.openSync(job.log_file, 'a')] 
+} );
+
+var updates = { detached_pid: child.pid };
+var kill_timer = null;
+var update_timer = null;
+
+var cstream = new JSONStream( child.stdout, child.stdin );
+cstream.recordRegExp = /^\s*\{.+\}\s*$/;
+
+cstream.on('json', function(data) {
+	// received JSON data from child
+	// store in object and send to Cronicle on exit
+	for (var key in data) updates[key] = data[key];
+} );
+
+cstream.on('text', function(line) {
+	// received non-json text from child, just log it
+	fs.appendFileSync(job.log_file, line);
+} );
+
+cstream.on('error', function(err, text) {
+	// Probably a JSON parse error (child emitting garbage)
+	if (text) fs.appendFileSync(job.log_file, text + "\n");
+} );
+
+child.on('error', function (err) {
+	// child error
+	updates = {};
+	if (kill_timer) clearTimeout(kill_timer);
+	if (update_timer) clearTimeout(update_timer);
+	
+	var queue_file = job.queue_dir + '/' + job.id + '-' + Date.now() + '.json';
+	fs.writeFileSync( queue_file + '.tmp', JSON.stringify({
+		action: "detachedJobUpdate",
+		id: job.id,
+		complete: 1,
+		code: 1,
+		description: "Script failed: " + Tools.getErrorDescription(err)
+	}) );
+	fs.renameSync( queue_file + '.tmp', queue_file );
+} );
+
+child.on('exit', function (code, signal) {
+	// child exited
+	if (kill_timer) clearTimeout(kill_timer);
+	if (update_timer) clearTimeout(update_timer);
+	
+	code = (code || signal || 0);
+	if (code && !updates.code) {
+		updates.code = code;
+		updates.description = "Plugin exited with code: " + code;
+	}
+	
+	updates.action = "detachedJobUpdate";
+	updates.id = job.id;
+	updates.complete = 1;
+	updates.time_end = Tools.timeNow();
+	
+	// write file atomically, just in case
+	var queue_file = job.queue_dir + '/' + job.id + '-complete.json';
+	fs.writeFileSync( queue_file + '.tmp', JSON.stringify(updates) );
+	fs.renameSync( queue_file + '.tmp', queue_file );
+} );
+
+// silence EPIPE errors on child STDIN
+child.stdin.on('error', function(err) {
+	// ignore
+} );
+
+// send initial job + params
+cstream.write( job );
+
+// we're done writing to the child -- don't hold open its stdin
+child.stdin.end();
+
+// send updates every N seconds, if the child sent us anything (i.e. progress updates)
+// randomize interval so we don't bash the queue dir when multiple detached jobs are running
+update_timer = setInterval( function() {
+	if (Tools.numKeys(updates) && !updates.complete) {
+		
+		updates.action = "detachedJobUpdate";
+		updates.id = job.id;
+		updates.in_progress = 1;
+		
+		// write file atomically, just in case
+		var queue_file = job.queue_dir + '/' + job.id + '-' + Date.now() + '.json';
+		fs.writeFileSync( queue_file + '.tmp', JSON.stringify(updates) );
+		fs.renameSync( queue_file + '.tmp', queue_file );
+		
+		updates = {};
+	}
+}, 30000 + Math.floor( Math.random() * 25000 ) );
+
+// Handle termination (server shutdown or job aborted)
+process.on('SIGTERM', function() { 
+	// console.log("Caught SIGTERM, killing child: " + child.pid);
+	if (update_timer) clearTimeout(update_timer);
+	
+	kill_timer = setTimeout( function() {
+		// child didn't die, kill with prejudice
+		// console.log("Child did not exit, killing harder: " + child.pid);
+		child.kill('SIGKILL');
+	}, 9 * 1000 );
+	
+	// try killing nicely first
+	child.kill('SIGTERM');
+} );
diff --git a/bin/shell-plugin.js b/bin/shell-plugin.js
new file mode 100644
index 0000000..b869dfb
--- /dev/null
+++ b/bin/shell-plugin.js
@@ -0,0 +1,164 @@
+#!/usr/bin/env node
+
+// Shell Script Runner for Cronicle
+// Invoked via the 'Shell Script' Plugin
+// Copyright (c) 2015 Joseph Huckaby
+// Released under the MIT License
+
+var fs = require('fs');
+var os = require('os');
+var cp = require('child_process');
+var path = require('path');
+var JSONStream = require('pixl-json-stream');
+var Tools = require('pixl-tools');
+var dotenv = require('dotenv');
+
+var env_cms_file = process.env.ENV_CMS_FILE || 'env.cms'
+var env_file = process.env.ENV_FILE || '.env'
+
+if (fs.existsSync(env_cms_file)) { 
+	try {
+		var env = require('dotenv').parse(cp.execSync(`bin/cms.sh d ${env_cms_file}`))
+		for (const k in env) {
+			process.env[k] = env[k]
+		}
+	} catch { };
+}
+else { 
+	dotenv.config({ path: env_file});
+}
+
+
+// setup stdin / stdout streams 
+process.stdin.setEncoding('utf8');
+process.stdout.setEncoding('utf8');
+
+var stream = new JSONStream(process.stdin, process.stdout);
+stream.on('json', function (job) {
+	// got job from parent 
+	var script_file = path.join(os.tmpdir(), 'cronicle-script-temp-' + job.id + '.sh');
+	fs.writeFileSync(script_file, job.params.script, { mode: "775" });
+
+	if (job.params.tty) { // execute thru script tool
+		var child = cp.spawn("/usr/bin/script", ["-qec", script_file, "--flush"], {
+			stdio: ['pipe', 'pipe', 'pipe']
+		});
+	}
+	else {
+		var child = cp.spawn(script_file, [], {
+			stdio: ['pipe', 'pipe', 'pipe']
+		});
+	}
+
+	var kill_timer = null;
+	var stderr_buffer = '';
+
+	// if tty option is checked do not pass stdin (to avoid it popping up in the log)
+	if (job.params.tty) { var cstream = new JSONStream(child.stdout); }
+	else { var cstream = new JSONStream(child.stdout, child.stdin); }
+
+	cstream.recordRegExp = /^\s*\{.+\}\s*$/;
+
+	cstream.on('json', function (data) {
+		// received JSON data from child, pass along to Cronicle or log
+		if (job.params.json) stream.write(data);
+		else cstream.emit('text', JSON.stringify(data) + "\n");
+	});
+
+	cstream.on('text', function (line) {
+		// received non-json text from child
+		// look for plain number from 0 to 100, treat as progress update
+		if (line.match(/^\s*(\d+)\%\s*$/)) {
+			var progress = Math.max(0, Math.min(100, parseInt(RegExp.$1))) / 100;
+			stream.write({
+				progress: progress
+			});
+		}
+		else {
+			// otherwise just log it
+			if (job.params.annotate) {
+				var dargs = Tools.getDateArgs(new Date());
+				line = '[' + dargs.yyyy_mm_dd + ' ' + dargs.hh_mi_ss + '] ' + line;
+			}
+			fs.appendFileSync(job.log_file, line);
+		}
+	});
+
+	cstream.on('error', function (err, text) {
+		// Probably a JSON parse error (child emitting garbage)
+		if (text) fs.appendFileSync(job.log_file, text + "\n");
+	});
+
+	child.on('error', function (err) {
+		// child error
+		stream.write({
+			complete: 1,
+			code: 1,
+			description: "Script failed: " + Tools.getErrorDescription(err)
+		});
+
+		fs.unlink(script_file, function (err) { ; });
+	});
+
+	child.on('exit', function (code, signal) {
+		// child exited
+		if (kill_timer) clearTimeout(kill_timer);
+		code = (code || signal || 0);
+
+		var data = {
+			complete: 1,
+			code: code,
+			description: code ? ("Script exited with code: " + code) : ""
+		};
+
+		if (stderr_buffer.length && stderr_buffer.match(/\S/)) {
+			data.html = {
+				title: "Error Output",
+				content: "
" + stderr_buffer.replace(/"
+			};
+
+			if (code) {
+				// possibly augment description with first line of stderr, if not too insane
+				var stderr_line = stderr_buffer.trim().split(/\n/).shift();
+				if (stderr_line.length < 256) data.description += ": " + stderr_line;
+			}
+		}
+
+		stream.write(data);
+		fs.unlink(script_file, function (err) { ; });
+	}); // exit
+
+	// silence EPIPE errors on child STDIN
+	child.stdin.on('error', function (err) {
+		// ignore
+	});
+
+	// track stderr separately for display purposes
+	child.stderr.setEncoding('utf8');
+	child.stderr.on('data', function (data) {
+		// keep first 32K in RAM, but log everything
+		if (stderr_buffer.length < 32768) stderr_buffer += data;
+		else if (!stderr_buffer.match(/\.\.\.$/)) stderr_buffer += '...';
+
+		fs.appendFileSync(job.log_file, data);
+	});
+
+	// pass job down to child process (harmless for shell, useful for php/perl/node)
+	cstream.write(job);
+	child.stdin.end();
+
+	// Handle shutdown
+	process.on('SIGTERM', function () {
+		console.log("Caught SIGTERM, killing child: " + child.pid);
+
+		kill_timer = setTimeout(function () {
+			// child didn't die, kill with prejudice
+			console.log("Child did not exit, killing harder: " + child.pid);
+			child.kill('SIGKILL');
+		}, 9 * 1000);
+
+		// try killing nicely first
+		child.kill('SIGTERM');
+	});
+
+}); // stream
diff --git a/bin/storage-cli.js b/bin/storage-cli.js
new file mode 100644
index 0000000..be77216
--- /dev/null
+++ b/bin/storage-cli.js
@@ -0,0 +1,612 @@
+#!/usr/bin/env node
+
+// CLI for Storage System
+// Copyright (c) 2015 Joseph Huckaby
+// Released under the MIT License
+
+var path = require('path');
+var cp = require('child_process');
+var os = require('os');
+var fs = require('fs');
+var async = require('async');
+var bcrypt = require('bcrypt-node');
+
+var Args = require('pixl-args');
+var Tools = require('pixl-tools');
+var StandaloneStorage = require('pixl-server-storage/standalone');
+
+// chdir to the proper server root dir
+process.chdir(path.dirname(__dirname));
+
+// load app's config file
+
+var config = require('../conf/config.json');
+
+// shift commands off beginning of arg array
+var argv = JSON.parse(JSON.stringify(process.argv.slice(2)));
+var commands = [];
+while (argv.length && !argv[0].match(/^\-/)) {
+	commands.push(argv.shift());
+}
+
+// now parse rest of cmdline args, if any
+var args = new Args(argv, {
+	debug: false,
+	verbose: false,
+	quiet: false
+});
+args = args.get(); // simple hash
+
+// copy debug flag into config (for standalone)
+config.Storage.debug = args.debug;
+
+var print = function (msg) {
+	// print message to console
+	if (!args.quiet) process.stdout.write(msg);
+};
+var verbose = function (msg) {
+	// print only in verbose mode
+	if (args.verbose) print(msg);
+};
+var warn = function (msg) {
+	// print to stderr unless quiet
+	if (!args.quiet) process.stderr.write(msg);
+};
+var verbose_warn = function (msg) {
+	// verbose print to stderr unless quiet
+	if (args.verbose && !args.quiet) process.stderr.write(msg);
+};
+
+if (config.uid && (process.getuid() != 0)) {
+	print("ERROR: Must be root to use this script.\n");
+	process.exit(1);
+}
+
+// determine server hostname
+var hostname = (process.env['HOSTNAME'] || process.env['HOST'] || os.hostname()).toLowerCase();
+
+// find the first external IPv4 address
+var ip = '';
+var ifaces = os.networkInterfaces();
+var addrs = [];
+for (var key in ifaces) {
+	if (ifaces[key] && ifaces[key].length) {
+		Array.from(ifaces[key]).forEach(function (item) { addrs.push(item); });
+	}
+}
+var addr = Tools.findObject(addrs, { family: 'IPv4', internal: false });
+if (addr && addr.address && addr.address.match(/^\d+\.\d+\.\d+\.\d+$/)) {
+	ip = addr.address;
+}
+else {
+	print("ERROR: Could not determine server's IP address.\n");
+	process.exit(1);
+}
+
+// util.isArray is DEPRECATED??? Nooooooooode!
+var isArray = Array.isArray || util.isArray;
+
+// prevent logging transactions to STDOUT
+config.Storage.log_event_types = {};
+
+// allow APPNAME_key env vars to override config
+var env_regex = new RegExp("^CRONICLE_(.+)$");
+for (var env_key in process.env) {
+	if (env_key.match(env_regex)) {
+		var env_path = RegExp.$1.trim().replace(/^_+/, '').replace(/_+$/, '').replace(/__/g, '/');
+		var env_value = process.env[env_key].toString();
+
+		// massage value into various types
+		if (env_value === 'true') env_value = true;
+		else if (env_value === 'false') env_value = false;
+		else if (env_value.match(/^\-?\d+$/)) env_value = parseInt(env_value);
+		else if (env_value.match(/^\-?\d+\.\d+$/)) env_value = parseFloat(env_value);
+
+		Tools.setPath(config, env_path, env_value);
+	}
+}
+
+// construct standalone storage server
+var storage = new StandaloneStorage(config.Storage, function (err) {
+	if (err) throw err;
+	// storage system is ready to go
+
+	// become correct user
+	if (config.uid && (process.getuid() == 0)) {
+		verbose("Switching to user: " + config.uid + "\n");
+		process.setuid(config.uid);
+	}
+
+	// custom job data expire handler
+	storage.addRecordType('cronicle_job', {
+		'delete': function (key, value, callback) {
+			storage.delete(key, function (err) {
+				storage.delete(key + '/log.txt.gz', function (err) {
+					callback();
+				}); // delete
+			}); // delete
+		}
+	});
+
+	// process command
+	var cmd = commands.shift();
+	verbose("\n");
+
+	switch (cmd) {
+		case 'setup':
+		case 'install':
+			// setup new manager server
+			var setup = require('../conf/setup.json');
+
+			// 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) {
+				if (!err) {
+					print("Storage has already been set up.  There is no need to run this command again.\n\n");
+					process.exit(0);
+				}
+
+				async.eachSeries(setup.storage,
+					function (params, callback) {
+						verbose("Executing: " + JSON.stringify(params) + "\n");
+						// [ "listCreate", "global/users", { "page_size": 100 } ]
+						var func = params.shift();
+						params.push(callback);
+
+						// massage a few params
+						if (typeof (params[1]) == 'object') {
+							var obj = params[1];
+							if (obj.created) obj.created = Tools.timeNow(true);
+							if (obj.modified) obj.modified = Tools.timeNow(true);
+							if (obj.regexp && (obj.regexp == '_HOSTNAME_')) obj.regexp = '^(' + Tools.escapeRegExp(hostname) + ')$';
+							if (obj.hostname && (obj.hostname == '_HOSTNAME_')) obj.hostname = hostname;
+							if (obj.ip && (obj.ip == '_IP_')) obj.ip = ip;
+						}
+
+						// call storage directly
+						storage[func].apply(storage, params);
+					},
+					function (err) {
+						if (err) throw err;
+						print("\n");
+						print("Setup completed successfully!\n");
+						print("This server (" + hostname + ") has been added as the single primary manager server.\n");
+						print("An administrator account has been created with username 'admin' and password 'admin'.\n");
+						print("You should now be able to start the service by typing: '/opt/cronicle/bin/control.sh start'\n");
+						print("Then, the web interface should be available at: http://" + hostname + ":" + config.WebServer.http_port + "/\n");
+						print("Please allow for up to 60 seconds for the server to become manager.\n\n");
+
+						storage.shutdown(function () { process.exit(0); });
+					}
+				);
+			});
+			break;
+
+		case 'admin':
+			// create or replace admin account
+			// Usage: ./storage-cli.js admin USERNAME PASSWORD [EMAIL]
+			var username = commands.shift();
+			var password = commands.shift();
+			var email = commands.shift() || 'admin@localhost';
+			if (!username || !password) {
+				print("\nUsage: bin/storage-cli.js admin USERNAME PASSWORD [EMAIL]\n\n");
+				process.exit(1);
+			}
+			if (!username.match(/^[\w\.\-]+@?[\w\.\-]+$/)) {
+				print("\nERROR: Username must contain only alphanumerics, dash and period.\n\n");
+				process.exit(1);
+			}
+			username = username.toLowerCase();
+
+			var user = {
+				username: username,
+				password: password,
+				full_name: "Administrator",
+				email: email
+			};
+
+			user.active = 1;
+			user.created = user.modified = Tools.timeNow(true);
+			user.salt = Tools.generateUniqueID(64, user.username);
+			user.password = bcrypt.hashSync(user.password + user.salt);
+			user.privileges = { admin: 1 };
+
+			storage.put('users/' + username, user, function (err) {
+				if (err) throw err;
+				print("\nAdministrator '" + username + "' created successfully.\n");
+				print("\n");
+
+				storage.shutdown(function () { process.exit(0); });
+			});
+			break;
+
+		case 'get':
+		case 'fetch':
+		case 'view':
+		case 'cat':
+			// get storage key
+			// Usage: ./storage-cli.js get users/jhuckaby
+			var key = commands.shift();
+			storage.get(key, function (err, data) {
+				if (err) throw err;
+				if (storage.isBinaryKey(key)) print(data.toString() + "\n");
+				else print(((typeof (data) == 'object') ? JSON.stringify(data, null, "\t") : data) + "\n");
+				print("\n");
+
+				storage.shutdown(function () { process.exit(0); });
+			});
+			break;
+
+		case 'put':
+		case 'save':
+		case 'store':
+			// put storage key (read data from STDIN)
+			// Usage: cat USER.json | ./storage-cli.js put users/jhuckaby
+			var key = commands.shift();
+			var json_raw = '';
+			var rl = require('readline').createInterface({ input: process.stdin });
+			rl.on('line', function (line) { json_raw += line; });
+			rl.on('close', function () {
+				print("Writing record from STDIN: " + key + "\n");
+
+				var data = null;
+				try { data = JSON.parse(json_raw); }
+				catch (err) {
+					warn("Failed to parse JSON for key: " + key + ": " + err + "\n");
+					process.exit(1);
+				}
+
+				storage.put(key, data, function (err) {
+					if (err) {
+						warn("Failed to store record: " + key + ": " + err + "\n");
+						process.exit(1);
+					}
+					print("Record successfully saved: " + key + "\n");
+
+					storage.shutdown(function () { process.exit(0); });
+				});
+			});
+			break;
+
+		case 'edit':
+		case 'vi':
+			var key = commands.shift();
+
+			if ((cmd == 'edit') && !process.env.EDITOR) {
+				warn("No EDITOR environment variable is set.\n");
+				process.exit(1);
+			}
+
+			storage.get(key, function (err, data) {
+				if (err) data = {};
+				print("Spawning editor to edit record: " + key + "\n");
+
+				// save to local temp file
+				var temp_file = path.join(os.tmpdir(), 'cli-temp-' + process.pid + '.json');
+				fs.writeFileSync(temp_file, JSON.stringify(data, null, "\t") + "\n");
+				var stats = fs.statSync(temp_file);
+				var old_mod = Math.floor(stats.mtime.getTime() / 1000);
+
+				// spawn vi but inherit terminal
+				var child = cp.spawn((cmd == 'vi') ? 'vi' : process.env.EDITOR, [temp_file], {
+					stdio: 'inherit'
+				});
+				child.on('exit', function (e, code) {
+					var stats = fs.statSync(temp_file);
+					var new_mod = Math.floor(stats.mtime.getTime() / 1000);
+					if (new_mod != old_mod) {
+						print("Saving new data back into record: " + key + "\n");
+
+						var json_raw = fs.readFileSync(temp_file, { encoding: 'utf8' });
+						fs.unlinkSync(temp_file);
+
+						var data = JSON.parse(json_raw);
+
+						storage.put(key, data, function (err, data) {
+							if (err) throw err;
+							print("Record successfully saved with your changes: " + key + "\n");
+
+							storage.shutdown(function () { process.exit(0); });
+						});
+					}
+					else {
+						fs.unlinkSync(temp_file);
+						print("File has not been changed, record was not touched: " + key + "\n");
+
+						storage.shutdown(function () { process.exit(0); });
+					}
+				});
+
+			}); // got data
+			break;
+
+		case 'delete':
+			// delete storage key
+			// Usage: ./storage-cli.js delete users/jhuckaby
+			var key = commands.shift();
+			storage.delete(key, function (err, data) {
+				if (err) throw err;
+				print("Record '" + key + "' deleted successfully.\n");
+				print("\n");
+
+				storage.shutdown(function () { process.exit(0); });
+			});
+			break;
+
+		case 'list_create':
+			// create new list
+			// Usage: ./storage-cli.js list_create key
+			var key = commands.shift();
+			storage.listCreate(key, null, function (err) {
+				if (err) throw err;
+				print("List created successfully: " + key + "\n");
+				print("\n");
+
+				storage.shutdown(function () { process.exit(0); });
+			});
+			break;
+
+		case 'list_pop':
+			// pop item off end of list
+			// Usage: ./storage-cli.js list_pop key
+			var key = commands.shift();
+			storage.listPop(key, function (err, item) {
+				if (err) throw err;
+				print("Item popped off list: " + key + ": " + JSON.stringify(item, null, "\t") + "\n");
+				print("\n");
+
+				storage.shutdown(function () { process.exit(0); });
+			});
+			break;
+
+		case 'list_get':
+			// fetch items from list
+			// Usage: ./storage-cli.js list_get key idx len
+			var key = commands.shift();
+			var idx = parseInt(commands.shift() || 0);
+			var len = parseInt(commands.shift() || 0);
+			storage.listGet(key, idx, len, function (err, items) {
+				if (err) throw err;
+				print("Got " + items.length + " items.\n");
+				print("Items from list: " + key + ": " + JSON.stringify(items, null, "\t") + "\n");
+				print("\n");
+
+				storage.shutdown(function () { process.exit(0); });
+			});
+			break;
+
+		case 'list_info':
+			// fetch info about list
+			// Usage: ./storage-cli.js list_info key
+			var key = commands.shift();
+
+			storage.listGetInfo(key, function (err, list) {
+				if (err) throw err;
+				print("List Header: " + key + ": " + JSON.stringify(list, null, "\t") + "\n\n");
+				var page_idx = list.first_page;
+				var item_idx = 0;
+				async.whilst(
+					function () { return page_idx <= list.last_page; },
+					function (callback) {
+						// load each page
+						storage._listLoadPage(key, page_idx++, false, function (err, page) {
+							if (err) return callback(err);
+							print("Page " + Math.floor(page_idx - 1) + ": " + page.items.length + " items\n");
+							callback();
+						}); // page loaded
+					},
+					function (err) {
+						// all pages iterated
+						if (err) throw err;
+						print("\n");
+
+						storage.shutdown(function () { process.exit(0); });
+					} // pages complete
+				); // whilst
+			});
+			break;
+
+		case 'list_delete':
+			// delete list
+			// Usage: ./storage-cli.js list_delete key
+			var key = commands.shift();
+			storage.listDelete(key, null, function (err) {
+				if (err) throw err;
+				print("List deleted successfully: " + key + "\n");
+				print("\n");
+
+				storage.shutdown(function () { process.exit(0); });
+			});
+			break;
+
+		case 'maint':
+		case 'maintenance':
+			// perform daily maintenance, specify date or defaults to current day
+			// Usage: ./storage-cli.js maint 2015-05-31
+			storage.runMaintenance(commands.shift(), function () {
+				print("Daily maintenance completed successfully.\n");
+				print("\n");
+
+				storage.shutdown(function () { process.exit(0); });
+			});
+			break;
+
+		case 'export':
+			// export all storage data (except completed jobs, sessions)
+			var file = commands.shift();
+			export_data(file);
+			break;
+
+		case 'import':
+			// import storage data from file
+			var file = commands.shift();
+			import_data(file);
+			break;
+
+		default:
+			print("Unknown command: " + cmd + "\n");
+			storage.shutdown(function () { process.exit(0); });
+			break;
+
+	} // switch
+});
+
+function export_data(file) {
+	// export data to file or stdout (except for completed jobs, logs, and sessions)
+	// one record per line: KEY - JSON
+	var stream = file ? fs.createWriteStream(file) : process.stdout;
+
+	// file header (for humans)
+	var file_header = "# Cronicle Data Export v1.0\n" +
+		"# Hostname: " + hostname + "\n" +
+		"# Date/Time: " + (new Date()).toString() + "\n" +
+		"# Format: KEY - JSON\n\n";
+
+	stream.write(file_header);
+	verbose_warn(file_header);
+
+	if (file) verbose_warn("Exporting to file: " + file + "\n\n");
+
+	// need to handle users separately, as they're stored as a list + individual records
+	storage.listEach('global/users',
+		function (item, idx, callback) {
+			var username = item.username;
+			var key = 'users/' + username.toString().toLowerCase().replace(/\W+/g, '');
+			verbose_warn("Exporting user: " + username + "\n");
+
+			storage.get(key, function (err, user) {
+				if (err) {
+					// user deleted?
+					warn("\nFailed to fetch user: " + key + ": " + err + "\n\n");
+					return callback();
+				}
+
+				stream.write(key + ' - ' + JSON.stringify(user) + "\n", 'utf8', callback);
+			}); // get
+		},
+		function (err) {
+			// ignoring errors here
+			// proceed to the rest of the lists
+			async.eachSeries(
+				[
+					'global/users',
+					'global/plugins',
+					'global/categories',
+					'global/server_groups',
+					'global/schedule',
+					'global/servers',
+					'global/api_keys',
+					'global/conf_keys'
+				],
+				function (list_key, callback) {
+					// first get the list header
+					verbose_warn("Exporting list: " + list_key + "\n");
+
+					storage.get(list_key, function (err, list) {
+						if (err) return callback(new Error("Failed to fetch list: " + list_key + ": " + err));
+
+						stream.write(list_key + ' - ' + JSON.stringify(list) + "\n");
+
+						// now iterate over all the list pages
+						var page_idx = list.first_page;
+
+						async.whilst(
+							function () { return page_idx <= list.last_page; },
+							function (callback) {
+								// load each page
+								var page_key = list_key + '/' + page_idx;
+								page_idx++;
+
+								verbose_warn("Exporting list page: " + page_key + "\n");
+
+								storage.get(page_key, function (err, page) {
+									if (err) return callback(new Error("Failed to fetch list page: " + page_key + ": " + err));
+
+									// write page data
+									stream.write(page_key + ' - ' + JSON.stringify(page) + "\n", 'utf8', callback);
+								}); // page get
+							}, // iterator
+							callback
+						); // whilst
+
+					}); // get
+				}, // iterator
+				function (err) {
+					if (err) {
+						warn("\nEXPORT ERROR: " + err + "\n");
+						process.exit(1);
+					}
+
+					verbose_warn("\nExport completed at " + (new Date()).toString() + ".\nExiting.\n\n");
+
+					if (file) stream.end();
+
+					storage.shutdown(function () { process.exit(0); });
+				} // done done
+			); // list eachSeries
+		} // done with users
+	); // users listEach
+};
+
+function import_data(file) {
+	// import storage data from specified file or stdin
+	// one record per line: KEY - JSON
+	print("\nCronicle Data Importer v1.0\n");
+	if (file) print("Importing from file: " + file + "\n");
+	else print("Importing from STDIN\n");
+	print("\n");
+
+	var count = 0;
+	var queue = async.queue(function (line, callback) {
+		// process each line
+		if (line.match(/^(\w[\w\-\.\/]*)\s+\-\s+(\{.+\})\s*$/)) {
+			var key = RegExp.$1;
+			var json_raw = RegExp.$2;
+			print("Importing record: " + key + "\n");
+
+			var data = null;
+			try { data = JSON.parse(json_raw); }
+			catch (err) {
+				warn("Failed to parse JSON for key: " + key + ": " + err + "\n");
+				return callback();
+			}
+
+			storage.put(key, data, function (err) {
+				if (err) {
+					warn("Failed to store record: " + key + ": " + err + "\n");
+					return callback();
+				}
+				count++;
+				callback();
+			});
+		}
+		else callback();
+	}, 1);
+
+	// setup readline to line-read from file or stdin
+	var readline = require('readline');
+	var rl = readline.createInterface({
+		input: file ? fs.createReadStream(file) : process.stdin
+	});
+
+	rl.on('line', function (line) {
+		// enqueue each line
+		queue.push(line);
+	});
+
+	rl.on('close', function () {
+		// end of input stream
+		var complete = function () {
+			// finally, delete state so cronicle recreates it
+			storage.delete('global/state', function (err) {
+				// ignore error here, as state may not exist yet
+				print("\nImport complete. " + count + " records imported.\nExiting.\n\n");
+				storage.shutdown(function () { process.exit(0); });
+			});
+		};
+
+		// fire complete on queue drain
+		if (queue.idle()) complete();
+		else queue.drain = complete;
+	}); // rl close
+};
diff --git a/bin/storage-migrate.js b/bin/storage-migrate.js
new file mode 100644
index 0000000..0318641
--- /dev/null
+++ b/bin/storage-migrate.js
@@ -0,0 +1,441 @@
+#!/usr/bin/env node
+
+// Cronicle Storage Migration System
+// Copyright (c) 2018 Joseph Huckaby
+// Released under the MIT License
+
+// Instructions:
+// Edit your Cronicle conf/config.json file, and make a copy of the `Storage` element.
+// Name the copy `NewStorage`, and put all the new settings in there, that you are migrating to.
+
+// Command-Line Usage:
+// 	bin/storage-migrate.js
+//		--debug: Echo debug log to console
+//		--verbose: List every key as it is copied
+//		--dryrun: Do not write any changes
+
+// After completion, delete `Storage`, and rename `NewStorage` to `Storage`, and you're migrated.
+
+var Path = require('path');
+var os = require('os');
+var fs = require('fs');
+var async = require('async');
+var Logger = require('pixl-logger');
+var cli = require('pixl-cli');
+var args = cli.args;
+cli.global();
+
+var StandaloneStorage = require('pixl-server-storage/standalone');
+
+// chdir to the proper server root dir
+process.chdir( Path.dirname( __dirname ) );
+
+// load app's config file
+var config = require('../conf/config.json');
+
+var StorageMigrator = {
+	
+	version: "1.0.0",
+	
+	run: function() {
+		// here we go
+		var self = this;
+		
+		// setup logger
+		var log_file = Path.join( config.log_dir, 'StorageMigration.log' );
+		this.logger = new Logger( log_file, config.log_columns, {
+			debugLevel: config.debug_level,
+			sync: true,
+			echo: args.debug,
+			color: args.color
+		} );
+		
+		print("\n");
+		this.logPrint(1, "Cronicle Storage Migration Script v" + this.version + " starting up");
+		this.logPrint(2, "Starting storage engines");
+		
+		if (!config.Storage) this.fatal("Your Cronicle configuration lacks a 'Storage' property");
+		if (!config.NewStorage) this.fatal("Your Cronicle configuration lacks a 'NewStorage' property.");
+		
+		if (config.uid && (process.getuid() != 0)) {
+			this.fatal( "Must be root to use the storage migration script." );
+		}
+		
+		// check pid file
+		if (config.pid_file) try {
+			var pid = fs.readFileSync( config.pid_file, 'utf8' );
+			if (pid && process.kill(pid, 0)) this.fatal("Please shut down Cronicle before migrating storage.");
+		}
+		catch (e) {;}
+		
+		// massage config, override logger
+		config.Storage.logger = self.logger;
+		config.Storage.log_event_types = { all: 1 };
+		
+		config.NewStorage.logger = self.logger;
+		config.NewStorage.log_event_types = { all: 1 };
+		
+		// start both standalone storage instances
+		async.series(
+			[
+				function(callback) {
+					self.oldStorage = new StandaloneStorage(config.Storage, callback);
+				},
+				function(callback) {
+					self.newStorage = new StandaloneStorage(config.NewStorage, callback);
+				},
+			],
+			function(err) {
+				if (err) self.fatal("Failed to start storage engine: " + err);
+				
+				self.logPrint(2, "Storage engines are ready to go");
+				
+				// become correct user
+				if (config.uid && (process.getuid() == 0)) {
+					self.logPrint( 3, "Switching to user: " + config.uid );
+					process.setuid( config.uid );
+				}
+				
+				self.testStorage();
+			}
+		); // series
+	},
+	
+	testStorage: function() {
+		// test both old and new storage
+		var self = this;
+		this.logDebug(3, "Testing storage engines");
+		
+		async.series(
+			[
+				function(callback) {
+					self.oldStorage.get('global/users', callback);
+				},
+				function(callback) {
+					self.newStorage.put('test/test1', { "foo1": "bar1" }, function(err) {
+						if (err) return callback(err);
+						
+						self.newStorage.delete('test/test1', function(err) {
+							if (err) return callback(err);
+							
+							callback();
+						});
+					});
+				},
+			],
+			function(err) {
+				if (err) self.fatal("Storage test failure: " + err);
+				
+				self.logPrint(2, "Storage engines tested successfully");
+				self.startMigration();
+			}
+		); // series
+	},
+	
+	startMigration: function() {
+		// start migration process
+		var self = this;
+		this.logPrint(3, "Starting migration");
+		
+		this.timeStart = Tools.timeNow(true);
+		this.numRecords = 0;
+		
+		this.copyKey( 'global/state', { ignore: true } );
+		
+		var lists = [
+			'global/users',
+			'global/plugins',
+			'global/categories',
+			'global/server_groups',
+			'global/schedule',
+			'global/servers',
+			'global/api_keys',
+			'global/conf_keys'
+		];
+		lists.forEach( function(key) { self.copyList(key); } );
+		
+		// these lists technically may not exist yet:
+		this.copyList( 'logs/completed', { ignore: true } );
+		this.copyList( 'logs/activity', { ignore: true } );
+		
+		this.migrateUsers();
+	},
+	
+	migrateUsers: function() {
+		var self = this;
+		this.logPrint(3, "Migrating user records");
+		
+		this.oldStorage.listEach( 'global/users',
+			function(user, idx, callback) {
+				var username = self.normalizeUsername(user.username);
+				var key = 'users/' + username;
+				self.copyKey( key );
+				process.nextTick( callback );
+			},
+			function() {
+				self.migrateCompletedEvents();
+			}
+		); // listEach
+	},
+	
+	migrateCompletedEvents: function() {
+		var self = this;
+		this.logPrint(3, "Migrating completed events");
+		
+		this.oldStorage.listEach( 'global/schedule',
+			function(event, idx, callback) {
+				var key = 'logs/events/' + event.id;
+				self.copyList( key, { ignore: true } );
+				process.nextTick( callback );
+			},
+			function() {
+				self.migrateCompletedJobs();
+			}
+		); // listEach
+	},
+	
+	migrateCompletedJobs: function() {
+		var self = this;
+		this.logPrint(3, "Migrating completed jobs");
+		
+		var unique_cleanup_lists = {};
+		
+		this.oldStorage.listEach( 'logs/completed',
+			function(job, idx, callback) {
+				self.copyKey( 'jobs/' + job.id, { ignore: true } );
+				self.copyKey( 'jobs/' + job.id + '/log.txt.gz', { ignore: true } );
+				
+				var time_end = job.time_start + job.elapsed;
+				var key_expires = time_end + (86400 * config.job_data_expire_days);
+				var log_expires = time_end + (86400 * (job.log_expire_days || config.job_data_expire_days));
+				
+				// get hash of unique exp dates, to grab cleanup lists
+				var dargs = Tools.getDateArgs( key_expires );
+				var cleanup_list_path = '_cleanup/' + dargs.yyyy + '/' + dargs.mm + '/' + dargs.dd;
+				unique_cleanup_lists[ cleanup_list_path ] = true;
+				
+				dargs = Tools.getDateArgs( log_expires );
+				cleanup_list_path = '_cleanup/' + dargs.yyyy + '/' + dargs.mm + '/' + dargs.dd;
+				unique_cleanup_lists[ cleanup_list_path ] = true;
+				
+				process.nextTick( callback );
+			},
+			function() {
+				// now queue up list copies for cleanup lists
+				self.logPrint(3, "Migrating cleanup lists");
+				
+				for (var key in unique_cleanup_lists) {
+					self.copyList( key, { ignore: true } );
+				}
+				
+				// Note: we are deliberately skipping the cleanup manager hash, e.g. _cleanup/expires
+				// This is only needed for records that CHANGE their expiration after the fact,
+				// which never happens with Cronicle.
+				
+				self.waitForQueue();
+			}
+		); // listEach
+	},
+	
+	waitForQueue: function() {
+		// wait for storage to complete queue
+		var self = this;
+		this.logPrint(3, "Waiting for storage queue");
+		
+		this.queueMax = this.newStorage.queue.length();
+		this.logDebug(5, "Queue length: " + this.queueMax);
+		
+		if (!args.verbose && !args.debug && !args.dryrun && !args.dots) {
+			cli.progress.start({ max: this.queueMax });
+		}
+		
+		this.newStorage.waitForQueueDrain( this.finish.bind(this) );
+	},
+	
+	finish: function() {
+		// all done
+		var self = this;
+		var elapsed = Tools.timeNow(true) - this.timeStart;
+		
+		cli.progress.end();
+		
+		print("\n");
+		this.logPrint(1, "Storage migration complete!");
+		this.logPrint(2, Tools.commify(this.numRecords) + " total records copied in " + Tools.getTextFromSeconds(elapsed, false, true) + ".");
+		this.logPrint(4, "You should now overwrite 'Storage' with 'NewStorage' in your config.json.");
+		this.logPrint(3, "Shutting down");
+		
+		async.series(
+			[
+				function(callback) {
+					self.oldStorage.shutdown(callback);
+				},
+				function(callback) {
+					self.newStorage.shutdown(callback);
+				},
+			],
+			function(err) {
+				self.logPrint(3, "Shutdown complete, exiting.");
+				print("\n");
+				process.exit(0);
+			}
+		); // series
+	},
+	
+	normalizeUsername: function(username) {
+		// lower-case, strip all non-alpha
+		if (!username) return '';
+		return username.toString().toLowerCase().replace(/\W+/g, '');
+	},
+	
+	copyKey: function(key, opts) {
+		// enqueue key for copy
+		this.logDebug(9, "Enqueuing key for copy: " + key, opts);
+		
+		this.newStorage.enqueue( Tools.mergeHashes({
+			action: 'custom',
+			copy_type: 'key',
+			copy_key: key,
+			handler: this.dequeue.bind(this)
+		}, opts || {} ));
+	},
+	
+	copyList: function(key, opts) {
+		// enqueue list for copy
+		this.logDebug(9, "Enqueuing list for copy: " + key, opts);
+		
+		this.newStorage.enqueue( Tools.mergeHashes({
+			action: 'custom',
+			copy_type: 'list',
+			copy_key: key,
+			handler: this.dequeue.bind(this)
+		}, opts || {} ));
+	},
+	
+	dequeue: function(task, callback) {
+		// copy list or key
+		var self = this;
+		var key = task.copy_key;
+		
+		switch (task.copy_type) {
+			case 'list':
+				// copy whole list
+				this.oldStorage.get(key, function(err, list) {
+					if (err) {
+						if (task.ignore) return callback();
+						self.fatal("Failed to get list: " + key + ": " + err);
+					}
+					
+					self.copyKey( key ); // list header
+					
+					for (var page_idx = list.first_page; page_idx <= list.last_page; page_idx++) {
+						self.copyKey( key + '/' + page_idx );
+					}
+					
+					callback();
+				});
+			break;
+			
+			default:
+				// copy record
+				if (this.newStorage.isBinaryKey(key)) {
+					// binary record, use streams
+					this.oldStorage.getStream(key, function(err, stream) {
+						if (err) {
+							if (task.ignore) return callback();
+							self.fatal("Failed to getStream key: " + key + ": " + err);
+						}
+						
+						if (args.dryrun) {
+							stream.on('end', function() {
+								verbose("DRY RUN: Copied binary record: " + key + "\n");
+								self.numRecords++;
+								callback();
+							});
+							stream.resume();
+							return;
+						}
+						
+						self.newStorage.putStream(key, stream, function(err) {
+							if (err) {
+								if (task.ignore) return callback();
+								self.fatal("Failed to putStream key: " + key + ": " + err);
+							}
+							
+							verbose("Copied binary record: " + key + "\n");
+							if (args.dots) print(".");
+							self.numRecords++;
+							
+							if (self.queueMax) {
+								var queueCurrent = self.newStorage.queue.length();
+								cli.progress.update( self.queueMax - queueCurrent );
+							}
+							
+							callback();
+						}); // putStream
+					} ); // getStream
+				}
+				else {
+					// standard JSON record
+					this.oldStorage.get(key, function(err, data) {
+						if (err) {
+							if (task.ignore) return callback();
+							self.fatal("Failed to get key: " + key + ": " + err);
+						}
+						
+						if (args.dryrun) {
+							verbose("DRY RUN: Copied record: " + key + "\n");
+							self.numRecords++;
+							return callback();
+						}
+						
+						self.newStorage.put(key, data, function(err) {
+							if (err) {
+								if (task.ignore) return callback();
+								self.fatal("Failed to put key: " + key + ": " + err);
+							}
+							
+							verbose("Copied record: " + key + "\n");
+							if (args.dots) print(".");
+							self.numRecords++;
+							
+							if (self.queueMax) {
+								var queueCurrent = self.newStorage.queue.length();
+								cli.progress.update( self.queueMax - queueCurrent );
+							}
+							
+							callback();
+						}); // put
+					} ); // get
+				}
+			break;
+		} // switch copy_type
+	},
+	
+	logDebug: function(level, msg, data) {
+		this.logger.debug( level, msg, data );
+	},
+	
+	logPrint: function(level, msg, data) {
+		// echo message to console and log it
+		switch (level) {
+			case 1: print( bold.yellow(msg) + "\n" ); break;
+			case 2: print( cyan(msg) + "\n" ); break;
+			case 3: print( green(msg) + "\n" ); break;
+			case 4: print( magenta(msg) + "\n" ); break;
+			case 9: print( gray(msg) + "\n" ); break;
+			default: print( msg + "\n" ); break;
+		}
+		if (data) print( gray( JSON.stringify(data) ) + "\n" );
+		this.logger.debug( level, msg, data );
+	},
+	
+	fatal: function(msg) {
+		// log fatal error and die
+		this.logger.error('fatal', msg);
+		die( "\n" + bold.red("ERROR: ") + bold(msg) + "\n\n" );
+	}
+	
+};
+
+StorageMigrator.run();
diff --git a/bin/test-plugin.js b/bin/test-plugin.js
new file mode 100644
index 0000000..019bbc7
--- /dev/null
+++ b/bin/test-plugin.js
@@ -0,0 +1,171 @@
+#!/usr/bin/env node
+
+// Test Plugin for Cronicle
+var js = require('fs');
+var JSONStream = require('pixl-json-stream');
+var Logger = require('pixl-logger');
+var Tools = require('pixl-tools');
+var Perf = require('pixl-perf');
+
+var perf = new Perf();
+perf.setScale( 1 ); // seconds
+perf.begin();
+
+// setup stdin / stdout streams 
+process.stdin.setEncoding('utf8');
+process.stdout.setEncoding('utf8');
+
+console.warn("Printed this with console.warn, should go to stderr, and thus straight to our logfile.");
+console.log("Printed this with console.log, should be ignored as not json, and also end up in our logfile.");
+
+if (process.argv.length > 2) console.log("ARGV: " + JSON.stringify(process.argv));
+
+/*process.on('SIGTERM', function() {
+	console.warn("Caught SIGTERM and ignoring it!  Hahahahaha!");
+} );*/
+
+var stream = new JSONStream( process.stdin, process.stdout );
+stream.on('json', function(job) {
+	// got job from parent 
+	var columns = ['hires_epoch', 'date', 'hostname', 'category', 'code', 'msg', 'data'];
+	var logger = new Logger( job.log_file, columns );
+	logger.set('hostname', job.hostname);
+	// logger.set('component', job.id);
+	logger.set('debugLevel', 9);
+	
+	logger.debug(1, "This is a test debug log entry");
+	logger.debug(9, "Here is our job, delivered via JSONStream:", job);
+	logger.debug(9, "The current date/time for our job is: " + (new Date(job.now * 1000)).toString() );
+	
+	// use some memory so we show up on the mem graph
+	var buf = null;
+	if (job.params.burn) {
+		buf = Buffer.alloc( 1024 * 1024 * Math.floor( 128 + (Math.random() * 128) ) );
+	}
+	
+	var start = Tools.timeNow();
+	var idx = 0;
+	var duration = 0;
+	
+	if (job.params.duration.toString().match(/^(\d+)\-(\d+)$/)) {
+		var low = RegExp.$1;
+		var high = RegExp.$2;
+		low = parseInt(low);
+		high = parseInt(high);
+		duration = Math.round( low + (Math.random() * (high - low)) );
+		logger.debug(9, "Chosen random duration: " + duration + " seconds");
+	}
+	else {
+		duration = parseInt( job.params.duration );
+	}
+	
+	var timer = setInterval( function() {
+		var now = Tools.timeNow();
+		var elapsed = now - start;
+		var progress = Math.min( elapsed / duration, 1.0 );
+		
+		if (buf) buf.fill( String.fromCharCode( Math.floor( Math.random() * 256 ) ) );
+		
+		if (job.params.progress) {
+			// report progress
+			logger.debug(9, "Progress: " + progress);
+			stream.write({
+				progress: progress
+			});
+		}
+		
+		idx++;
+		if (idx % 10 == 0) {
+			logger.debug(9, "Now is the ⏱ for all good 🏃 to come to the 🏥 of their 🇺🇸! " + progress);
+		}
+		
+		if (progress >= 1.0) {
+			logger.debug(9, "We're done!");
+			perf.end();
+			clearTimeout( timer );
+			
+			// insert some fake random stats into perf
+			var max = perf.scale * (duration / 5);
+			var rand_range = function(low, high) { return low + (Math.random() * (high - low)); };
+			
+			perf.perf.db_query = { end: 1, elapsed: rand_range(0, max * 0.3) };
+			perf.perf.db_connect = { end: 1, elapsed: rand_range(max * 0.2, max * 0.5) };
+			perf.perf.log_read = { end: 1, elapsed: rand_range(max * 0.4, max * 0.7) };
+			perf.perf.gzip_data = { end: 1, elapsed: rand_range(max * 0.6, max * 0.9) };
+			perf.perf.http_post = { end: 1, elapsed: rand_range(max * 0.8, max * 1) };
+			
+			// include a table with some stats
+			var table = {
+				title: "Sample Job Stats",
+				header: [
+					"IP Address", "DNS Lookup", "Flag", "Count", "Percentage"
+				],
+				rows: [
+					["62.121.210.2", "directing.com", "MaxEvents-ImpsUserHour-DMZ", 138, "0.0032%" ],
+					["97.247.105.50", "hsd2.nm.comcast.net", "MaxEvents-ImpsUserHour-ILUA", 84, "0.0019%" ],
+					["21.153.110.51", "grandnetworks.net", "InvalidIP-Basic", 20, "0.00046%" ],
+					["95.224.240.69", "hsd6.mi.comcast.net", "MaxEvents-ImpsUserHour-NM", 19, "0.00044%" ],
+					["72.129.60.245", "hsd6.nm.comcast.net", "InvalidCat-Domestic", 17, "0.00039%" ],
+					["21.239.78.116", "cable.mindsprung.com", "InvalidDog-Exotic", 15, "0.00037%" ],
+					["172.24.147.27", "cliento.mchsi.com", "MaxEvents-ClicksPer", 14, "0.00035%" ],
+					["60.203.211.33", "rgv.res.com", "InvalidFrog-Croak", 14, "0.00030%" ],
+					["24.8.8.129", "dsl.att.com", "Pizza-Hawaiian", 12, "0.00025%" ],
+					["255.255.1.1", "favoriteisp.com", "Random-Data", 10, "0%" ]
+				],
+				caption: "This is an example stats table you can generate from within your Plugin code."
+			};
+			
+			// include a custom html report
+			var html = {
+				title: "Sample Job Report",
+				content: "
This is a sample text report you can generate from within your Plugin code (can be HTML too).\n\n-------------------------------------------------\n          Date/Time | 2015-10-01 6:28:38 AM      \n       Elapsed Time | 1 hour 15 minutes          \n     Total Log Rows | 4,313,619                  \n       Skipped Rows | 15                         \n  Pre-Filtered Rows | 16,847                     \n             Events | 4,296,757                  \n        Impressions | 4,287,421                  \n Backup Impressions | 4,000                      \n             Clicks | 5,309 (0.12%)              \n      Backup Clicks | 27 (0.00062%)              \n       Unique Users | 1,239,502                  \n      Flagged Users | 1,651                      \n      Ignored Users | 1,025,910                  \n        Other Users | 211,941                    \n     Flagged Events | 6,575 (0.15%)              \nFlagged Impressions | 6,327 (0.14%)              \n     Flagged Clicks | 241 (4.53%)                \n       Memory Usage | 7.38 GB                    \n-------------------------------------------------
", + caption: "" + }; + + switch (job.params.action) { + case 'Success': + logger.debug(9, "Simulating a successful response"); + stream.write({ + complete: 1, + code: 0, + description: "Success!", + perf: perf.summarize(), + table: table, + html: html + }); + break; + + case 'Failure': + logger.debug(9, "Simulating a failure response"); + stream.write({ + complete: 1, + code: 999, + description: "Simulating an error message here. Something went wrong!", + perf: perf.summarize() + }); + break; + + case 'Crash': + logger.debug(9, "Simulating a crash"); + setTimeout( function() { + // process.exit(1); + throw new Error("Test Crash"); + }, 100 ); + break; + } + + // process.exit(0); + } + else { + // burn up some CPU so we show up on the chart + if (job.params.burn) { + var temp = Tools.timeNow(); + while (Tools.timeNow() - temp < 0.10) { + var x = Math.PI * 32768 / 100.3473847384 * Math.random(); + } + } + } + + }, 150 ); + +} ); diff --git a/bin/url-plugin.js b/bin/url-plugin.js new file mode 100644 index 0000000..07e18ff --- /dev/null +++ b/bin/url-plugin.js @@ -0,0 +1,174 @@ +#!/usr/bin/env node + +// URL Plugin for Cronicle +// Invoked via the 'HTTP Client' Plugin +// Copyright (c) 2017 Joseph Huckaby +// Released under the MIT License + +// Job Params: +// method, url, headers, data, timeout, follow, ssl_cert_bypass, success_match, error_match + +var fs = require('fs'); +var os = require('os'); +var cp = require('child_process'); +var path = require('path'); +var JSONStream = require('pixl-json-stream'); +var Tools = require('pixl-tools'); +var Request = require('pixl-request'); + +// setup stdin / stdout streams +process.stdin.setEncoding('utf8'); +process.stdout.setEncoding('utf8'); + +var stream = new JSONStream( process.stdin, process.stdout ); +stream.on('json', function(job) { + // got job from parent + var params = job.params; + var request = new Request(); + + var print = function(text) { + fs.appendFileSync( job.log_file, text ); + }; + + // timeout + request.setTimeout( (params.timeout || 0) * 1000 ); + + // ssl cert bypass + if (params.ssl_cert_bypass) { + process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0"; + } + + if (!params.url || !params.url.match(/^https?\:\/\/\S+$/i)) { + stream.write({ complete: 1, code: 1, description: "Malformed URL: " + (params.url || '(n/a)') }); + return; + } + + // allow URL to be substituted using [placeholders] + params.url = Tools.sub( params.url, job ); + + print("Sending HTTP " + params.method + " to URL:\n" + params.url + "\n"); + + // headers + if (params.headers) { + // allow headers to be substituted using [placeholders] + params.headers = Tools.sub( params.headers, job ); + + print("\nRequest Headers:\n" + params.headers.trim() + "\n"); + params.headers.replace(/\r\n/g, "\n").trim().split(/\n/).forEach( function(pair) { + if (pair.match(/^([^\:]+)\:\s*(.+)$/)) { + request.setHeader( RegExp.$1, RegExp.$2 ); + } + } ); + } + + // follow redirects + if (params.follow) request.setFollow( 32 ); + + var opts = { + method: params.method + }; + + // post data + if (opts.method == 'POST') { + // allow POST data to be substituted using [placeholders] + params.data = Tools.sub( params.data, job ); + + print("\nPOST Data:\n" + params.data.trim() + "\n"); + opts.data = Buffer.from( params.data || '' ); + } + + // matching + var success_match = new RegExp( params.success_match || '.*' ); + var error_match = new RegExp( params.error_match || '(?!)' ); + + // send request + request.request( params.url, opts, function(err, resp, data, perf) { + // HTTP code out of success range = error + if (!err && ((resp.statusCode < 200) || (resp.statusCode >= 400))) { + err = new Error("HTTP " + resp.statusCode + " " + resp.statusMessage); + err.code = resp.statusCode; + } + + // successmatch? errormatch? + var text = data ? data.toString() : ''; + if (!err) { + if (text.match(error_match)) { + err = new Error("Response contains error match: " + params.error_match); + } + else if (!text.match(success_match)) { + err = new Error("Response missing success match: " + params.success_match); + } + } + + // start building cronicle JSON update + var update = { + complete: 1 + }; + if (err) { + update.code = err.code || 1; + update.description = err.message || err; + } + else { + update.code = 0; + update.description = "Success (HTTP " + resp.statusCode + " " + resp.statusMessage + ")"; + } + + print( "\n" + update.description + "\n" ); + + // add raw response headers into table + if (resp && resp.rawHeaders) { + var rows = []; + print("\nResponse Headers:\n"); + + for (var idx = 0, len = resp.rawHeaders.length; idx < len; idx += 2) { + rows.push([ resp.rawHeaders[idx], resp.rawHeaders[idx + 1] ]); + print( resp.rawHeaders[idx] + ": " + resp.rawHeaders[idx + 1] + "\n" ); + } + + update.table = { + title: "HTTP Response Headers", + header: ["Header Name", "Header Value"], + rows: rows.sort( function(a, b) { + return a[0].localeCompare(b[0]); + } ) + }; + } + + // add response headers to chain_data if applicable + if (job.chain) { + update.chain_data = { + headers: resp.headers + }; + } + + // add raw response content, if text (and not too long) + if (text && resp.headers['content-type'] && resp.headers['content-type'].match(/(text|javascript|json|css|html)/i)) { + print("\nRaw Response Content:\n" + text.trim() + "\n"); + + if (text.length < 32768) { + update.html = { + title: "Raw Response Content", + content: "
" + text.replace(/"
+				};
+			}
+			
+			// if response was JSON and chain mode is enabled, chain parsed data
+			if (job.chain && (text.length < 1024 * 1024) && resp.headers['content-type'].match(/(application|text)\/json/i)) {
+				var json = null;
+				try { json = JSON.parse(text); }
+				catch (e) {
+					print("\nWARNING: Failed to parse JSON response: " + e + " (could not include JSON in chain_data)\n");
+				}
+				if (json) update.chain_data.json = json;
+			}
+		}
+		
+		if (perf) {
+			// passthru perf to cronicle
+			update.perf = perf.metrics();
+			print("\nPerformance Metrics: " + perf.summarize() + "\n");
+		}
+		
+		stream.write(update);
+	} );
+});
diff --git a/bin/worker b/bin/worker
new file mode 100644
index 0000000..6ce931c
--- /dev/null
+++ b/bin/worker
@@ -0,0 +1,12 @@
+#!/bin/sh
+HOMEDIR="$(dirname "$(cd -- "$(dirname "$(readlink -f "$0")")" && (pwd -P 2>/dev/null || pwd))")"
+
+if [ -f "$HOMEDIR/logs/cronicled.pid" ]; then
+  echo 'removing old pid file'
+  rm "$HOMEDIR/logs/cronicled.pid"
+fi
+
+$HOMEDIR/bin/control.sh start
+
+
+
diff --git a/bin/workflow.js b/bin/workflow.js
new file mode 100644
index 0000000..1bbb48e
--- /dev/null
+++ b/bin/workflow.js
@@ -0,0 +1,274 @@
+#!/usr/bin/env node
+
+const rl = require('readline').createInterface({ input: process.stdin });
+const PixlRequest = require('pixl-request');
+const request = new PixlRequest();
+
+process.stdin.setEncoding('utf8');
+process.stdout.setEncoding('utf8');
+
+let apikey = process.env['TEMP_KEY']
+let baseUrl = process.env['BASE_URL'] || 'http://localhost:3012'
+
+let taskList = []  // todo list
+let jobStatus = {}  // a map of launched jobs
+
+let errorCount = 0
+let wfStatus = 'running'
+let max_errors = parseInt(process.env['WF_MAXERR']);
+
+//console.log(process.env)
+
+function niceInterval(s, asLocatTime) {
+	let date = new Date(0);
+	date.setSeconds(parseInt(s));
+	if (asLocatTime) return date.toLocaleTimeString();
+	return date.toISOString().substr(11, 8);
+}
+
+function getJson(url, data) {
+	return new Promise((resolve, reject) => {
+		request.json(url, data, function (err, resp, data) {
+			if (err) return reject(err);
+			if (data.description) return reject(new Error(data.description))
+			resolve({ resp: resp, data: data })
+		});
+	});
+}
+
+function sleep(millis) { return new Promise(resolve => setTimeout(resolve, millis)) }
+
+async function abortPending() {
+	for (j in jobStatus) {
+		try {
+			let title = jobStatus[j].title;
+			if (!(jobStatus[j].completed)) {
+				let resp = await getJson(baseUrl + '/api/app/abort_job', { id: j, api_key: apikey })
+				console.log('SIGTERM sent to job ' + j);
+			}
+		}
+		catch (e) {
+			console.log('Failed to abort job: ' + j + ': ' + e.message);
+		}
+	}
+}
+
+
+// handle termination
+process.on('SIGTERM', async function () {
+	console.log("Caught SIGTERM, aborting pending jobs");
+
+	wfStatus = 'abort';
+	await abortPending();
+
+});
+
+// -------------------------- MAIN --------------------------------------------------------------//
+
+rl.on('line', function (line) {
+	// got line from stdin, parse JSON
+	console.log(JSON.stringify({ progress: 0.01 }))
+
+	const input = JSON.parse(line);
+	let concur = parseInt(process.env['WF_CONCUR'])
+
+	let wf_type = process.env['WF_TYPE'] || 'category'  // cat or event
+	let wf_strict = parseInt(process.env['WF_STRICT']) // report error on child failure (warning is default)
+	let eventid = process.env['WF_EVENT']
+	let event_params = process.env['WF_ARGS']
+	//let pendingJobs = 0;
+
+	async function poll() {
+
+		// get a list of tasks (either events of category or same job with different parameters)
+
+		if (wf_type == 'event') {  // run event N times
+			let evt = await getJson(baseUrl + '/api/app/get_event', { api_key: apikey, id: eventid })
+			if (evt.data.plugin == 'workflow') throw new Error('workflow events are not allowed for this action')
+			console.log(`Running event: \u001b[1m${evt.data.event.title}\u001b[22m  `)
+			if (!concur) concur = 1; // run one by one by default
+			if (concur > evt.data.event.max_children) concur = evt.data.event.max_children;
+			taskList = event_params.split(',').map(arg => {
+				return {
+					id: evt.data.event.id,
+					title: evt.data.event.title + `@${arg}`,
+					plugin: evt.data.event.plugin,
+					arg: arg,
+				}
+			});
+		}
+		else { // run all jobs in category
+			let sched = await getJson(baseUrl + '/api/app/get_schedule', { api_key: apikey })
+			console.log(`Running category: \u001b[1m${input.category_title}\u001b[22m  `)
+			taskList = sched.data.rows
+				.filter(t => t.category == input.category && t.id !== input.event && t.plugin != 'workflow')
+				.sort((a, b) => a.title.localeCompare(b.title))
+			if (concur > taskList.length || !concur) concur = taskList.length
+		}
+
+		// -----------------
+		// get list of running events (prior to wf). This also asserts api endpoint
+		let r = await getJson(baseUrl + '/api/app/get_active_jobs', { api_key: apikey })
+		let currActive = []
+		for (let id in r.data.jobs) { currActive.push(r.data.jobs[id].event) }
+
+		console.log('Job Schedule:');
+		let s = 1;
+		let lineLen = 0;
+		taskList.forEach(e => {
+			msg = ` ${s})  ${e.title} (${e.id}), plugin: ${e.plugin} `
+			//if (wf_type == 'event') msg += '| ARG = ' + e.arg + '  ';
+			if (wf_type == 'category' && currActive.includes(e.id)) msg += `⚠️ already in progress  `
+			lineLen = lineLen > msg.length ? lineLen : msg.length
+			console.log(msg);
+			s += 1;
+		});
+		console.log('-'.repeat(lineLen));
+		console.log(`\n\n\u001b[1m\u001b[32mWorkflow Started\u001b[39m\u001b[22m @ ${(new Date()).toLocaleString()}\n |  `)
+
+		let pendingJobs = taskList.length
+
+		// launch first batch of jobs
+		for (q = 0; q < concur; q++) {
+			let task = taskList[q];
+			console.log(` | 🚀 --> starting ${task.title}  `);
+
+			try {
+				let job = await getJson(baseUrl + '/api/app/run_event', { id: task.id, api_key: apikey, arg: task.arg || 0 });
+				if (job.data.queue) throw new Error("Event has beed added to internal queue and will run independently from this WF")
+				jobStatus[job.data.ids[0]] = { event: task.id, title: task.title, arg: task.arg, completed: false, code: 0 }
+
+			}
+			catch (e) {
+				errorCount += 1;
+				jobStatus[task.id] = {
+					event: task.id,
+					title: task.title,
+					arg: task.arg,
+					completed: false,
+					code: 1,
+					description: e.message,
+					elapsed: 0
+				}
+				continue;
+			}
+		}
+
+		console.log(' |  ');
+
+		let next = concur;
+		// begin polling
+		while (pendingJobs) {
+
+			await sleep(1000);
+
+			let resp = await getJson(baseUrl + '/api/app/get_active_jobs', { api_key: apikey })
+			let activeJobs = resp.data.jobs
+
+			let rerunList = {};
+			for (r in activeJobs) { if (activeJobs[r].when) { rerunList[activeJobs[r].id] = activeJobs[r].when } }
+
+			for (j in jobStatus) {
+
+				if (jobStatus[j].completed) continue // do nothing if completed
+
+				if (!(resp.data.jobs[j])) {  // if job is not in active job list mark as completed
+					jobStatus[j].completed = true
+					pendingJobs -= 1
+
+					let msg = '';
+
+					// in case job is waiting to rerun on failure, just report error and release it from WF
+					if (rerunList[j]) {
+						errorCount += 1
+						msg = ` | ❌ ${jobStatus[j].title} failed, but scheduled to rerun at ${niceInterval(rerunList[j], true)}. Releasing job ${j} from workflow`
+					}
+					// if job failed to start
+					else if (jobStatus[j].code) { msg = ` | 💥 ${jobStatus[j].title}: ${jobStatus[j].description}` }
+					// normal handling - look up job stats in history
+					else {
+						// check job status
+						let jstat = "";
+						let desc = "  ";
+						await sleep(30); // a little lag to avoid "job not found" error
+						let jd = await getJson(baseUrl + '/api/app/get_job_details', { id: j, api_key: apikey })
+						let compl = jd.data.job;
+						if (compl) {
+							if (compl.code == 0) { jstat = '✔️' }
+							else if (compl.code == 255) { jstat = '⚠️'; desc = `\n |    warn: \u001b[33m${compl.description}\u001b[39m  ` }
+							else {
+								errorCount += 1;
+								jstat = '❌';
+								desc = `\n |    err: \u001b[31m${compl.description}\u001b[39m  `
+								if (max_errors && errorCount >= max_errors) wfStatus = 'abort'; // prevent launching new jobs
+
+							}
+							jobStatus[j].elapsed = compl.elapsed
+						}
+						msg = ` | ${jstat} ${jobStatus[j].title} (job ${j}) completed at ${(new Date()).toLocaleTimeString()}\n |      \u001b[33melapsed in ${niceInterval(jobStatus[j].elapsed)}\u001b[39m  ${desc}`
+					}
+
+					msg += (pendingJobs ? `\n |    [ ${pendingJobs} more job(s) to go ]  ` : '  ')
+
+					// starting next job in queue
+					if (next < taskList.length && wfStatus != 'abort') {
+
+						let task = taskList[next];
+
+						msg += `--> starting \u001b[1m${task.title}\u001b[22m 🚀  `;
+						//if (taskList[next].arg) msg += `[ARG=${taskList[next].arg}]  `;
+						try {
+							let job = await getJson(baseUrl + '/api/app/run_event', { id: task.id, api_key: apikey, arg: task.arg || 0 });
+							if (job.data.queue) throw new Error("Event has beed added to internal queue and will run independently from this WF")
+							jobStatus[job.data.ids[0]] = { event: task.id, arg: task.arg, title: task.title, completed: false, code: 0 }
+						}
+						catch (e) {
+							errorCount += 1;
+							jobStatus[task.id] = {
+								event: task.id,
+								arg: task.arg,
+								title: task.title, completed: false,
+								code: 1, description: e.message, elapsed: 0
+							}
+						}
+						next += 1
+					}
+
+					console.log(msg);
+
+					if (max_errors && errorCount >= max_errors) {
+						console.log(" |\n ⚠️ Error count exceeded maximum, aborting workflow...")
+						wfStatus = 'abort';
+						await abortPending();
+						throw new Error("WF Error count exceeded maximum");
+
+					}
+
+					console.log(' |');
+					console.log(JSON.stringify({ progress: (1 - pendingJobs / taskList.length) }))
+				}
+			}
+		}
+
+		console.log(`\n\u001b[1m\u001b[32mWorkflow Completed\u001b[39m\u001b[22m @${(new Date()).toLocaleString()}  `)
+
+		// print performance
+		let perf = {}
+		Object.keys(jobStatus).forEach(key => {
+			let perf_key = jobStatus[key].arg ? 'arg: ' + jobStatus[key].arg : jobStatus[key].title
+			perf[perf_key] = jobStatus[key].elapsed || 0
+		})
+		console.log(JSON.stringify({ perf: perf }))
+
+		let result = { complete: 1, code: 0 }
+		if (errorCount > 0) result = { complete: 1, code: (wf_strict ? 1 : 255), description: `WF - ${errorCount} out of ${taskList.length} jobs reported error` }
+		if (errorCount == taskList.length) result = { complete: 1, code: 1, description: "WF - All jobs failed" }
+		console.log(JSON.stringify(result))
+
+
+	};
+
+	poll().catch(e => console.log(JSON.stringify({ complete: 1, code: 1, description: e.message })));
+	rl.close();
+
+});
\ No newline at end of file
diff --git a/htdocs/blank.html b/htdocs/blank.html
new file mode 100644
index 0000000..3bf1273
--- /dev/null
+++ b/htdocs/blank.html
@@ -0,0 +1,10 @@
+
+
+	
+		
+		Blank
+	
+	
+		 
+	
+
\ No newline at end of file
diff --git a/htdocs/css/style.css b/htdocs/css/style.css
new file mode 100644
index 0000000..5220657
--- /dev/null
+++ b/htdocs/css/style.css
@@ -0,0 +1,590 @@
+/* Styles for Cronicle */
+/* Theme colors: #3f7ed5, #5890db, #7cafda, #9ccffa */
+
+div.container {
+	min-width: 750px;
+}
+
+#d_header_logo {
+	position: relative;
+	width: 40px;
+	height: 40px;
+	background: url(/images/clock-bkgnd.png) no-repeat center center;
+	background-size: 36px 36px;
+}
+
+.header_clock_layer {
+	position: absolute;
+	left: 0px;
+	top: 0px;
+	width: 40px;
+	height: 40px;
+	background-repeat: no-repeat;
+	background-position: center center;
+	background-size: 36px 36px;
+	opacity: 0;
+	
+	transform-origin: 50% 50%;
+	-webkit-transform-origin: 50% 50%;
+	
+	transform: rotateZ(0deg);
+	-webkit-transform: rotateZ(0deg);
+	
+	/* transition: all 0.5s ease-in-out;
+	-webkit-transition: all 0.5s ease-in-out; */
+}
+
+#d_header_clock_hour {
+	background-image: url(/images/clock-hour.png);
+}
+
+#d_header_clock_minute {
+	background-image: url(/images/clock-minute.png);
+}
+
+#d_header_clock_second {
+	background-image: url(/images/clock-second.png);
+}
+
+#d_tab_time {
+	cursor: default;
+	opacity: 0;
+	color: #888;
+}
+
+#d_tab_manager {
+	cursor: default;
+}
+#d_tab_manager.active {
+	cursor: pointer;
+}
+#d_tab_manager.active:hover {
+	color: #036 !important;
+	text-decoration: underline;
+}
+
+#d_scroll_time {
+	position: fixed;
+	box-sizing: border-box;
+	top: -30px;
+	left: 100%;
+	margin-left: -180px;
+	width: 180px;
+	height: 20px;
+	line-height: 20px;
+	
+	background: #f8f8f8;
+	border-left: 1px solid #ddd;
+	border-bottom: 1px solid #ddd;
+	
+	font-size: 12px;
+	font-weight: bold;
+	text-align: center;
+	color: #777;
+	text-shadow: #fff 1px 1px;
+	box-shadow: rgba(0,0,0,0.05) 0px 2px 3px;
+	
+	z-index: 10003;
+}
+
+/* Menus */
+
+.subtitle_menu {
+	-webkit-appearance: none;
+	-moz-appearance: none;
+	appearance: none;
+	cursor: pointer;
+	outline: none;
+	border: none;
+	
+	font-size: 12px;
+	font-weight: bold;
+	color: #999;
+	background-color: white;
+	
+	max-width: 100px;
+	white-space: nowrap;
+	overflow: hidden;
+	text-overflow: ellipsis;	
+}
+.subtitle_menu:hover {
+	color: #3f7ed5;
+}
+
+/* Material Design Icons Additions */
+
+.mdi-lg {
+	/* Larger MD Icon */
+	transform-origin: 50% 50%;
+	-webkit-transform-origin: 50% 50%;
+	
+	transform: scale(1.25);
+	-webkit-transform: scale(1.25);
+}
+
+/* Timing Params */
+
+div.timing_details_label {
+	font-size: 12px;
+	font-weight: bold;
+	color: #3f7ed5;
+	cursor: default;
+	text-shadow: 0px 1px 0px white;
+}
+
+div.timing_details_content {
+	margin-top: 1px;
+	margin-bottom: 10px;
+	font-weight: bold;
+	color: #555;
+	line-height: 21px;
+}
+
+/* Plugin Params */
+
+div.plugin_params_label {
+	font-size: 12px;
+	font-weight: bold;
+	color: #3f7ed5;
+	cursor: default;
+	text-shadow: 0px 1px 0px white;
+}
+
+div.plugin_params_content {
+	margin-top: 1px;
+	margin-bottom: 10px;
+	font-weight: bold;
+	color: #555;
+}
+
+div.plugin_params_content input, div.plugin_params_content select, div.plugin_params_content label {
+	font-size: 12px;
+}
+
+body.chrome div.plugin_params_content label {
+	/* Chrome hack */
+	position: relative;
+	top: -2px;
+}
+
+.std_combo_unit_table input { font-size: 14px; }
+.std_combo_unit_table select { font-size: 12px; }
+
+.fieldset_params_table td {
+	font-weight: normal;
+	padding-right: 6px;
+}
+.fieldset_params_table input, .fieldset_params_table select, .fieldset_params_table label {
+	font-size: 12px;
+}
+.fieldset_params_table label {
+	font-weight: normal;
+	color: #555;
+}
+
+/* Pies */
+
+@media only screen and (min-width: 1020px) and (max-width: 1100px) {
+	div.pie-column {
+		transform: scale(0.9);
+	}
+}
+@media only screen and (min-width: 950px) and (max-width: 1020px) {
+	div.pie-column {
+		transform: scale(0.8);
+	}
+}
+@media only screen and (min-width: 880px) and (max-width: 950px) {
+	div.pie-column {
+		transform: scale(0.7);
+	}
+}
+@media only screen and (max-width: 880px) {
+	div.pie-column {
+		transform: scale(0.6);
+	}
+}
+
+div.pie-column {
+	width: 321px;
+}
+div.pie-column.column-left {
+	position: absolute; 
+	left: 0;
+}
+div.pie-column.column-center {
+	margin: 0 auto 0 auto; 
+	width: 321px; 
+	position: relative
+}
+div.pie-column.column-right {
+	position: absolute; 
+	left: 100%; 
+	margin-left: -321px;
+}
+
+div.pie-title {
+	width: 250px; 
+	height: 20px; 
+	line-height: 15px; 
+	text-align: center; 
+	font-weight: bold; 
+	font-size: 15px; 
+	color: #3f7ed5;
+}
+
+canvas.pie {
+	display: inline-block;
+}
+
+div.pie-overlay {
+	position: absolute; 
+	top: 20px; 
+	width: 250px; 
+	height: 250px; 
+	z-index: 2
+}
+
+div.pie-overlay-title {
+	margin-top: 103px; 
+	font-size: 24px; 
+	font-weight: bold; 
+	text-align: center;
+}
+
+div.pie-overlay-subtitle {
+	font-size: 14px; 
+	font-weight: bold; 
+	text-align: center;
+	color: #aaa;
+}
+
+div.pie-legend-column {
+	display: inline-block; 
+	position: absolute;
+	vertical-align: top; 
+	/* width: 66px;  */
+	height: 250px; 
+	margin-left: 5px;
+	overflow-y: auto;
+}
+
+div.pie-legend-container {
+	/* width: 66px; */
+	max-width: 130px;
+}
+
+div.pie-legend-item {
+	/* width: 60px; */
+	min-width: 30px;
+	max-width: 120px;
+	height: 11px;
+	margin-bottom: 6px;
+	font-size: 11px;
+	font-weight: bold;
+	overflow: hidden;
+	text-overflow: ellipsis;
+	border-radius: 4px;
+	padding: 3px 3px 3px 3px;
+	color: white;
+	text-shadow: 0px 1px 1px black;
+	cursor: default;
+}
+
+/* Line Charts */
+
+div.graph-title {
+	height: 20px; 
+	line-height: 15px; 
+	text-align: center; 
+	font-weight: bold; 
+	font-size: 15px; 
+	color: #3f7ed5;
+}
+
+.c3-line { stroke-width: 2px !important; }
+.c3-legend-item { font-size: 13px !important; }
+.c3-axis { font-size: 12px !important; fill: #888 !important; }
+.c3-axis path { stroke: #ccc !important; }
+.c3-axis .tick line { stroke: #ccc !important; }
+.c3-grid line { stroke: #ccc !important; }
+
+/* Live Log Tail */
+
+pre.log_chunk {
+	margin: 0;
+	padding: 0;
+}
+
+/* Data Table Row Colors */
+
+.data_table tr.plain td, .swatch.plain {
+	background-color: #efefef;
+	/* background-image: linear-gradient(to top, rgba(255, 255, 255, 0) 0%, rgba(255, 255, 255, 1) 100%); */
+}
+.data_table tr.red td, .swatch.red {
+	background-color: #ffe0e0;
+	/* background-image: linear-gradient(to top, rgba(255, 255, 255, 0) 0%, rgba(255, 255, 255, 1) 100%); */
+}
+.data_table tr.green td, .swatch.green {
+	background-color: #d8ffd8;
+	/* background-image: linear-gradient(to top, rgba(255, 255, 255, 0) 0%, rgba(255, 255, 255, 1) 100%); */
+}
+.data_table tr.blue td, .swatch.blue {
+	background-color: #e0f0ff;
+	/* background-image: linear-gradient(to top, rgba(255, 255, 255, 0) 0%, rgba(255, 255, 255, 1) 100%); */
+}
+.data_table tr.skyblue td, .swatch.skyblue {
+	background-color: #e0ffff;
+	/* background-image: linear-gradient(to top, rgba(255, 255, 255, 0) 0%, rgba(255, 255, 255, 1) 100%); */
+}
+.data_table tr.yellow td, .swatch.yellow {
+	background-color: #ffffd0;
+	/* background-image: linear-gradient(to top, rgba(255, 255, 255, 0) 0%, rgba(255, 255, 255, 1) 100%); */
+}
+.data_table tr.purple td, .swatch.purple {
+	background-color: #ffe0ff;
+	/* background-image: linear-gradient(to top, rgba(255, 255, 255, 0) 0%, rgba(255, 255, 255, 1) 100%); */
+}
+.data_table tr.orange td, .swatch.orange {
+	background-color: #ffe8d0;
+	/* background-image: linear-gradient(to top, rgba(255, 255, 255, 0) 0%, rgba(255, 255, 255, 1) 100%); */
+}
+
+/* Misc */
+
+div.activity_desc > b {
+	word-break: break-all;
+}
+
+.link {
+	user-select: none;
+	-moz-user-select: none;
+	-webkit-user-select: none;
+}
+
+.link.addme {
+	/*font-weight: normal !important;*/
+	font-weight: bold;
+	color: #777;
+	margin-left: 5px;
+	text-decoration: none !important;
+}
+.link.addme:hover {
+	text-decoration: underline !important;
+}
+
+.swatch {
+	float: left;
+	width: 50px;
+	height: 20px;
+	border: 1px solid #fff;
+	border-radius: 5px;
+	margin: 1px;
+	/* background-image: linear-gradient(to top, rgba(255, 255, 255, 0) 0%, rgba(255, 255, 255, 0.5) 100%) !important; */
+	cursor: pointer;
+}
+.swatch:hover {
+	border: 1px solid #ddd;
+}
+.swatch.active {
+	border: 1px solid #888;
+}
+
+tr.collapse {
+	display: none;
+}
+
+div.schedule_group_header {
+	margin-top: 11px;
+	font-size: 14px;
+	line-height: 16px;
+	color: #888;
+}
+
+div.schedule_group_button_container > i {
+	cursor: pointer;
+	margin-left: 5px;
+	/* padding: 2px;
+	box-shadow: 0px 0px 0px 1px #ccc; */
+}
+div.schedule_group_button_container > i:hover {
+	color:#036;
+}
+div.schedule_group_button_container > i.selected {
+	color: #3f7ed5;
+	cursor: default !important;
+}
+div.schedule_group_button_container > i.selected:hover {
+	color: #3f7ed5 !important;
+}
+
+/* Password Stuff */
+
+.password_toggle {
+	display: inline-block;
+	width: 32px;
+	line-height: 15px;
+	font-size: 11px;
+	padding-left: 5px;
+}
+
+.psi_container {
+	box-sizing: border-box;
+	position: relative;
+	overflow: hidden;
+	margin: 2px 0px 2px 0px;
+	height: 8px;
+	border: 1px solid #eee;
+	cursor: pointer;
+}
+body.safari div.psi_container {
+	/* Safari has a weird margin issue with this thing */
+	margin-left: 2px !important;
+}
+
+.psi_bar {
+	box-sizing: border-box;
+	width: 0%;
+	height: 6px;
+	transition: all 0.5s ease;
+	-webkit-transition: all 0.5s ease;
+}
+.psi_bar.str0 {
+	width: 20%;
+	background: red;
+	border-right: 1px solid #eee;
+}
+.psi_bar.str1 {
+	width: 40%;
+	background: red;
+	border-right: 1px solid #eee;
+}
+.psi_bar.str2 {
+	width: 60%;
+	background: yellow;
+	border-right: 1px solid #eee;
+}
+.psi_bar.str3 {
+	width: 80%;
+	background: rgb(0, 255, 0);
+	border-right: 1px solid #eee;
+}
+.psi_bar.str4 {
+	width: 100%;
+	background: rgb(0, 255, 0);
+	border-right: none;
+}
+
+/* Slider */
+
+input[type=range] {
+	-webkit-appearance: none;
+	margin: 10px 0;
+	width: 100%;
+}
+input[type=range]:focus {
+	outline: none;
+}
+input[type=range]::-webkit-slider-runnable-track {
+	width: 100%;
+	height: 8px;
+	cursor: pointer;
+	background: #ccc;
+	background-image: linear-gradient(to bottom, #eee 0%, #ccc 100%);
+	box-shadow: 1px 1px 1px #aaa inset, -1px -1px 1px #eee inset;
+	border-radius: 5px;
+}
+input[type=range]::-webkit-slider-thumb {
+	border: 0px solid #000000;
+	height: 20px;
+	width: 24px;
+	border-radius: 10px;
+	background: #ccc;
+	background-image: linear-gradient(to bottom, #ddd 0%, #999 100%);
+	box-shadow: 1px 1px 0px #eee inset, -1px -1px 0px #999 inset;
+	cursor: pointer;
+	-webkit-appearance: none;
+	margin-top: -6px;
+}
+
+input[type=range]::-moz-range-track {
+	width: 100%;
+	height: 8px;
+	cursor: pointer;
+	background: #ccc;
+	background-image: linear-gradient(to bottom, #eee 0%, #ccc 100%);
+	box-shadow: 1px 1px 1px #aaa inset, -1px -1px 1px #eee inset;
+	border-radius: 5px;
+}
+input[type=range]::-moz-range-thumb {
+	border: 0px solid #000000;
+	height: 20px;
+	width: 24px;
+	border-radius: 10px;
+	background: #ccc;
+	background-image: linear-gradient(to bottom, #ddd 0%, #999 100%);
+	box-shadow: 1px 1px 0px #eee inset, -1px -1px 0px #999 inset;
+	cursor: pointer;
+	margin-top: -6px;
+}
+
+/* Date/Time Dialog */
+
+fieldset.dt_fs {
+	margin-bottom: 4px;
+	padding: 2px 5px 5px 5px;
+}
+fieldset.dt_fs > legend {
+	font-size: 11px;
+	text-transform: uppercase;
+}
+
+/* Table Pagination Spacing Fix */
+
+div.pagination > table tr td {
+	white-space: nowrap;
+}
+
+/* Subtitle Widget Adjustments */
+
+.subtitle_widget a, .subtitle_widget span.link {
+	color: #777;
+	text-decoration: none;
+}
+.subtitle_widget a:hover, .subtitle_widget span.link:hover {
+	text-decoration: underline;
+	color: #036;
+}
+#s_watch_job:hover {
+	color: #036 !important;
+}
+
+span.link.abort {
+	color: #777;
+}
+span.link.abort:hover {
+	color: red;
+}
+
+/* Dialog Fixes */
+
+div.dialog_subtitle {
+	cursor: default;
+}
+
+/* Cursor fix */
+
+td.table_label {
+	cursor: default;
+}
+
+/* User Category/Group Privilege Checkbox Label Augmentation */
+
+#fe_eu_priv_cat_limit:checked + label:after {
+	content: ':'
+}
+
+#fe_eu_priv_grp_limit:checked + label:after {
+	content: ':'
+}
\ No newline at end of file
diff --git a/htdocs/custom/config.html b/htdocs/custom/config.html
new file mode 100644
index 0000000..c3a913a
--- /dev/null
+++ b/htdocs/custom/config.html
@@ -0,0 +1,48 @@
+
+
+
+
+    
+    
+    Config Viewer
+
+    
+    
+    
+    
+    
+
+    
+
+
+
+
+
+    
+ + Go Back +
+
+ + + + + + \ No newline at end of file diff --git a/htdocs/custom/console.html b/htdocs/custom/console.html new file mode 100644 index 0000000..618ef9e --- /dev/null +++ b/htdocs/custom/console.html @@ -0,0 +1,92 @@ + + + + + + + Cronicle Console + + + + + + +
+
+ + + + + + \ No newline at end of file diff --git a/htdocs/custom/dashboard.html b/htdocs/custom/dashboard.html new file mode 100644 index 0000000..cddf01c --- /dev/null +++ b/htdocs/custom/dashboard.html @@ -0,0 +1,168 @@ + + + + + + + Event Dashboard + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+

Event Dashboard

+ +
+
+
Filter Event
+
Filter Category
+
+
+
+
+ + +
+
+
+ +
+ + + + + + \ No newline at end of file diff --git a/htdocs/favicon.ico b/htdocs/favicon.ico new file mode 100644 index 0000000..6c7da6b Binary files /dev/null and b/htdocs/favicon.ico differ diff --git a/htdocs/images/clock-bkgnd.png b/htdocs/images/clock-bkgnd.png new file mode 100644 index 0000000..4dccfdb Binary files /dev/null and b/htdocs/images/clock-bkgnd.png differ diff --git a/htdocs/images/clock-hour.png b/htdocs/images/clock-hour.png new file mode 100644 index 0000000..a32cde6 Binary files /dev/null and b/htdocs/images/clock-hour.png differ diff --git a/htdocs/images/clock-minute.png b/htdocs/images/clock-minute.png new file mode 100644 index 0000000..a58976d Binary files /dev/null and b/htdocs/images/clock-minute.png differ diff --git a/htdocs/images/clock-second.png b/htdocs/images/clock-second.png new file mode 100644 index 0000000..51d780f Binary files /dev/null and b/htdocs/images/clock-second.png differ diff --git a/htdocs/images/loading-16.gif b/htdocs/images/loading-16.gif new file mode 100644 index 0000000..5b33f7e Binary files /dev/null and b/htdocs/images/loading-16.gif differ diff --git a/htdocs/images/loading-24.gif b/htdocs/images/loading-24.gif new file mode 100644 index 0000000..1c72ebb Binary files /dev/null and b/htdocs/images/loading-24.gif differ diff --git a/htdocs/images/loading.gif b/htdocs/images/loading.gif new file mode 100644 index 0000000..f864d5f Binary files /dev/null and b/htdocs/images/loading.gif differ diff --git a/htdocs/images/logo-1024.png b/htdocs/images/logo-1024.png new file mode 100644 index 0000000..75cd6d8 Binary files /dev/null and b/htdocs/images/logo-1024.png differ diff --git a/htdocs/images/logo-128.png b/htdocs/images/logo-128.png new file mode 100644 index 0000000..7d2a37a Binary files /dev/null and b/htdocs/images/logo-128.png differ diff --git a/htdocs/images/logo-256.png b/htdocs/images/logo-256.png new file mode 100644 index 0000000..9bfcd47 Binary files /dev/null and b/htdocs/images/logo-256.png differ diff --git a/htdocs/images/logo-512.png b/htdocs/images/logo-512.png new file mode 100644 index 0000000..09e2ee5 Binary files /dev/null and b/htdocs/images/logo-512.png differ diff --git a/htdocs/images/logo-64.png b/htdocs/images/logo-64.png new file mode 100644 index 0000000..5a2b9a8 Binary files /dev/null and b/htdocs/images/logo-64.png differ diff --git a/htdocs/index-dev.html b/htdocs/index-dev.html new file mode 100644 index 0000000..f95dc3b --- /dev/null +++ b/htdocs/index-dev.html @@ -0,0 +1,195 @@ + + + + + + Loading... + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
 
+ +
+
+ +
+
+
+
+
+
+ +
+ + + +
+ + + + + + + +
+ +
+ + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/htdocs/js/app.js b/htdocs/js/app.js new file mode 100644 index 0000000..5dca99e --- /dev/null +++ b/htdocs/js/app.js @@ -0,0 +1,1853 @@ +// Cronicle Web App +// Author: Joseph Huckaby +// Copyright (c) 2015 Joseph Huckaby and PixlCore.com + +if (!window.app) throw new Error("App Framework is not present."); + +app.extend({ + + name: '', + preload_images: ['loading.gif'], + activeJobs: {}, + eventQueue: {}, + state: null, + plain_text_post: true, + clock_visible: false, + scroll_time_visible: false, + default_prefs: { + schedule_group_by: 'category' + }, + + fa_icons: { + "f2b9": "fa-address-book", + "f2ba": "fa-address-book-o", + "f2bb": "fa-address-card", + "f2bc": "fa-address-card-o", + "f042": "fa-adjust", + "f170": "fa-adn", + "f037": "fa-align-center", + "f039": "fa-align-justify", + "f036": "fa-align-left", + "f038": "fa-align-right", + "f270": "fa-amazon", + "f0f9": "fa-ambulance", + "f2a3": "fa-american-sign-language-interpreting", + "f13d": "fa-anchor", + "f17b": "fa-android", + "f209": "fa-angellist", + "f103": "fa-angle-double-down", + "f100": "fa-angle-double-left", + "f101": "fa-angle-double-right", + "f102": "fa-angle-double-up", + "f107": "fa-angle-down", + "f104": "fa-angle-left", + "f105": "fa-angle-right", + "f106": "fa-angle-up", + "f179": "fa-apple", + "f187": "fa-archive", + "f1fe": "fa-area-chart", + "f0ab": "fa-arrow-circle-down", + "f0a8": "fa-arrow-circle-left", + "f01a": "fa-arrow-circle-o-down", + "f190": "fa-arrow-circle-o-left", + "f18e": "fa-arrow-circle-o-right", + "f01b": "fa-arrow-circle-o-up", + "f0a9": "fa-arrow-circle-right", + "f0aa": "fa-arrow-circle-up", + "f063": "fa-arrow-down", + "f060": "fa-arrow-left", + "f061": "fa-arrow-right", + "f047": "fa-arrows", + "f0b2": "fa-arrows-alt", + "f07e": "fa-arrows-h", + "f07d": "fa-arrows-v", + "f062": "fa-arrow-up", + "f2a2": "fa-assistive-listening-systems", + "f069": "fa-asterisk", + "f1fa": "fa-at", + "f29e": "fa-audio-description", + "f1b9": "fa-automobile", + "f04a": "fa-backward", + "f24e": "fa-balance-scale", + "f05e": "fa-ban", + "f2d5": "fa-bandcamp", + "f19c": "fa-bank", + "f080": "fa-bar-chart", + "f02a": "fa-barcode", + "f0c9": "fa-bars", + "f2cd": "fa-bath", + "f240": "fa-battery", + "f244": "fa-battery-0", + "f243": "fa-battery-1", + "f242": "fa-battery-2", + "f241": "fa-battery-3", + "f236": "fa-bed", + "f0fc": "fa-beer", + "f1b4": "fa-behance", + "f1b5": "fa-behance-square", + "f0f3": "fa-bell", + "f0a2": "fa-bell-o", + "f1f6": "fa-bell-slash", + "f1f7": "fa-bell-slash-o", + "f206": "fa-bicycle", + "f1e5": "fa-binoculars", + "f1fd": "fa-birthday-cake", + "f171": "fa-bitbucket", + "f172": "fa-bitbucket-square", + "f15a": "fa-bitcoin", + "f27e": "fa-black-tie", + "f29d": "fa-blind", + "f293": "fa-bluetooth", + "f294": "fa-bluetooth-b", + "f032": "fa-bold", + "f0e7": "fa-bolt", + "f1e2": "fa-bomb", + "f02d": "fa-book", + "f02e": "fa-bookmark", + "f097": "fa-bookmark-o", + "f2a1": "fa-braille", + "f0b1": "fa-briefcase", + "f188": "fa-bug", + "f1ad": "fa-building", + "f0f7": "fa-building-o", + "f0a1": "fa-bullhorn", + "f140": "fa-bullseye", + "f207": "fa-bus", + "f20d": "fa-buysellads", + "f1ba": "fa-cab", + "f1ec": "fa-calculator", + "f073": "fa-calendar", + "f274": "fa-calendar-check-o", + "f272": "fa-calendar-minus-o", + "f133": "fa-calendar-o", + "f271": "fa-calendar-plus-o", + "f273": "fa-calendar-times-o", + "f030": "fa-camera", + "f083": "fa-camera-retro", + "f0d7": "fa-caret-down", + "f0d9": "fa-caret-left", + "f0da": "fa-caret-right", + "f150": "fa-caret-square-o-down", + "f191": "fa-caret-square-o-left", + "f152": "fa-caret-square-o-right", + "f151": "fa-caret-square-o-up", + "f0d8": "fa-caret-up", + "f218": "fa-cart-arrow-down", + "f217": "fa-cart-plus", + "f20a": "fa-cc", + "f1f3": "fa-cc-amex", + "f24c": "fa-cc-diners-club", + "f1f2": "fa-cc-discover", + "f24b": "fa-cc-jcb", + "f1f1": "fa-cc-mastercard", + "f1f4": "fa-cc-paypal", + "f1f5": "fa-cc-stripe", + "f1f0": "fa-cc-visa", + "f0a3": "fa-certificate", + "f0c1": "fa-chain", + "f127": "fa-chain-broken", + "f00c": "fa-check", + "f058": "fa-check-circle", + "f05d": "fa-check-circle-o", + "f14a": "fa-check-square", + "f046": "fa-check-square-o", + "f13a": "fa-chevron-circle-down", + "f137": "fa-chevron-circle-left", + "f138": "fa-chevron-circle-right", + "f139": "fa-chevron-circle-up", + "f078": "fa-chevron-down", + "f053": "fa-chevron-left", + "f054": "fa-chevron-right", + "f077": "fa-chevron-up", + "f1ae": "fa-child", + "f268": "fa-chrome", + "f111": "fa-circle", + "f10c": "fa-circle-o", + "f1ce": "fa-circle-o-notch", + "f1db": "fa-circle-thin", + "f0ea": "fa-clipboard", + "f017": "fa-clock-o", + "f24d": "fa-clone", + "f00d": "fa-close", + "f0c2": "fa-cloud", + "f0ed": "fa-cloud-download", + "f0ee": "fa-cloud-upload", + "f157": "fa-cny", + "f121": "fa-code", + "f126": "fa-code-fork", + "f1cb": "fa-codepen", + "f284": "fa-codiepie", + "f0f4": "fa-coffee", + "f013": "fa-cog", + "f085": "fa-cogs", + "f0db": "fa-columns", + "f075": "fa-comment", + "f27a": "fa-commenting", + "f27b": "fa-commenting-o", + "f0e5": "fa-comment-o", + "f086": "fa-comments", + "f0e6": "fa-comments-o", + "f14e": "fa-compass", + "f066": "fa-compress", + "f20e": "fa-connectdevelop", + "f26d": "fa-contao", + "f0c5": "fa-copy", + "f1f9": "fa-copyright", + "f25e": "fa-creative-commons", + "f09d": "fa-credit-card", + "f283": "fa-credit-card-alt", + "f125": "fa-crop", + "f05b": "fa-crosshairs", + "f13c": "fa-css3", + "f1b2": "fa-cube", + "f1b3": "fa-cubes", + "f0c4": "fa-cut", + "f0f5": "fa-cutlery", + "f0e4": "fa-dashboard", + "f210": "fa-dashcube", + "f1c0": "fa-database", + "f2a4": "fa-deaf", + "f03b": "fa-dedent", + "f1a5": "fa-delicious", + "f108": "fa-desktop", + "f1bd": "fa-deviantart", + "f219": "fa-diamond", + "f1a6": "fa-digg", + "f155": "fa-dollar", + "f192": "fa-dot-circle-o", + "f019": "fa-download", + "f17d": "fa-dribbble", + "f2c2": "fa-drivers-license", + "f2c3": "fa-drivers-license-o", + "f16b": "fa-dropbox", + "f1a9": "fa-drupal", + "f282": "fa-edge", + "f044": "fa-edit", + "f2da": "fa-eercast", + "f052": "fa-eject", + "f141": "fa-ellipsis-h", + "f142": "fa-ellipsis-v", + "f1d1": "fa-empire", + "f0e0": "fa-envelope", + "f003": "fa-envelope-o", + "f2b6": "fa-envelope-open", + "f2b7": "fa-envelope-open-o", + "f199": "fa-envelope-square", + "f299": "fa-envira", + "f12d": "fa-eraser", + "f2d7": "fa-etsy", + "f153": "fa-eur", + "f0ec": "fa-exchange", + "f12a": "fa-exclamation", + "f06a": "fa-exclamation-circle", + "f071": "fa-exclamation-triangle", + "f065": "fa-expand", + "f23e": "fa-expeditedssl", + "f08e": "fa-external-link", + "f14c": "fa-external-link-square", + "f06e": "fa-eye", + "f1fb": "fa-eyedropper", + "f070": "fa-eye-slash", + "f2b4": "fa-fa", + "f09a": "fa-facebook", + "f230": "fa-facebook-official", + "f082": "fa-facebook-square", + "f049": "fa-fast-backward", + "f050": "fa-fast-forward", + "f1ac": "fa-fax", + "f09e": "fa-feed", + "f182": "fa-female", + "f0fb": "fa-fighter-jet", + "f15b": "fa-file", + "f1c6": "fa-file-archive-o", + "f1c7": "fa-file-audio-o", + "f1c9": "fa-file-code-o", + "f1c3": "fa-file-excel-o", + "f1c5": "fa-file-image-o", + "f1c8": "fa-file-movie-o", + "f016": "fa-file-o", + "f1c1": "fa-file-pdf-o", + "f1c4": "fa-file-powerpoint-o", + "f15c": "fa-file-text", + "f0f6": "fa-file-text-o", + "f1c2": "fa-file-word-o", + "f008": "fa-film", + "f0b0": "fa-filter", + "f06d": "fa-fire", + "f134": "fa-fire-extinguisher", + "f269": "fa-firefox", + "f2b0": "fa-first-order", + "f024": "fa-flag", + "f11e": "fa-flag-checkered", + "f11d": "fa-flag-o", + "f0c3": "fa-flask", + "f16e": "fa-flickr", + "f0c7": "fa-floppy-o", + "f07b": "fa-folder", + "f114": "fa-folder-o", + "f07c": "fa-folder-open", + "f115": "fa-folder-open-o", + "f031": "fa-font", + "f280": "fa-fonticons", + "f286": "fa-fort-awesome", + "f211": "fa-forumbee", + "f04e": "fa-forward", + "f180": "fa-foursquare", + "f2c5": "fa-free-code-camp", + "f119": "fa-frown-o", + "f1e3": "fa-futbol-o", + "f11b": "fa-gamepad", + "f0e3": "fa-gavel", + "f154": "fa-gbp", + "f22d": "fa-genderless", + "f265": "fa-get-pocket", + "f260": "fa-gg", + "f261": "fa-gg-circle", + "f06b": "fa-gift", + "f1d3": "fa-git", + "f09b": "fa-github", + "f113": "fa-github-alt", + "f092": "fa-github-square", + "f296": "fa-gitlab", + "f1d2": "fa-git-square", + "f184": "fa-gittip", + "f000": "fa-glass", + "f2a5": "fa-glide", + "f2a6": "fa-glide-g", + "f0ac": "fa-globe", + "f1a0": "fa-google", + "f0d5": "fa-google-plus", + "f2b3": "fa-google-plus-circle", + "f0d4": "fa-google-plus-square", + "f1ee": "fa-google-wallet", + "f19d": "fa-graduation-cap", + "f2d6": "fa-grav", + "f0c0": "fa-group", + "f1d4": "fa-hacker-news", + "f255": "fa-hand-grab-o", + "f258": "fa-hand-lizard-o", + "f0a7": "fa-hand-o-down", + "f0a5": "fa-hand-o-left", + "f0a4": "fa-hand-o-right", + "f0a6": "fa-hand-o-up", + "f256": "fa-hand-paper-o", + "f25b": "fa-hand-peace-o", + "f25a": "fa-hand-pointer-o", + "f257": "fa-hand-scissors-o", + "f2b5": "fa-handshake-o", + "f259": "fa-hand-spock-o", + "f292": "fa-hashtag", + "f0a0": "fa-hdd-o", + "f1dc": "fa-header", + "f025": "fa-headphones", + "f004": "fa-heart", + "f21e": "fa-heartbeat", + "f08a": "fa-heart-o", + "f1da": "fa-history", + "f015": "fa-home", + "f0f8": "fa-hospital-o", + "f254": "fa-hourglass", + "f251": "fa-hourglass-1", + "f252": "fa-hourglass-2", + "f253": "fa-hourglass-3", + "f250": "fa-hourglass-o", + "f27c": "fa-houzz", + "f0fd": "fa-h-square", + "f13b": "fa-html5", + "f246": "fa-i-cursor", + "f2c1": "fa-id-badge", + "f20b": "fa-ils", + "f03e": "fa-image", + "f2d8": "fa-imdb", + "f01c": "fa-inbox", + "f03c": "fa-indent", + "f275": "fa-industry", + "f129": "fa-info", + "f05a": "fa-info-circle", + "f156": "fa-inr", + "f16d": "fa-instagram", + "f26b": "fa-internet-explorer", + "f224": "fa-intersex", + "f208": "fa-ioxhost", + "f033": "fa-italic", + "f1aa": "fa-joomla", + "f1cc": "fa-jsfiddle", + "f084": "fa-key", + "f11c": "fa-keyboard-o", + "f159": "fa-krw", + "f1ab": "fa-language", + "f109": "fa-laptop", + "f202": "fa-lastfm", + "f203": "fa-lastfm-square", + "f06c": "fa-leaf", + "f212": "fa-leanpub", + "f094": "fa-lemon-o", + "f149": "fa-level-down", + "f148": "fa-level-up", + "f1cd": "fa-life-bouy", + "f0eb": "fa-lightbulb-o", + "f201": "fa-line-chart", + "f0e1": "fa-linkedin", + "f08c": "fa-linkedin-square", + "f2b8": "fa-linode", + "f17c": "fa-linux", + "f03a": "fa-list", + "f022": "fa-list-alt", + "f0cb": "fa-list-ol", + "f0ca": "fa-list-ul", + "f124": "fa-location-arrow", + "f023": "fa-lock", + "f175": "fa-long-arrow-down", + "f177": "fa-long-arrow-left", + "f178": "fa-long-arrow-right", + "f176": "fa-long-arrow-up", + "f2a8": "fa-low-vision", + "f0d0": "fa-magic", + "f076": "fa-magnet", + "f064": "fa-mail-forward", + "f112": "fa-mail-reply", + "f122": "fa-mail-reply-all", + "f183": "fa-male", + "f279": "fa-map", + "f041": "fa-map-marker", + "f278": "fa-map-o", + "f276": "fa-map-pin", + "f277": "fa-map-signs", + "f222": "fa-mars", + "f227": "fa-mars-double", + "f229": "fa-mars-stroke", + "f22b": "fa-mars-stroke-h", + "f22a": "fa-mars-stroke-v", + "f136": "fa-maxcdn", + "f20c": "fa-meanpath", + "f23a": "fa-medium", + "f0fa": "fa-medkit", + "f2e0": "fa-meetup", + "f11a": "fa-meh-o", + "f223": "fa-mercury", + "f2db": "fa-microchip", + "f130": "fa-microphone", + "f131": "fa-microphone-slash", + "f068": "fa-minus", + "f056": "fa-minus-circle", + "f146": "fa-minus-square", + "f147": "fa-minus-square-o", + "f289": "fa-mixcloud", + "f10b": "fa-mobile", + "f285": "fa-modx", + "f0d6": "fa-money", + "f186": "fa-moon-o", + "f21c": "fa-motorcycle", + "f245": "fa-mouse-pointer", + "f001": "fa-music", + "f22c": "fa-neuter", + "f1ea": "fa-newspaper-o", + "f247": "fa-object-group", + "f248": "fa-object-ungroup", + "f263": "fa-odnoklassniki", + "f264": "fa-odnoklassniki-square", + "f23d": "fa-opencart", + "f19b": "fa-openid", + "f26a": "fa-opera", + "f23c": "fa-optin-monster", + "f18c": "fa-pagelines", + "f1fc": "fa-paint-brush", + "f0c6": "fa-paperclip", + "f1d8": "fa-paper-plane", + "f1d9": "fa-paper-plane-o", + "f1dd": "fa-paragraph", + "f04c": "fa-pause", + "f28b": "fa-pause-circle", + "f28c": "fa-pause-circle-o", + "f1b0": "fa-paw", + "f1ed": "fa-paypal", + "f040": "fa-pencil", + "f14b": "fa-pencil-square", + "f295": "fa-percent", + "f095": "fa-phone", + "f098": "fa-phone-square", + "f200": "fa-pie-chart", + "f2ae": "fa-pied-piper", + "f1a8": "fa-pied-piper-alt", + "f1a7": "fa-pied-piper-pp", + "f0d2": "fa-pinterest", + "f231": "fa-pinterest-p", + "f0d3": "fa-pinterest-square", + "f072": "fa-plane", + "f04b": "fa-play", + "f144": "fa-play-circle", + "f01d": "fa-play-circle-o", + "f1e6": "fa-plug", + "f067": "fa-plus", + "f055": "fa-plus-circle", + "f0fe": "fa-plus-square", + "f196": "fa-plus-square-o", + "f2ce": "fa-podcast", + "f011": "fa-power-off", + "f02f": "fa-print", + "f288": "fa-product-hunt", + "f12e": "fa-puzzle-piece", + "f1d6": "fa-qq", + "f029": "fa-qrcode", + "f128": "fa-question", + "f059": "fa-question-circle", + "f29c": "fa-question-circle-o", + "f2c4": "fa-quora", + "f10d": "fa-quote-left", + "f10e": "fa-quote-right", + "f1d0": "fa-ra", + "f074": "fa-random", + "f2d9": "fa-ravelry", + "f1b8": "fa-recycle", + "f1a1": "fa-reddit", + "f281": "fa-reddit-alien", + "f1a2": "fa-reddit-square", + "f021": "fa-refresh", + "f25d": "fa-registered", + "f18b": "fa-renren", + "f01e": "fa-repeat", + "f079": "fa-retweet", + "f018": "fa-road", + "f135": "fa-rocket", + "f0e2": "fa-rotate-left", + "f158": "fa-rouble", + "f143": "fa-rss-square", + "f267": "fa-safari", + "f28a": "fa-scribd", + "f002": "fa-search", + "f010": "fa-search-minus", + "f00e": "fa-search-plus", + "f213": "fa-sellsy", + "f233": "fa-server", + "f1e0": "fa-share-alt", + "f1e1": "fa-share-alt-square", + "f14d": "fa-share-square", + "f045": "fa-share-square-o", + "f132": "fa-shield", + "f21a": "fa-ship", + "f214": "fa-shirtsinbulk", + "f290": "fa-shopping-bag", + "f291": "fa-shopping-basket", + "f07a": "fa-shopping-cart", + "f2cc": "fa-shower", + "f012": "fa-signal", + "f090": "fa-sign-in", + "f2a7": "fa-sign-language", + "f08b": "fa-sign-out", + "f215": "fa-simplybuilt", + "f0e8": "fa-sitemap", + "f216": "fa-skyatlas", + "f17e": "fa-skype", + "f198": "fa-slack", + "f1de": "fa-sliders", + "f1e7": "fa-slideshare", + "f118": "fa-smile-o", + "f2ab": "fa-snapchat", + "f2ac": "fa-snapchat-ghost", + "f2ad": "fa-snapchat-square", + "f2dc": "fa-snowflake-o", + "f0dc": "fa-sort", + "f15d": "fa-sort-alpha-asc", + "f15e": "fa-sort-alpha-desc", + "f160": "fa-sort-amount-asc", + "f161": "fa-sort-amount-desc", + "f0de": "fa-sort-asc", + "f0dd": "fa-sort-desc", + "f162": "fa-sort-numeric-asc", + "f163": "fa-sort-numeric-desc", + "f1be": "fa-soundcloud", + "f197": "fa-space-shuttle", + "f110": "fa-spinner", + "f1b1": "fa-spoon", + "f1bc": "fa-spotify", + "f0c8": "fa-square", + "f096": "fa-square-o", + "f18d": "fa-stack-exchange", + "f16c": "fa-stack-overflow", + "f005": "fa-star", + "f089": "fa-star-half", + "f123": "fa-star-half-empty", + "f006": "fa-star-o", + "f1b6": "fa-steam", + "f1b7": "fa-steam-square", + "f048": "fa-step-backward", + "f051": "fa-step-forward", + "f0f1": "fa-stethoscope", + "f249": "fa-sticky-note", + "f24a": "fa-sticky-note-o", + "f04d": "fa-stop", + "f28d": "fa-stop-circle", + "f28e": "fa-stop-circle-o", + "f21d": "fa-street-view", + "f0cc": "fa-strikethrough", + "f1a4": "fa-stumbleupon", + "f1a3": "fa-stumbleupon-circle", + "f12c": "fa-subscript", + "f239": "fa-subway", + "f0f2": "fa-suitcase", + "f185": "fa-sun-o", + "f2dd": "fa-superpowers", + "f12b": "fa-superscript", + "f0ce": "fa-table", + "f10a": "fa-tablet", + "f02b": "fa-tag", + "f02c": "fa-tags", + "f0ae": "fa-tasks", + "f2c6": "fa-telegram", + "f26c": "fa-television", + "f1d5": "fa-tencent-weibo", + "f120": "fa-terminal", + "f034": "fa-text-height", + "f035": "fa-text-width", + "f00a": "fa-th", + "f2b2": "fa-themeisle", + "f2c7": "fa-thermometer", + "f2cb": "fa-thermometer-0", + "f2ca": "fa-thermometer-1", + "f2c9": "fa-thermometer-2", + "f2c8": "fa-thermometer-3", + "f009": "fa-th-large", + "f00b": "fa-th-list", + "f165": "fa-thumbs-down", + "f088": "fa-thumbs-o-down", + "f087": "fa-thumbs-o-up", + "f164": "fa-thumbs-up", + "f08d": "fa-thumb-tack", + "f145": "fa-ticket", + "f057": "fa-times-circle", + "f05c": "fa-times-circle-o", + "f2d3": "fa-times-rectangle", + "f2d4": "fa-times-rectangle-o", + "f043": "fa-tint", + "f204": "fa-toggle-off", + "f205": "fa-toggle-on", + "f25c": "fa-trademark", + "f238": "fa-train", + "f225": "fa-transgender-alt", + "f1f8": "fa-trash", + "f014": "fa-trash-o", + "f1bb": "fa-tree", + "f181": "fa-trello", + "f262": "fa-tripadvisor", + "f091": "fa-trophy", + "f0d1": "fa-truck", + "f195": "fa-try", + "f1e4": "fa-tty", + "f173": "fa-tumblr", + "f174": "fa-tumblr-square", + "f1e8": "fa-twitch", + "f099": "fa-twitter", + "f081": "fa-twitter-square", + "f0e9": "fa-umbrella", + "f0cd": "fa-underline", + "f29a": "fa-universal-access", + "f09c": "fa-unlock", + "f13e": "fa-unlock-alt", + "f093": "fa-upload", + "f287": "fa-usb", + "f007": "fa-user", + "f2bd": "fa-user-circle", + "f2be": "fa-user-circle-o", + "f0f0": "fa-user-md", + "f2c0": "fa-user-o", + "f234": "fa-user-plus", + "f21b": "fa-user-secret", + "f235": "fa-user-times", + "f221": "fa-venus", + "f226": "fa-venus-double", + "f228": "fa-venus-mars", + "f237": "fa-viacoin", + "f2a9": "fa-viadeo", + "f2aa": "fa-viadeo-square", + "f03d": "fa-video-camera", + "f27d": "fa-vimeo", + "f194": "fa-vimeo-square", + "f1ca": "fa-vine", + "f189": "fa-vk", + "f2a0": "fa-volume-control-phone", + "f027": "fa-volume-down", + "f026": "fa-volume-off", + "f028": "fa-volume-up", + "f1d7": "fa-wechat", + "f18a": "fa-weibo", + "f232": "fa-whatsapp", + "f193": "fa-wheelchair", + "f29b": "fa-wheelchair-alt", + "f1eb": "fa-wifi", + "f266": "fa-wikipedia-w", + "f2d0": "fa-window-maximize", + "f2d1": "fa-window-minimize", + "f2d2": "fa-window-restore", + "f17a": "fa-windows", + "f19a": "fa-wordpress", + "f297": "fa-wpbeginner", + "f2de": "fa-wpexplorer", + "f298": "fa-wpforms", + "f0ad": "fa-wrench", + "f168": "fa-xing", + "f169": "fa-xing-square", + "f19e": "fa-yahoo", + "f23b": "fa-y-combinator", + "f1e9": "fa-yelp", + "f2b1": "fa-yoast", + "f167": "fa-youtube", + "f16a": "fa-youtube-play", + "f166": "fa-youtube-square", + }, + + receiveConfig: function(resp) { + // receive config from server + if (resp.code) { + app.showProgress( 1.0, "Waiting for manager server..." ); + setTimeout( function() { load_script( '/api/app/config?callback=app.receiveConfig' ); }, 1000 ); + return; + } + delete resp.code; + window.config = resp.config; + + for (var key in resp) { + this[key] = resp[key]; + } + + // allow visible app name to be changed in config + this.name = config.name; + $('#d_header_title').html( '' + this.name + '' ); + + // hit the manager server directly from now on + this.setmanagerHostname( resp.manager_hostname ); + + this.config.Page = [ + { ID: 'Home' }, + { ID: 'Login' }, + { ID: 'Schedule' }, + { ID: 'History' }, + { ID: 'JobDetails' }, + { ID: 'MyAccount' }, + { ID: 'Admin' } + ]; + this.config.DefaultPage = 'Home'; + + // did we try to init and fail? if so, try again now + if (this.initReady) { + this.hideProgress(); + delete this.initReady; + this.init(); + } + }, + + init: function() { + // initialize application + if (this.abort) return; // fatal error, do not initialize app + + if (!this.config) { + // must be in manager server wait loop + this.initReady = true; + return; + } + + if (!this.servers) this.servers = {}; + this.server_groups = []; + + // timezone support + this.tz = jstz.determine().name(); + this.zones = moment.tz.names(); + + // preload a few essential images + for (var idx = 0, len = this.preload_images.length; idx < len; idx++) { + var filename = '' + this.preload_images[idx]; + var img = new Image(); + img.src = '/images/'+filename; + } + + // populate prefs for first time user + for (var key in this.default_prefs) { + if (!(key in window.localStorage)) { + window.localStorage[key] = this.default_prefs[key]; + } + } + + // pop version into footer + $('#d_footer_version').html( "Version " + this.version || 0 ); + + // some css classing for browser-specific adjustments + var ua = navigator.userAgent; + if (ua.match(/Safari/) && !ua.match(/(Chrome|Opera)/)) { + $('body').addClass('safari'); + } + else if (ua.match(/Chrome/)) { + $('body').addClass('chrome'); + } + else if (ua.match(/Firefox/)) { + $('body').addClass('firefox'); + } + + // follow scroll so we can fade in/out the scroll time widget + window.addEventListener( "scroll", function() { + app.checkScrollTime(); + }, false ); + app.checkScrollTime(); + + this.page_manager = new PageManager( always_array(config.Page) ); + + // this.setHeaderClock(); + this.socketConnect(); + + // Nav.init(); + }, + + updateHeaderInfo: function() { + // update top-right display + var html = ''; + html += '
'; + html += '
  Logout
'; + html += '
'; + html += '
' + (this.user.full_name || app.username).replace(/\s+.+$/, '') + '
'; + $('#d_header_user_container').html( html ); + }, + + doUserLogin: function(resp) { + // user login, called from login page, or session recover + // overriding this from base.js, so we can pass the session ID to the websocket + delete resp.code; + + for (var key in resp) { + this[key] = resp[key]; + } + + if (this.isCategoryLimited() || this.isGroupLimited() ) { + this.pruneSchedule(); + this.pruneCategories(); + this.pruneActiveJobs(); + } + + this.setPref('username', resp.username); + this.setPref('session_id', resp.session_id); + + this.updateHeaderInfo(); + + // update clock + this.setHeaderClock( this.epoch ); + + // show scheduler manager switch + this.updatemanagerSwitch(); + if (this.hasPrivilege('state_update')) $('#d_tab_manager').addClass('active'); + + // show admin tab if user is worthy + if (this.isAdmin()) $('#tab_Admin').show(); + else $('#tab_Admin').hide(); + + // authenticate websocket + this.socket.emit( 'authenticate', { token: resp.session_id } ); + }, + + doUserLogout: function(bad_cookie) { + // log user out and redirect to login screen + var self = this; + + if (!bad_cookie) { + // user explicitly logging out + this.showProgress(1.0, "Logging out..."); + this.setPref('username', ''); + } + + this.api.post( 'user/logout', { + session_id: this.getPref('session_id') + }, + function(resp, tx) { + delete self.user; + delete self.username; + delete self.user_info; + + if (self.socket) self.socket.emit( 'logout', {} ); + + self.setPref('session_id', ''); + + $('#d_header_user_container').html( '' ); + $('#d_tab_manager').html( '' ); + + $('div.header_clock_layer').fadeTo( 1000, 0 ); + $('#d_tab_time > span').html( '' ); + self.clock_visible = false; + self.checkScrollTime(); + + if (app.config.external_users) { + // external user api + Debug.trace("User session cookie was deleted, querying external user API"); + setTimeout( function() { + if (bad_cookie) app.doExternalLogin(); + else app.doExternalLogout(); + }, 250 ); + } + else { + Debug.trace("User session cookie was deleted, redirecting to login page"); + self.hideProgress(); + Nav.go('Login'); + } + + setTimeout( function() { + if (!app.config.external_users) { + if (bad_cookie) self.showMessage('error', "Your session has expired. Please log in again."); + else self.showMessage('success', "You were logged out successfully."); + } + + self.activeJobs = {}; + delete self.servers; + delete self.schedule; + delete self.categories; + delete self.plugins; + delete self.server_groups; + delete self.epoch; + + }, 150 ); + + $('#tab_Admin').hide(); + } ); + }, + + doExternalLogin: function() { + // login using external user management system + // Force API to hit current page hostname vs. manager server, so login redirect URL reflects it + app.api.post( '/api/user/external_login', { cookie: document.cookie }, function(resp) { + if (resp.user) { + Debug.trace("User Session Resume: " + resp.username + ": " + resp.session_id); + app.hideProgress(); + app.doUserLogin( resp ); + Nav.refresh(); + } + else if (resp.location) { + Debug.trace("External User API requires redirect"); + app.showProgress(1.0, "Logging in..."); + setTimeout( function() { window.location = resp.location; }, 250 ); + } + else app.doError(resp.description || "Unknown login error."); + } ); + }, + + doExternalLogout: function() { + // redirect to external user management system for logout + var url = app.config.external_user_api; + url += (url.match(/\?/) ? '&' : '?') + 'logout=1'; + + Debug.trace("External User API requires redirect"); + app.showProgress(1.0, "Logging out..."); + setTimeout( function() { window.location = url; }, 250 ); + }, + + show_info: function(title) { + // just display stuff and close dialog + this.confirm_callback = this.hideDialog; + + let buttons_html = ` +
+ +
OK
+ ` + + let html = ` +
${title}
+
${buttons_html}
+ ` + Dialog.showAuto( "", html ); + // special mode for key capture + Dialog.active = 'confirmation'; + }, + + socketConnect: function() { + // init socket.io client + var self = this; + + var url = this.proto + this.managerHostname + ':' + this.port; + if (!config.web_socket_use_hostnames && this.servers && this.servers[this.managerHostname] && this.servers[this.managerHostname].ip) { + // use ip instead of hostname if available + url = this.proto + this.servers[this.managerHostname].ip + ':' + this.port; + } + if (!config.web_direct_connect) { + url = this.proto + location.host; + } + Debug.trace("Websocket Connect: " + url); + + if (this.socket) { + Debug.trace("Destroying previous socket"); + this.socket.removeAllListeners(); + if (this.socket.connected) this.socket.disconnect(); + this.socket = null; + } + + var socket = this.socket = io( url, { + // forceNew: true, + transports: config.socket_io_transports || ['websocket'], + reconnection: false, + reconnectionDelay: 1000, + reconnectionDelayMax: 2000, + reconnectionAttempts: 9999, + timeout: 3000 + } ); + + socket.on('connect', function() { + if (!Nav.inited) Nav.init(); + + Debug.trace("socket.io connected successfully"); + // if (self.progress) self.hideProgress(); + + // if we are already logged in, authenticate websocket now + var session_id = app.getPref('session_id'); + if (session_id) socket.emit( 'authenticate', { token: session_id } ); + } ); + + socket.on('connect_error', function(err) { + Debug.trace("socket.io connect error: " + err); + } ); + + socket.on('connect_timeout', function(err) { + Debug.trace("socket.io connect timeout"); + } ); + + socket.on('reconnecting', function() { + Debug.trace("socket.io reconnecting..."); + // self.showProgress( 0.5, "Reconnecting to server..." ); + } ); + + socket.on('reconnect', function() { + Debug.trace("socket.io reconnected successfully"); + // if (self.progress) self.hideProgress(); + } ); + + socket.on('reconnect_failed', function() { + Debug.trace("socket.io has given up -- we must refresh"); + location.reload(); + } ); + + socket.on('disconnect', function() { + // unexpected disconnection + Debug.trace("socket.io disconnected unexpectedly"); + } ); + + socket.on('status', function(data) { + if (!data.manager) { + // OMG we're not talking to manager anymore? + self.recalculatemanager(data); + } + else { + // connected to manager + self.epoch = data.epoch; + self.servers = data.servers; + self.setHeaderClock( data.epoch ); + + // update active jobs + self.updateActiveJobs( data ); + + // notify current page + var id = self.page_manager.current_page_id; + var page = self.page_manager.find(id); + if (page && page.onStatusUpdate) page.onStatusUpdate(data); + + // remove dialog if present + if (self.waitingFormanager && self.progress) { + self.hideProgress(); + delete self.waitingFormanager; + } + } // manager + } ); + + socket.on('update', function(data) { + // receive data update (global list contents) + var limited_user = self.isCategoryLimited() || self.isGroupLimited(); + + for (var key in data) { + self[key] = data[key]; + + if (limited_user) { + if (key == 'schedule') self.pruneSchedule(); + else if (key == 'categories') self.pruneCategories(); + } + + var id = self.page_manager.current_page_id; + var page = self.page_manager.find(id); + if (page && page.onDataUpdate) page.onDataUpdate(key, data[key]); + } + + // update manager switch (once per minute) + if (data.state) self.updatemanagerSwitch(); + + // clear event autosave data if schedule was updated + if (data.schedule) delete self.autosave_event; + } ); + + // --- Keep socket.io connected forever --- + // This is the worst hack in history, but socket.io-client + // is simply not behaving, and I have tried EVERYTHING ELSE. + setInterval( function() { + if (socket && !socket.connected) { + Debug.trace("Forcing socket to reconnect"); + socket.connect(); + } + }, 5000 ); + }, + + updateActiveJobs: function(data) { + // update active jobs + var jobs = data.active_jobs; + var changed = false; + + // hide silent jobs? + // if(jobs) jobs = jobs.map(j=>!j.silent) + + // determine if jobs have been added or deleted + for (var id in jobs) { + // check for new jobs added + if (!this.activeJobs[id]) changed = true; + } + for (var id in this.activeJobs) { + // check for jobs completed + if (!jobs[id]) changed = true; + } + + this.activeJobs = jobs; + if (this.isCategoryLimited() || this.isGroupLimited() ) this.pruneActiveJobs(); + data.jobs_changed = changed; + }, + + pruneActiveJobs: function() { + // remove active jobs that the user should not see, due to category/group privs + if (!this.activeJobs) return; + + for (var id in this.activeJobs) { + var job = this.activeJobs[id]; + if (!this.hasCategoryAccess(job.category) || !this.hasGroupAccess(job.target)) { + delete this.activeJobs[id]; + } + } + }, + + pruneSchedule: function() { + // remove schedule items that the user should not see, due to category/group privs + if (!this.schedule || !this.schedule.length) return; + var new_items = []; + + for (var idx = 0, len = this.schedule.length; idx < len; idx++) { + var item = this.schedule[idx]; + if (this.hasCategoryAccess(item.category) && this.hasGroupAccess(item.target)) { + new_items.push(item); + } + } + + this.schedule = new_items; + }, + + pruneCategories: function() { + // remove categories that the user should not see, due to category/group privs + if (!this.categories || !this.categories.length) return; + var new_items = []; + + for (var idx = 0, len = this.categories.length; idx < len; idx++) { + var item = this.categories[idx]; + if (this.hasCategoryAccess(item.id)) new_items.push(item); + } + + this.categories = new_items; + }, + + isCategoryLimited: function() { + // return true if user is limited to specific categories, false otherwise + if (this.isAdmin()) return false; + return( app.user && app.user.privileges && app.user.privileges.cat_limit ); + }, + + isGroupLimited: function() { + // return true if user is limited to specific server groups, false otherwise + if (this.isAdmin()) return false; + return( app.user && app.user.privileges && app.user.privileges.grp_limit ); + }, + + hasCategoryAccess: function(cat_id) { + // check if user has access to specific category + if (!app.user || !app.user.privileges) return false; + if (app.user.privileges.admin) return true; + if (!app.user.privileges.cat_limit) return true; + + var priv_id = 'cat_' + cat_id; + return( !!app.user.privileges[priv_id] ); + }, + + hasGroupAccess: function(grp_id) { + // check if user has access to specific server group + if (!app.user || !app.user.privileges) return false; + if (app.user.privileges.admin) return true; + if (!app.user.privileges.grp_limit) return true; + + var priv_id = 'grp_' + grp_id; + var result = !!app.user.privileges[priv_id]; + if (result) return true; + + // make sure grp_id is a hostname from this point on + if (find_object(app.server_groups, { id: grp_id })) return false; + + var groups = app.server_groups.filter( function(group) { + return grp_id.match( group.regexp ); + } ); + + // we just need one group to match, then the user has permission to target the server + for (var idx = 0, len = groups.length; idx < len; idx++) { + priv_id = 'grp_' + groups[idx].id; + result = !!app.user.privileges[priv_id]; + if (result) return true; + } + return false; + }, + + hasPrivilege: function(priv_id) { + // check if user has privilege + if (!app.user || !app.user.privileges) return false; + if (app.user.privileges.admin) return true; + return( !!app.user.privileges[priv_id] ); + }, + + recalculatemanager: function(data) { + // Oops, we're connected to a worker! manager must have been restarted. + // If worker knows who is manager, switch now, otherwise go into wait loop + var self = this; + this.showProgress( 1.0, "Waiting for manager server..." ); + this.waitingFormanager = true; + + if (data.manager_hostname) { + // reload browser which should connect to manager + location.reload(); + } + }, + + setmanagerHostname: function(hostname) { + // set new manager hostname, update stuff + Debug.trace("New manager Hostname: " + hostname); + this.managerHostname = hostname; + + if (config.web_direct_connect) { + this.base_api_url = this.proto + this.managerHostname + ':' + this.port + config.base_api_uri; + if (!config.web_socket_use_hostnames && this.servers && this.servers[this.managerHostname] && this.servers[this.managerHostname].ip) { + // use ip instead of hostname if available + this.base_api_url = this.proto + this.servers[this.managerHostname].ip + ':' + this.port + config.base_api_uri; + } + } + else { + this.base_api_url = this.proto + location.host + config.base_api_uri; + } + + Debug.trace("API calls now going to: " + this.base_api_url); + }, + + setHeaderClock: function(when) { + // move the header clock hands to the selected time + + if (!when) when = time_now(); + var dargs = get_date_args( when ); + + // hour hand + var hour = (((dargs.hour + (dargs.min / 60)) % 12) / 12) * 360; + $('#d_header_clock_hour').css({ + transform: 'rotateZ('+hour+'deg)', + '-webkit-transform': 'rotateZ('+hour+'deg)' + }); + + // minute hand + var min = ((dargs.min + (dargs.sec / 60)) / 60) * 360; + $('#d_header_clock_minute').css({ + transform: 'rotateZ('+min+'deg)', + '-webkit-transform': 'rotateZ('+min+'deg)' + }); + + // second hand + var sec = (dargs.sec / 60) * 360; + $('#d_header_clock_second').css({ + transform: 'rotateZ('+sec+'deg)', + '-webkit-transform': 'rotateZ('+sec+'deg)' + }); + + // show clock if needed + if (!this.clock_visible) { + this.clock_visible = true; + $('div.header_clock_layer, #d_tab_time').fadeTo( 1000, 1.0 ); + this.checkScrollTime(); + } + + // date/time in tab bar + // $('#d_tab_time, #d_scroll_time > span').html( get_nice_date_time( when, true, true ) ); + var num_active = num_keys( app.activeJobs || {} ); + var nice_active = commify(num_active) + ' ' + pluralize('Job', num_active); + if (!num_active) nice_active = "Idle"; + + $('#d_tab_time > span, #d_scroll_time > span').html( + // get_nice_date_time( when, true, true ) + ' ' + + get_nice_time(when, true) + ' ' + + moment.tz( when * 1000, app.tz).format("z") + ' - ' + + nice_active + ); + }, + + updatemanagerSwitch: function() { + // update manager switch display + var html = ''; + if (this.hasPrivilege('state_update')) { + html = '' : 'class="fa fa-square-o">')+' Scheduler Enabled'; + } + else { + if (this.state.enabled) html = ' Scheduler Enabled'; + else html = ' Scheduler Disabled'; + } + + $('#d_tab_manager') + .css( 'color', this.state.enabled ? '#3f7ed5' : '#777' ) + .html( html ); + }, + + togglemanagerSwitch: function() { + // toggle manager scheduler switch on/off + var self = this; + var enabled = this.state.enabled ? 0 : 1; + + if (!this.hasPrivilege('state_update')) return; + + // $('#d_tab_manager > i').removeClass().addClass('fa fa-spin fa-spinner'); + + app.api.post( 'app/update_manager_state', { enabled: enabled }, function(resp) { + app.showMessage('success', "Scheduler has been " + (enabled ? 'enabled' : 'disabled') + "."); + self.state.enabled = enabled; + self.updatemanagerSwitch(); + } ); + }, + + checkScrollTime: function() { + // check page scroll, see if we need to fade in/out the scroll time widget + var pos = get_scroll_xy(); + var y = pos.y; + var min_y = 70; + + if ((y >= min_y) && this.clock_visible) { + if (!this.scroll_time_visible) { + // time to fade it in + $('#d_scroll_time').stop().css('top', '0px').fadeTo( 1000, 1.0 ); + this.scroll_time_visible = true; + } + } + else { + if (this.scroll_time_visible) { + // time to fade it out + $('#d_scroll_time').stop().fadeTo( 500, 0, function() { + $(this).css('top', '-30px'); + } ); + this.scroll_time_visible = false; + } + } + }, + + get_password_type: function() { + // get user's pref for password field type, defaulting to config + return this.getPref('password_type') || config.default_password_type || 'password'; + }, + + get_password_toggle_html: function() { + // get html for a password toggle control + var text = (this.get_password_type() == 'password') ? 'Show' : 'Hide'; + return '' + text + ''; + }, + + toggle_password_field: function(span) { + // toggle password field visible / masked + var $span = $(span); + var $field = $span.prev(); + if ($field.attr('type') == 'password') { + $field.attr('type', 'text'); + $span.html( 'Hide' ); + this.setPref('password_type', 'text'); + } + else { + $field.attr('type', 'password'); + $span.html( 'Show' ); + this.setPref('password_type', 'password'); + } + }, + + password_strengthify: function(sel) { + // add password strength meter (text field should be wrapped by div) + var $field = $(sel); + var $div = $field.parent(); + + var $cont = $('
'); + $cont.css('width', $field[0].offsetWidth ); + $cont.html( '
' ); + $div.append( $cont ); + + $field.keyup( function() { + setTimeout( function() { + app.update_password_strength($field, $cont); + }, 1 ); + } ); + + if (!window.zxcvbn) load_script('js/external/zxcvbn.js'); + }, + + update_password_strength: function($field, $cont) { + // update password strength indicator after keypress + if (window.zxcvbn) { + var password = $field.val(); + var result = zxcvbn( password ); + // Debug.trace("Password score: " + password + ": " + result.score); + var $bar = $cont.find('div.psi_bar'); + $bar.removeClass('str0 str1 str2 str3 str4'); + if (password.length) $bar.addClass('str' + result.score); + app.last_password_strength = result; + } + }, + + get_password_warning: function() { + // return string of text used for bad password dialog + var est_length = app.last_password_strength.crack_time_display; + if (est_length == 'instant') est_length = 'instantly'; + else est_length = 'in about ' + est_length; + + return "The password you entered is insecure, and could be easily compromised by hackers. Our anaysis indicates that it could be cracked via brute force " + est_length + ". For more details see this article.

Do you really want to use this password?"; + }, + + get_color_checkbox_html: function(id, label, checked) { + // get html for color label checkbox, with built-in handlers to toggle state + if (checked === true) checked = "checked"; + else if (checked === false) checked = ""; + + return ' '+label+''; + }, + + toggle_color_checkbox: function(elem) { + // toggle color checkbox state + var $elem = $(elem); + if ($elem.hasClass('checked')) { + // uncheck + $elem.removeClass('checked').find('i').removeClass('fa-check-square-o').addClass('fa-square-o'); + } + else { + // check + $elem.addClass('checked').find('i').addClass('fa-check-square-o').removeClass('fa-square-o'); + } + } + +}); // app + +function get_pretty_int_list(arr, ranges) { + // compose int array to string using commas + spaces, and + // the english "and" to group the final two elements. + // also detect sequences and collapse those into dashed ranges + if (!arr || !arr.length) return ''; + if (arr.length == 1) return arr[0].toString(); + arr = deep_copy_object(arr).sort( function(a, b) { return a - b; } ); + + // check for ranges and collapse them + if (ranges) { + var groups = []; + var group = []; + for (var idx = 0, len = arr.length; idx < len; idx++) { + var elem = arr[idx]; + if (!group.length || (elem == group[group.length - 1] + 1)) group.push(elem); + else { groups.push(group); group = [elem]; } + } + if (group.length) groups.push(group); + arr = []; + for (var idx = 0, len = groups.length; idx < len; idx++) { + var group = groups[idx]; + if (group.length == 1) arr.push( group[0] ); + else if (group.length == 2) { + arr.push( group[0] ); + arr.push( group[1] ); + } + else { + arr.push( group[0] + ' - ' + group[group.length - 1] ); + } + } + } // ranges + + if (arr.length == 1) return arr[0].toString(); + return arr.slice(0, arr.length - 1).join(', ') + ' and ' + arr[ arr.length - 1 ]; +} + +function summarize_event_timing(timing, timezone, extra) { + // summarize event timing into human-readable string + if (!timing && extra) { + return `On Demand +` + } + if (!timing) { return "On demand" }; + + // years + var year_str = ''; + if (timing.years && timing.years.length) { + year_str = get_pretty_int_list(timing.years, true); + } + + // months + var mon_str = ''; + if (timing.months && timing.months.length) { + mon_str = get_pretty_int_list(timing.months, true).replace(/(\d+)/g, function(m_all, m_g1) { + return _months[ parseInt(m_g1) - 1 ][1]; + }); + } + + // days + var mday_str = ''; + if (timing.days && timing.days.length) { + mday_str = get_pretty_int_list(timing.days, true).replace(/(\d+)/g, function(m_all, m_g1) { + return m_g1 + _number_suffixes[ parseInt( m_g1.substring(m_g1.length - 1) ) ]; + }); + } + + // weekdays + var wday_str = ''; + if (timing.weekdays && timing.weekdays.length) { + wday_str = get_pretty_int_list(timing.weekdays, true).replace(/(\d+)/g, function(m_all, m_g1) { + return _day_names[ parseInt(m_g1) ] + 's'; + }); + wday_str = wday_str.replace(/Mondays\s+\-\s+Fridays/, 'weekdays'); + } + + // hours + var hour_str = ''; + if (timing.hours && timing.hours.length) { + hour_str = get_pretty_int_list(timing.hours, true).replace(/(\d+)/g, function(m_all, m_g1) { + return _hour_names[ parseInt(m_g1) ]; + }); + } + + // minutes + var min_str = ''; + if (timing.minutes && timing.minutes.length) { + min_str = get_pretty_int_list(timing.minutes, false).replace(/(\d+)/g, function(m_all, m_g1) { + return ':' + ((m_g1.length == 1) ? ('0'+m_g1) : m_g1); + }); + } + + // construct final string + var groups = []; + var mday_compressed = false; + + if (year_str) { + groups.push( 'in ' + year_str ); + if (mon_str) groups.push( mon_str ); + } + else if (mon_str) { + // compress single month + single day + if (timing.months && timing.months.length == 1 && timing.days && timing.days.length == 1) { + groups.push( 'on ' + mon_str + ' ' + mday_str ); + mday_compressed = true; + } + else { + groups.push( 'in ' + mon_str ); + } + } + + if (mday_str && !mday_compressed) { + if (mon_str || wday_str) groups.push( 'on the ' + mday_str ); + else groups.push( 'monthly on the ' + mday_str ); + } + if (wday_str) groups.push( 'on ' + wday_str ); + + // compress single hour + single minute + if (timing.hours && timing.hours.length == 1 && timing.minutes && timing.minutes.length == 1) { + hour_str.match(/^(\d+)(\w+)$/); + var hr = RegExp.$1; + var ampm = RegExp.$2; + var new_str = hr + min_str + ampm; + + if (mday_str || wday_str) groups.push( 'at ' + new_str ); + else groups.push( 'daily at ' + new_str ); + } + else { + var min_added = false; + if (hour_str) { + if (mday_str || wday_str) groups.push( 'at ' + hour_str ); + else groups.push( 'daily at ' + hour_str ); + } + else { + // check for repeating minute pattern + if (timing.minutes && timing.minutes.length) { + var interval = detect_num_interval( timing.minutes, 60 ); + if (interval) { + var new_str = 'every ' + interval + ' minutes'; + if (timing.minutes[0] > 0) { + var m_g1 = timing.minutes[0].toString(); + new_str += ' starting on the :' + ((m_g1.length == 1) ? ('0'+m_g1) : m_g1); + } + groups.push( new_str ); + min_added = true; + } + } + + if (!min_added) { + if (min_str) groups.push( 'hourly' ); + } + } + + if (!min_added) { + if (min_str) groups.push( 'on the ' + min_str.replace(/\:00/, 'hour').replace(/\:30/, 'half-hour') ); + else groups.push( 'every minute' ); + } + } + + var text = groups.join(', '); + var output = text.substring(0, 1).toUpperCase() + text.substring(1, text.length); + + if (timezone && (timezone != app.tz)) { + // get tz abbreviation + output += ' (' + moment.tz.zone(timezone).abbr( (new Date()).getTime() ) + ')'; + } + + if(extra) { + let xtitle = extra.toString().split(/[\,\;\|]/).filter(e=>e).join(', ') + return `${output} +` + } + + return output +}; + +function detect_num_interval(arr, max) { + // detect interval between array elements, return if found + // all elements must have same interval between them + if (arr.length < 2) return false; + // if (arr[0] > 0) return false; + + var interval = arr[1] - arr[0]; + for (var idx = 1, len = arr.length; idx < len; idx++) { + var temp = arr[idx] - arr[idx - 1]; + if (temp != interval) return false; + } + + // if max is provided, final element + interval must equal max + // if (max && (arr[arr.length - 1] + interval != max)) return false; + if (max && ((arr[arr.length - 1] + interval) % max != arr[0])) return false; + + return interval; +}; + +// Crontab Parsing Tools +// by Joseph Huckaby, (c) 2015, MIT License + +var cron_aliases = { + jan: 1, + feb: 2, + mar: 3, + apr: 4, + may: 5, + jun: 6, + jul: 7, + aug: 8, + sep: 9, + oct: 10, + nov: 11, + dec: 12, + + sun: 0, + mon: 1, + tue: 2, + wed: 3, + thu: 4, + fri: 5, + sat: 6 +}; +var cron_alias_re = new RegExp("\\b(" + hash_keys_to_array(cron_aliases).join('|') + ")\\b", "g"); + +function parse_crontab_part(timing, raw, key, min, max, rand_seed) { + // parse one crontab part, e.g. 1,2,3,5,20-25,30-35,59 + // can contain single number, and/or list and/or ranges and/or these things: */5 or 10-50/5 + if (raw == '*') { return; } // wildcard + if (raw == 'h') { + // unique value over accepted range, but locked to random seed + // https://github.com/jhuckaby/Cronicle/issues/6 + raw = min + (parseInt( hex_md5(rand_seed), 16 ) % ((max - min) + 1)); + raw = '' + raw; + } + if (!raw.match(/^[\w\-\,\/\*]+$/)) { throw new Error("Invalid crontab format: " + raw); } + var values = {}; + var bits = raw.split(/\,/); + + for (var idx = 0, len = bits.length; idx < len; idx++) { + var bit = bits[idx]; + if (bit.match(/^\d+$/)) { + // simple number, easy + values[bit] = 1; + } + else if (bit.match(/^(\d+)\-(\d+)$/)) { + // simple range, e.g. 25-30 + var start = parseInt( RegExp.$1 ); + var end = parseInt( RegExp.$2 ); + for (var idy = start; idy <= end; idy++) { values[idy] = 1; } + } + else if (bit.match(/^\*\/(\d+)$/)) { + // simple step interval, e.g. */5 + var step = parseInt( RegExp.$1 ); + var start = min; + var end = max; + for (var idy = start; idy <= end; idy += step) { values[idy] = 1; } + } + else if (bit.match(/^(\d+)\-(\d+)\/(\d+)$/)) { + // range step inverval, e.g. 1-31/5 + var start = parseInt( RegExp.$1 ); + var end = parseInt( RegExp.$2 ); + var step = parseInt( RegExp.$3 ); + for (var idy = start; idy <= end; idy += step) { values[idy] = 1; } + } + else { + throw new Error("Invalid crontab format: " + bit + " (" + raw + ")"); + } + } + + // min max + var to_add = {}; + var to_del = {}; + for (var value in values) { + value = parseInt( value ); + if (value < min) { + to_del[value] = 1; + to_add[min] = 1; + } + else if (value > max) { + to_del[value] = 1; + value -= min; + value = value % ((max - min) + 1); // max is inclusive + value += min; + to_add[value] = 1; + } + } + for (var value in to_del) delete values[value]; + for (var value in to_add) values[value] = 1; + + // convert to sorted array + var list = hash_keys_to_array(values); + for (var idx = 0, len = list.length; idx < len; idx++) { + list[idx] = parseInt( list[idx] ); + } + list = list.sort( function(a, b) { return a - b; } ); + if (list.length) timing[key] = list; +}; + +function parse_crontab(raw, rand_seed) { + // parse standard crontab syntax, return timing object + // e.g. 1,2,3,5,20-25,30-35,59 23 31 12 * * + // optional 6th element == years + if (!rand_seed) rand_seed = get_unique_id(); + var timing = {}; + + // resolve all @shortcuts + raw = trim(raw).toLowerCase(); + if (raw.match(/\@(yearly|annually)/)) raw = '0 0 1 1 *'; + else if (raw == '@monthly') raw = '0 0 1 * *'; + else if (raw == '@weekly') raw = '0 0 * * 0'; + else if (raw == '@daily') raw = '0 0 * * *'; + else if (raw == '@hourly') raw = '0 * * * *'; + + // expand all month/wday aliases + raw = raw.replace(cron_alias_re, function(m_all, m_g1) { + return cron_aliases[m_g1]; + } ); + + // at this point string should not contain any alpha characters or '@', except for 'h' + if (raw.match(/([a-gi-z\@]+)/i)) throw new Error("Invalid crontab keyword: " + RegExp.$1); + + // split into parts + var parts = raw.split(/\s+/); + if (parts.length > 6) throw new Error("Invalid crontab format: " + parts.slice(6).join(' ')); + if (!parts[0].length) throw new Error("Invalid crontab format"); + + // parse each part + if ((parts.length > 0) && parts[0].length) parse_crontab_part( timing, parts[0], 'minutes', 0, 59, rand_seed ); + if ((parts.length > 1) && parts[1].length) parse_crontab_part( timing, parts[1], 'hours', 0, 23, rand_seed ); + if ((parts.length > 2) && parts[2].length) parse_crontab_part( timing, parts[2], 'days', 1, 31, rand_seed ); + if ((parts.length > 3) && parts[3].length) parse_crontab_part( timing, parts[3], 'months', 1, 12, rand_seed ); + if ((parts.length > 4) && parts[4].length) parse_crontab_part( timing, parts[4], 'weekdays', 0, 6, rand_seed ); + if ((parts.length > 5) && parts[5].length) parse_crontab_part( timing, parts[5], 'years', 1970, 3000, rand_seed ); + + return timing; +}; + +// TAB handling code from http://www.webdeveloper.com/forum/showthread.php?t=32317 +// Hacked to do my bidding - JH 2008-09-15 +function setSelectionRange(input, selectionStart, selectionEnd) { + if (input.setSelectionRange) { + input.focus(); + input.setSelectionRange(selectionStart, selectionEnd); + } + else if (input.createTextRange) { + var range = input.createTextRange(); + range.collapse(true); + range.moveEnd('character', selectionEnd); + range.moveStart('character', selectionStart); + range.select(); + } +}; + +function replaceSelection (input, replaceString) { + var oldScroll = input.scrollTop; + if (input.setSelectionRange) { + var selectionStart = input.selectionStart; + var selectionEnd = input.selectionEnd; + input.value = input.value.substring(0, selectionStart)+ replaceString + input.value.substring(selectionEnd); + + if (selectionStart != selectionEnd){ + setSelectionRange(input, selectionStart, selectionStart + replaceString.length); + }else{ + setSelectionRange(input, selectionStart + replaceString.length, selectionStart + replaceString.length); + } + + }else if (document.selection) { + var range = document.selection.createRange(); + + if (range.parentElement() == input) { + var isCollapsed = range.text == ''; + range.text = replaceString; + + if (!isCollapsed) { + range.moveStart('character', -replaceString.length); + range.select(); + } + } + } + input.scrollTop = oldScroll; +}; + +function catchTab(item,e){ + var c = e.which ? e.which : e.keyCode; + + if (c == 9){ + replaceSelection(item,String.fromCharCode(9)); + setTimeout("document.getElementById('"+item.id+"').focus();",0); + return false; + } +}; + +function get_text_from_seconds_round_custom(sec, abbrev) { + // convert raw seconds to human-readable relative time + // round to nearest instead of floor, but allow one decimal point if under 10 units + var neg = ''; + if (sec < 0) { sec =- sec; neg = '-'; } + + var text = abbrev ? "sec" : "second"; + var amt = sec; + + if (sec > 59) { + var min = sec / 60; + text = abbrev ? "min" : "minute"; + amt = min; + + if (min > 59) { + var hour = min / 60; + text = abbrev ? "hr" : "hour"; + amt = hour; + + if (hour > 23) { + var day = hour / 24; + text = "day"; + amt = day; + } // hour>23 + } // min>59 + } // sec>59 + + if (amt < 10) amt = Math.round(amt * 10) / 10; + else amt = Math.round(amt); + + var text = "" + amt + " " + text; + if ((amt != 1) && !abbrev) text += "s"; + + return(neg + text); +}; diff --git a/htdocs/js/external/ansi_up.js b/htdocs/js/external/ansi_up.js new file mode 100644 index 0000000..fd5ba8d --- /dev/null +++ b/htdocs/js/external/ansi_up.js @@ -0,0 +1,421 @@ +/* ansi_up.js + * author : Dru Nelson + * license : MIT + * http://github.com/drudru/ansi_up + */ +(function (root, factory) { + if (typeof define === 'function' && define.amd) { + // AMD. Register as an anonymous module. + define(['exports'], factory); + } else if (typeof exports === 'object' && typeof exports.nodeName !== 'string') { + // CommonJS + factory(exports); + } else { + // Browser globals + var exp = {}; + factory(exp); + root.AnsiUp = exp.default; + } +}(this, function (exports) { +"use strict"; +var __makeTemplateObject = (this && this.__makeTemplateObject) || function (cooked, raw) { + if (Object.defineProperty) { Object.defineProperty(cooked, "raw", { value: raw }); } else { cooked.raw = raw; } + return cooked; +}; +var PacketKind; +(function (PacketKind) { + PacketKind[PacketKind["EOS"] = 0] = "EOS"; + PacketKind[PacketKind["Text"] = 1] = "Text"; + PacketKind[PacketKind["Incomplete"] = 2] = "Incomplete"; + PacketKind[PacketKind["ESC"] = 3] = "ESC"; + PacketKind[PacketKind["Unknown"] = 4] = "Unknown"; + PacketKind[PacketKind["SGR"] = 5] = "SGR"; + PacketKind[PacketKind["OSCURL"] = 6] = "OSCURL"; +})(PacketKind || (PacketKind = {})); +var AnsiUp = (function () { + function AnsiUp() { + this.VERSION = "4.0.4"; + this.setup_palettes(); + this._use_classes = false; + this._escape_for_html = true; + this.bold = false; + this.fg = this.bg = null; + this._buffer = ''; + this._url_whitelist = { 'http': 1, 'https': 1 }; + } + Object.defineProperty(AnsiUp.prototype, "use_classes", { + get: function () { + return this._use_classes; + }, + set: function (arg) { + this._use_classes = arg; + }, + enumerable: true, + configurable: true + }); + Object.defineProperty(AnsiUp.prototype, "escape_for_html", { + get: function () { + return this._escape_for_html; + }, + set: function (arg) { + this._escape_for_html = arg; + }, + enumerable: true, + configurable: true + }); + Object.defineProperty(AnsiUp.prototype, "url_whitelist", { + get: function () { + return this._url_whitelist; + }, + set: function (arg) { + this._url_whitelist = arg; + }, + enumerable: true, + configurable: true + }); + AnsiUp.prototype.setup_palettes = function () { + var _this = this; + this.ansi_colors = + [ + [ + { rgb: [0, 0, 0], class_name: "ansi-black" }, + { rgb: [187, 0, 0], class_name: "ansi-red" }, + { rgb: [0, 187, 0], class_name: "ansi-green" }, + { rgb: [187, 187, 0], class_name: "ansi-yellow" }, + { rgb: [0, 0, 187], class_name: "ansi-blue" }, + { rgb: [187, 0, 187], class_name: "ansi-magenta" }, + { rgb: [0, 187, 187], class_name: "ansi-cyan" }, + { rgb: [255, 255, 255], class_name: "ansi-white" } + ], + [ + { rgb: [85, 85, 85], class_name: "ansi-bright-black" }, + { rgb: [255, 85, 85], class_name: "ansi-bright-red" }, + { rgb: [0, 255, 0], class_name: "ansi-bright-green" }, + { rgb: [255, 255, 85], class_name: "ansi-bright-yellow" }, + { rgb: [85, 85, 255], class_name: "ansi-bright-blue" }, + { rgb: [255, 85, 255], class_name: "ansi-bright-magenta" }, + { rgb: [85, 255, 255], class_name: "ansi-bright-cyan" }, + { rgb: [255, 255, 255], class_name: "ansi-bright-white" } + ] + ]; + this.palette_256 = []; + this.ansi_colors.forEach(function (palette) { + palette.forEach(function (rec) { + _this.palette_256.push(rec); + }); + }); + var levels = [0, 95, 135, 175, 215, 255]; + for (var r = 0; r < 6; ++r) { + for (var g = 0; g < 6; ++g) { + for (var b = 0; b < 6; ++b) { + var col = { rgb: [levels[r], levels[g], levels[b]], class_name: 'truecolor' }; + this.palette_256.push(col); + } + } + } + var grey_level = 8; + for (var i = 0; i < 24; ++i, grey_level += 10) { + var gry = { rgb: [grey_level, grey_level, grey_level], class_name: 'truecolor' }; + this.palette_256.push(gry); + } + }; + AnsiUp.prototype.escape_txt_for_html = function (txt) { + return txt.replace(/[&<>]/gm, function (str) { + if (str === "&") + return "&"; + if (str === "<") + return "<"; + if (str === ">") + return ">"; + }); + }; + AnsiUp.prototype.append_buffer = function (txt) { + var str = this._buffer + txt; + this._buffer = str; + }; + AnsiUp.prototype.get_next_packet = function () { + var pkt = { + kind: PacketKind.EOS, + text: '', + url: '' + }; + var len = this._buffer.length; + if (len == 0) + return pkt; + var pos = this._buffer.indexOf("\x1B"); + if (pos == -1) { + pkt.kind = PacketKind.Text; + pkt.text = this._buffer; + this._buffer = ''; + return pkt; + } + if (pos > 0) { + pkt.kind = PacketKind.Text; + pkt.text = this._buffer.slice(0, pos); + this._buffer = this._buffer.slice(pos); + return pkt; + } + if (pos == 0) { + if (len == 1) { + pkt.kind = PacketKind.Incomplete; + return pkt; + } + var next_char = this._buffer.charAt(1); + if ((next_char != '[') && (next_char != ']')) { + pkt.kind = PacketKind.ESC; + pkt.text = this._buffer.slice(0, 1); + this._buffer = this._buffer.slice(1); + return pkt; + } + if (next_char == '[') { + if (!this._csi_regex) { + this._csi_regex = rgx(__makeTemplateObject(["\n ^ # beginning of line\n #\n # First attempt\n (?: # legal sequence\n \u001B[ # CSI\n ([<-?]?) # private-mode char\n ([d;]*) # any digits or semicolons\n ([ -/]? # an intermediate modifier\n [@-~]) # the command\n )\n | # alternate (second attempt)\n (?: # illegal sequence\n \u001B[ # CSI\n [ -~]* # anything legal\n ([\0-\u001F:]) # anything illegal\n )\n "], ["\n ^ # beginning of line\n #\n # First attempt\n (?: # legal sequence\n \\x1b\\[ # CSI\n ([\\x3c-\\x3f]?) # private-mode char\n ([\\d;]*) # any digits or semicolons\n ([\\x20-\\x2f]? # an intermediate modifier\n [\\x40-\\x7e]) # the command\n )\n | # alternate (second attempt)\n (?: # illegal sequence\n \\x1b\\[ # CSI\n [\\x20-\\x7e]* # anything legal\n ([\\x00-\\x1f:]) # anything illegal\n )\n "])); + } + var match = this._buffer.match(this._csi_regex); + if (match === null) { + pkt.kind = PacketKind.Incomplete; + return pkt; + } + if (match[4]) { + pkt.kind = PacketKind.ESC; + pkt.text = this._buffer.slice(0, 1); + this._buffer = this._buffer.slice(1); + return pkt; + } + if ((match[1] != '') || (match[3] != 'm')) + pkt.kind = PacketKind.Unknown; + else + pkt.kind = PacketKind.SGR; + pkt.text = match[2]; + var rpos = match[0].length; + this._buffer = this._buffer.slice(rpos); + return pkt; + } + if (next_char == ']') { + if (len < 4) { + pkt.kind = PacketKind.Incomplete; + return pkt; + } + if ((this._buffer.charAt(2) != '8') + || (this._buffer.charAt(3) != ';')) { + pkt.kind = PacketKind.ESC; + pkt.text = this._buffer.slice(0, 1); + this._buffer = this._buffer.slice(1); + return pkt; + } + if (!this._osc_st) { + this._osc_st = rgxG(__makeTemplateObject(["\n (?: # legal sequence\n (\u001B\\) # ESC | # alternate\n (\u0007) # BEL (what xterm did)\n )\n | # alternate (second attempt)\n ( # illegal sequence\n [\0-\u0006] # anything illegal\n | # alternate\n [\b-\u001A] # anything illegal\n | # alternate\n [\u001C-\u001F] # anything illegal\n )\n "], ["\n (?: # legal sequence\n (\\x1b\\\\) # ESC \\\n | # alternate\n (\\x07) # BEL (what xterm did)\n )\n | # alternate (second attempt)\n ( # illegal sequence\n [\\x00-\\x06] # anything illegal\n | # alternate\n [\\x08-\\x1a] # anything illegal\n | # alternate\n [\\x1c-\\x1f] # anything illegal\n )\n "])); + } + this._osc_st.lastIndex = 0; + { + var match_1 = this._osc_st.exec(this._buffer); + if (match_1 === null) { + pkt.kind = PacketKind.Incomplete; + return pkt; + } + if (match_1[3]) { + pkt.kind = PacketKind.ESC; + pkt.text = this._buffer.slice(0, 1); + this._buffer = this._buffer.slice(1); + return pkt; + } + } + { + var match_2 = this._osc_st.exec(this._buffer); + if (match_2 === null) { + pkt.kind = PacketKind.Incomplete; + return pkt; + } + if (match_2[3]) { + pkt.kind = PacketKind.ESC; + pkt.text = this._buffer.slice(0, 1); + this._buffer = this._buffer.slice(1); + return pkt; + } + } + if (!this._osc_regex) { + this._osc_regex = rgx(__makeTemplateObject(["\n ^ # beginning of line\n #\n \u001B]8; # OSC Hyperlink\n [ -:<-~]* # params (excluding ;)\n ; # end of params\n ([!-~]{0,512}) # URL capture\n (?: # ST\n (?:\u001B\\) # ESC | # alternate\n (?:\u0007) # BEL (what xterm did)\n )\n ([!-~]+) # TEXT capture\n \u001B]8;; # OSC Hyperlink End\n (?: # ST\n (?:\u001B\\) # ESC | # alternate\n (?:\u0007) # BEL (what xterm did)\n )\n "], ["\n ^ # beginning of line\n #\n \\x1b\\]8; # OSC Hyperlink\n [\\x20-\\x3a\\x3c-\\x7e]* # params (excluding ;)\n ; # end of params\n ([\\x21-\\x7e]{0,512}) # URL capture\n (?: # ST\n (?:\\x1b\\\\) # ESC \\\n | # alternate\n (?:\\x07) # BEL (what xterm did)\n )\n ([\\x21-\\x7e]+) # TEXT capture\n \\x1b\\]8;; # OSC Hyperlink End\n (?: # ST\n (?:\\x1b\\\\) # ESC \\\n | # alternate\n (?:\\x07) # BEL (what xterm did)\n )\n "])); + } + var match = this._buffer.match(this._osc_regex); + if (match === null) { + pkt.kind = PacketKind.ESC; + pkt.text = this._buffer.slice(0, 1); + this._buffer = this._buffer.slice(1); + return pkt; + } + pkt.kind = PacketKind.OSCURL; + pkt.url = match[1]; + pkt.text = match[2]; + var rpos = match[0].length; + this._buffer = this._buffer.slice(rpos); + return pkt; + } + } + }; + AnsiUp.prototype.ansi_to_html = function (txt) { + this.append_buffer(txt); + var blocks = []; + while (true) { + var packet = this.get_next_packet(); + if ((packet.kind == PacketKind.EOS) + || (packet.kind == PacketKind.Incomplete)) + break; + if ((packet.kind == PacketKind.ESC) + || (packet.kind == PacketKind.Unknown)) + continue; + if (packet.kind == PacketKind.Text) + blocks.push(this.transform_to_html(this.with_state(packet))); + else if (packet.kind == PacketKind.SGR) + this.process_ansi(packet); + else if (packet.kind == PacketKind.OSCURL) + blocks.push(this.process_hyperlink(packet)); + } + return blocks.join(""); + }; + AnsiUp.prototype.with_state = function (pkt) { + return { bold: this.bold, fg: this.fg, bg: this.bg, text: pkt.text }; + }; + AnsiUp.prototype.process_ansi = function (pkt) { + var sgr_cmds = pkt.text.split(';'); + while (sgr_cmds.length > 0) { + var sgr_cmd_str = sgr_cmds.shift(); + var num = parseInt(sgr_cmd_str, 10); + if (isNaN(num) || num === 0) { + this.fg = this.bg = null; + this.bold = false; + } + else if (num === 1) { + this.bold = true; + } + else if (num === 22) { + this.bold = false; + } + else if (num === 39) { + this.fg = null; + } + else if (num === 49) { + this.bg = null; + } + else if ((num >= 30) && (num < 38)) { + this.fg = this.ansi_colors[0][(num - 30)]; + } + else if ((num >= 40) && (num < 48)) { + this.bg = this.ansi_colors[0][(num - 40)]; + } + else if ((num >= 90) && (num < 98)) { + this.fg = this.ansi_colors[1][(num - 90)]; + } + else if ((num >= 100) && (num < 108)) { + this.bg = this.ansi_colors[1][(num - 100)]; + } + else if (num === 38 || num === 48) { + if (sgr_cmds.length > 0) { + var is_foreground = (num === 38); + var mode_cmd = sgr_cmds.shift(); + if (mode_cmd === '5' && sgr_cmds.length > 0) { + var palette_index = parseInt(sgr_cmds.shift(), 10); + if (palette_index >= 0 && palette_index <= 255) { + if (is_foreground) + this.fg = this.palette_256[palette_index]; + else + this.bg = this.palette_256[palette_index]; + } + } + if (mode_cmd === '2' && sgr_cmds.length > 2) { + var r = parseInt(sgr_cmds.shift(), 10); + var g = parseInt(sgr_cmds.shift(), 10); + var b = parseInt(sgr_cmds.shift(), 10); + if ((r >= 0 && r <= 255) && (g >= 0 && g <= 255) && (b >= 0 && b <= 255)) { + var c = { rgb: [r, g, b], class_name: 'truecolor' }; + if (is_foreground) + this.fg = c; + else + this.bg = c; + } + } + } + } + } + }; + AnsiUp.prototype.transform_to_html = function (fragment) { + var txt = fragment.text; + if (txt.length === 0) + return txt; + if (this._escape_for_html) + txt = this.escape_txt_for_html(txt); + if (!fragment.bold && fragment.fg === null && fragment.bg === null) + return txt; + var styles = []; + var classes = []; + var fg = fragment.fg; + var bg = fragment.bg; + if (fragment.bold) + styles.push('font-weight:bold'); + if (!this._use_classes) { + if (fg) + styles.push("color:rgb(" + fg.rgb.join(',') + ")"); + if (bg) + styles.push("background-color:rgb(" + bg.rgb + ")"); + } + else { + if (fg) { + if (fg.class_name !== 'truecolor') { + classes.push(fg.class_name + "-fg"); + } + else { + styles.push("color:rgb(" + fg.rgb.join(',') + ")"); + } + } + if (bg) { + if (bg.class_name !== 'truecolor') { + classes.push(bg.class_name + "-bg"); + } + else { + styles.push("background-color:rgb(" + bg.rgb.join(',') + ")"); + } + } + } + var class_string = ''; + var style_string = ''; + if (classes.length) + class_string = " class=\"" + classes.join(' ') + "\""; + if (styles.length) + style_string = " style=\"" + styles.join(';') + "\""; + return "" + txt + ""; + }; + ; + AnsiUp.prototype.process_hyperlink = function (pkt) { + var parts = pkt.url.split(':'); + if (parts.length < 1) + return ''; + if (!this._url_whitelist[parts[0]]) + return ''; + var result = "" + this.escape_txt_for_html(pkt.text) + ""; + return result; + }; + return AnsiUp; +}()); +function rgx(tmplObj) { + var subst = []; + for (var _i = 1; _i < arguments.length; _i++) { + subst[_i - 1] = arguments[_i]; + } + var regexText = tmplObj.raw[0]; + var wsrgx = /^\s+|\s+\n|\s*#[\s\S]*?\n|\n/gm; + var txt2 = regexText.replace(wsrgx, ''); + return new RegExp(txt2); +} +function rgxG(tmplObj) { + var subst = []; + for (var _i = 1; _i < arguments.length; _i++) { + subst[_i - 1] = arguments[_i]; + } + var regexText = tmplObj.raw[0]; + var wsrgx = /^\s+|\s+\n|\s*#[\s\S]*?\n|\n/gm; + var txt2 = regexText.replace(wsrgx, ''); + return new RegExp(txt2, 'g'); +} +//# sourceMappingURL=ansi_up.js.map + Object.defineProperty(exports, "__esModule", { value: true }); + exports.default = AnsiUp; +})); diff --git a/htdocs/js/external/graphlib.min.js b/htdocs/js/external/graphlib.min.js new file mode 100644 index 0000000..68d1e41 --- /dev/null +++ b/htdocs/js/external/graphlib.min.js @@ -0,0 +1,2522 @@ +(function(f){if(typeof exports==="object"&&typeof module!=="undefined"){module.exports=f()}else if(typeof define==="function"&&define.amd){define([],f)}else{var g;if(typeof window!=="undefined"){g=window}else if(typeof global!=="undefined"){g=global}else if(typeof self!=="undefined"){g=self}else{g=this}g.graphlib=f()}})(function(){var define,module,exports;return function(){function r(e,n,t){function o(i,f){if(!n[i]){if(!e[i]){var c="function"==typeof require&&require;if(!f&&c)return c(i,!0);if(u)return u(i,!0);var a=new Error("Cannot find module '"+i+"'");throw a.code="MODULE_NOT_FOUND",a}var p=n[i]={exports:{}};e[i][0].call(p.exports,function(r){var n=e[i][1][r];return o(n||r)},p,p.exports,r,e,n,t)}return n[i].exports}for(var u="function"==typeof require&&require,i=0;i0){v=pq.removeMin();vEntry=results[v];if(vEntry.distance===Number.POSITIVE_INFINITY){break}edgeFn(v).forEach(updateNeighbors)}return results}},{"../data/priority-queue":15,"../lodash":19}],6:[function(require,module,exports){var _=require("../lodash");var tarjan=require("./tarjan");module.exports=findCycles;function findCycles(g){return _.filter(tarjan(g),function(cmpt){return cmpt.length>1||cmpt.length===1&&g.hasEdge(cmpt[0],cmpt[0])})}},{"../lodash":19,"./tarjan":13}],7:[function(require,module,exports){var _=require("../lodash");module.exports=floydWarshall;var DEFAULT_WEIGHT_FUNC=_.constant(1);function floydWarshall(g,weightFn,edgeFn){return runFloydWarshall(g,weightFn||DEFAULT_WEIGHT_FUNC,edgeFn||function(v){return g.outEdges(v)})}function runFloydWarshall(g,weightFn,edgeFn){var results={};var nodes=g.nodes();nodes.forEach(function(v){results[v]={};results[v][v]={distance:0};nodes.forEach(function(w){if(v!==w){results[v][w]={distance:Number.POSITIVE_INFINITY}}});edgeFn(v).forEach(function(edge){var w=edge.v===v?edge.w:edge.v;var d=weightFn(edge);results[v][w]={distance:d,predecessor:v}})});nodes.forEach(function(k){var rowK=results[k];nodes.forEach(function(i){var rowI=results[i];nodes.forEach(function(j){var ik=rowI[k];var kj=rowK[j];var ij=rowI[j];var altDistance=ik.distance+kj.distance;if(altDistance0){v=pq.removeMin();if(_.has(parents,v)){result.setEdge(v,parents[v])}else if(init){throw new Error("Input graph is not connected: "+g)}else{init=true}g.nodeEdges(v).forEach(updateNeighbors)}return result}},{"../data/priority-queue":15,"../graph":16,"../lodash":19}],13:[function(require,module,exports){var _=require("../lodash");module.exports=tarjan;function tarjan(g){var index=0;var stack=[];var visited={};// node id -> { onStack, lowlink, index } +var results=[];function dfs(v){var entry=visited[v]={onStack:true,lowlink:index,index:index++};stack.push(v);g.successors(v).forEach(function(w){if(!_.has(visited,w)){dfs(w);entry.lowlink=Math.min(entry.lowlink,visited[w].lowlink)}else if(visited[w].onStack){entry.lowlink=Math.min(entry.lowlink,visited[w].index)}});if(entry.lowlink===entry.index){var cmpt=[];var w;do{w=stack.pop();visited[w].onStack=false;cmpt.push(w)}while(v!==w);results.push(cmpt)}}g.nodes().forEach(function(v){if(!_.has(visited,v)){dfs(v)}});return results}},{"../lodash":19}],14:[function(require,module,exports){var _=require("../lodash");module.exports=topsort;topsort.CycleException=CycleException;function topsort(g){var visited={};var stack={};var results=[];function visit(node){if(_.has(stack,node)){throw new CycleException}if(!_.has(visited,node)){stack[node]=true;visited[node]=true;_.each(g.predecessors(node),visit);delete stack[node];results.push(node)}}_.each(g.sinks(),visit);if(_.size(visited)!==g.nodeCount()){throw new CycleException}return results}function CycleException(){}CycleException.prototype=new Error;// must be an instance of Error to pass testing +},{"../lodash":19}],15:[function(require,module,exports){var _=require("../lodash");module.exports=PriorityQueue; +/** + * A min-priority queue data structure. This algorithm is derived from Cormen, + * et al., "Introduction to Algorithms". The basic idea of a min-priority + * queue is that you can efficiently (in O(1) time) get the smallest key in + * the queue. Adding and removing elements takes O(log n) time. A key can + * have its priority decreased in O(log n) time. + */function PriorityQueue(){this._arr=[];this._keyIndices={}} +/** + * Returns the number of elements in the queue. Takes `O(1)` time. + */PriorityQueue.prototype.size=function(){return this._arr.length}; +/** + * Returns the keys that are in the queue. Takes `O(n)` time. + */PriorityQueue.prototype.keys=function(){return this._arr.map(function(x){return x.key})}; +/** + * Returns `true` if **key** is in the queue and `false` if not. + */PriorityQueue.prototype.has=function(key){return _.has(this._keyIndices,key)}; +/** + * Returns the priority for **key**. If **key** is not present in the queue + * then this function returns `undefined`. Takes `O(1)` time. + * + * @param {Object} key + */PriorityQueue.prototype.priority=function(key){var index=this._keyIndices[key];if(index!==undefined){return this._arr[index].priority}}; +/** + * Returns the key for the minimum element in this queue. If the queue is + * empty this function throws an Error. Takes `O(1)` time. + */PriorityQueue.prototype.min=function(){if(this.size()===0){throw new Error("Queue underflow")}return this._arr[0].key}; +/** + * Inserts a new key into the priority queue. If the key already exists in + * the queue this function returns `false`; otherwise it will return `true`. + * Takes `O(n)` time. + * + * @param {Object} key the key to add + * @param {Number} priority the initial priority for the key + */PriorityQueue.prototype.add=function(key,priority){var keyIndices=this._keyIndices;key=String(key);if(!_.has(keyIndices,key)){var arr=this._arr;var index=arr.length;keyIndices[key]=index;arr.push({key:key,priority:priority});this._decrease(index);return true}return false}; +/** + * Removes and returns the smallest key in the queue. Takes `O(log n)` time. + */PriorityQueue.prototype.removeMin=function(){this._swap(0,this._arr.length-1);var min=this._arr.pop();delete this._keyIndices[min.key];this._heapify(0);return min.key}; +/** + * Decreases the priority for **key** to **priority**. If the new priority is + * greater than the previous priority, this function will throw an Error. + * + * @param {Object} key the key for which to raise priority + * @param {Number} priority the new priority for the key + */PriorityQueue.prototype.decrease=function(key,priority){var index=this._keyIndices[key];if(priority>this._arr[index].priority){throw new Error("New priority is greater than current priority. "+"Key: "+key+" Old: "+this._arr[index].priority+" New: "+priority)}this._arr[index].priority=priority;this._decrease(index)};PriorityQueue.prototype._heapify=function(i){var arr=this._arr;var l=2*i;var r=l+1;var largest=i;if(l>1;if(arr[parent].priority label +this._nodes={};if(this._isCompound){ +// v -> parent +this._parent={}; +// v -> children +this._children={};this._children[GRAPH_NODE]={}} +// v -> edgeObj +this._in={}; +// u -> v -> Number +this._preds={}; +// v -> edgeObj +this._out={}; +// v -> w -> Number +this._sucs={}; +// e -> edgeObj +this._edgeObjs={}; +// e -> label +this._edgeLabels={}} +/* Number of nodes in the graph. Should only be changed by the implementation. */Graph.prototype._nodeCount=0; +/* Number of edges in the graph. Should only be changed by the implementation. */Graph.prototype._edgeCount=0; +/* === Graph functions ========= */Graph.prototype.isDirected=function(){return this._isDirected};Graph.prototype.isMultigraph=function(){return this._isMultigraph};Graph.prototype.isCompound=function(){return this._isCompound};Graph.prototype.setGraph=function(label){this._label=label;return this};Graph.prototype.graph=function(){return this._label}; +/* === Node functions ========== */Graph.prototype.setDefaultNodeLabel=function(newDefault){if(!_.isFunction(newDefault)){newDefault=_.constant(newDefault)}this._defaultNodeLabelFn=newDefault;return this};Graph.prototype.nodeCount=function(){return this._nodeCount};Graph.prototype.nodes=function(){return _.keys(this._nodes)};Graph.prototype.sources=function(){var self=this;return _.filter(this.nodes(),function(v){return _.isEmpty(self._in[v])})};Graph.prototype.sinks=function(){var self=this;return _.filter(this.nodes(),function(v){return _.isEmpty(self._out[v])})};Graph.prototype.setNodes=function(vs,value){var args=arguments;var self=this;_.each(vs,function(v){if(args.length>1){self.setNode(v,value)}else{self.setNode(v)}});return this};Graph.prototype.setNode=function(v,value){if(_.has(this._nodes,v)){if(arguments.length>1){this._nodes[v]=value}return this}this._nodes[v]=arguments.length>1?value:this._defaultNodeLabelFn(v);if(this._isCompound){this._parent[v]=GRAPH_NODE;this._children[v]={};this._children[GRAPH_NODE][v]=true}this._in[v]={};this._preds[v]={};this._out[v]={};this._sucs[v]={};++this._nodeCount;return this};Graph.prototype.node=function(v){return this._nodes[v]};Graph.prototype.hasNode=function(v){return _.has(this._nodes,v)};Graph.prototype.removeNode=function(v){var self=this;if(_.has(this._nodes,v)){var removeEdge=function(e){self.removeEdge(self._edgeObjs[e])};delete this._nodes[v];if(this._isCompound){this._removeFromParentsChildList(v);delete this._parent[v];_.each(this.children(v),function(child){self.setParent(child)});delete this._children[v]}_.each(_.keys(this._in[v]),removeEdge);delete this._in[v];delete this._preds[v];_.each(_.keys(this._out[v]),removeEdge);delete this._out[v];delete this._sucs[v];--this._nodeCount}return this};Graph.prototype.setParent=function(v,parent){if(!this._isCompound){throw new Error("Cannot set parent in a non-compound graph")}if(_.isUndefined(parent)){parent=GRAPH_NODE}else{ +// Coerce parent to string +parent+="";for(var ancestor=parent;!_.isUndefined(ancestor);ancestor=this.parent(ancestor)){if(ancestor===v){throw new Error("Setting "+parent+" as parent of "+v+" would create a cycle")}}this.setNode(parent)}this.setNode(v);this._removeFromParentsChildList(v);this._parent[v]=parent;this._children[parent][v]=true;return this};Graph.prototype._removeFromParentsChildList=function(v){delete this._children[this._parent[v]][v]};Graph.prototype.parent=function(v){if(this._isCompound){var parent=this._parent[v];if(parent!==GRAPH_NODE){return parent}}};Graph.prototype.children=function(v){if(_.isUndefined(v)){v=GRAPH_NODE}if(this._isCompound){var children=this._children[v];if(children){return _.keys(children)}}else if(v===GRAPH_NODE){return this.nodes()}else if(this.hasNode(v)){return[]}};Graph.prototype.predecessors=function(v){var predsV=this._preds[v];if(predsV){return _.keys(predsV)}};Graph.prototype.successors=function(v){var sucsV=this._sucs[v];if(sucsV){return _.keys(sucsV)}};Graph.prototype.neighbors=function(v){var preds=this.predecessors(v);if(preds){return _.union(preds,this.successors(v))}};Graph.prototype.isLeaf=function(v){var neighbors;if(this.isDirected()){neighbors=this.successors(v)}else{neighbors=this.neighbors(v)}return neighbors.length===0};Graph.prototype.filterNodes=function(filter){var copy=new this.constructor({directed:this._isDirected,multigraph:this._isMultigraph,compound:this._isCompound});copy.setGraph(this.graph());var self=this;_.each(this._nodes,function(value,v){if(filter(v)){copy.setNode(v,value)}});_.each(this._edgeObjs,function(e){if(copy.hasNode(e.v)&©.hasNode(e.w)){copy.setEdge(e,self.edge(e))}});var parents={};function findParent(v){var parent=self.parent(v);if(parent===undefined||copy.hasNode(parent)){parents[v]=parent;return parent}else if(parent in parents){return parents[parent]}else{return findParent(parent)}}if(this._isCompound){_.each(copy.nodes(),function(v){copy.setParent(v,findParent(v))})}return copy}; +/* === Edge functions ========== */Graph.prototype.setDefaultEdgeLabel=function(newDefault){if(!_.isFunction(newDefault)){newDefault=_.constant(newDefault)}this._defaultEdgeLabelFn=newDefault;return this};Graph.prototype.edgeCount=function(){return this._edgeCount};Graph.prototype.edges=function(){return _.values(this._edgeObjs)};Graph.prototype.setPath=function(vs,value){var self=this;var args=arguments;_.reduce(vs,function(v,w){if(args.length>1){self.setEdge(v,w,value)}else{self.setEdge(v,w)}return w});return this}; +/* + * setEdge(v, w, [value, [name]]) + * setEdge({ v, w, [name] }, [value]) + */Graph.prototype.setEdge=function(){var v,w,name,value;var valueSpecified=false;var arg0=arguments[0];if(typeof arg0==="object"&&arg0!==null&&"v"in arg0){v=arg0.v;w=arg0.w;name=arg0.name;if(arguments.length===2){value=arguments[1];valueSpecified=true}}else{v=arg0;w=arguments[1];name=arguments[3];if(arguments.length>2){value=arguments[2];valueSpecified=true}}v=""+v;w=""+w;if(!_.isUndefined(name)){name=""+name}var e=edgeArgsToId(this._isDirected,v,w,name);if(_.has(this._edgeLabels,e)){if(valueSpecified){this._edgeLabels[e]=value}return this}if(!_.isUndefined(name)&&!this._isMultigraph){throw new Error("Cannot set a named edge when isMultigraph = false")} +// It didn't exist, so we need to create it. +// First ensure the nodes exist. +this.setNode(v);this.setNode(w);this._edgeLabels[e]=valueSpecified?value:this._defaultEdgeLabelFn(v,w,name);var edgeObj=edgeArgsToObj(this._isDirected,v,w,name); +// Ensure we add undirected edges in a consistent way. +v=edgeObj.v;w=edgeObj.w;Object.freeze(edgeObj);this._edgeObjs[e]=edgeObj;incrementOrInitEntry(this._preds[w],v);incrementOrInitEntry(this._sucs[v],w);this._in[w][e]=edgeObj;this._out[v][e]=edgeObj;this._edgeCount++;return this};Graph.prototype.edge=function(v,w,name){var e=arguments.length===1?edgeObjToId(this._isDirected,arguments[0]):edgeArgsToId(this._isDirected,v,w,name);return this._edgeLabels[e]};Graph.prototype.hasEdge=function(v,w,name){var e=arguments.length===1?edgeObjToId(this._isDirected,arguments[0]):edgeArgsToId(this._isDirected,v,w,name);return _.has(this._edgeLabels,e)};Graph.prototype.removeEdge=function(v,w,name){var e=arguments.length===1?edgeObjToId(this._isDirected,arguments[0]):edgeArgsToId(this._isDirected,v,w,name);var edge=this._edgeObjs[e];if(edge){v=edge.v;w=edge.w;delete this._edgeLabels[e];delete this._edgeObjs[e];decrementOrRemoveEntry(this._preds[w],v);decrementOrRemoveEntry(this._sucs[v],w);delete this._in[w][e];delete this._out[v][e];this._edgeCount--}return this};Graph.prototype.inEdges=function(v,u){var inV=this._in[v];if(inV){var edges=_.values(inV);if(!u){return edges}return _.filter(edges,function(edge){return edge.v===u})}};Graph.prototype.outEdges=function(v,w){var outV=this._out[v];if(outV){var edges=_.values(outV);if(!w){return edges}return _.filter(edges,function(edge){return edge.w===w})}};Graph.prototype.nodeEdges=function(v,w){var inEdges=this.inEdges(v,w);if(inEdges){return inEdges.concat(this.outEdges(v,w))}};function incrementOrInitEntry(map,k){if(map[k]){map[k]++}else{map[k]=1}}function decrementOrRemoveEntry(map,k){if(!--map[k]){delete map[k]}}function edgeArgsToId(isDirected,v_,w_,name){var v=""+v_;var w=""+w_;if(!isDirected&&v>w){var tmp=v;v=w;w=tmp}return v+EDGE_KEY_DELIM+w+EDGE_KEY_DELIM+(_.isUndefined(name)?DEFAULT_EDGE_NAME:name)}function edgeArgsToObj(isDirected,v_,w_,name){var v=""+v_;var w=""+w_;if(!isDirected&&v>w){var tmp=v;v=w;w=tmp}var edgeObj={v:v,w:w};if(name){edgeObj.name=name}return edgeObj}function edgeObjToId(isDirected,edgeObj){return edgeArgsToId(isDirected,edgeObj.v,edgeObj.w,edgeObj.name)}},{"./lodash":19}],17:[function(require,module,exports){ +// Includes only the "core" of graphlib +module.exports={Graph:require("./graph"),version:require("./version")}},{"./graph":16,"./version":20}],18:[function(require,module,exports){var _=require("./lodash");var Graph=require("./graph");module.exports={write:write,read:read};function write(g){var json={options:{directed:g.isDirected(),multigraph:g.isMultigraph(),compound:g.isCompound()},nodes:writeNodes(g),edges:writeEdges(g)};if(!_.isUndefined(g.graph())){json.value=_.clone(g.graph())}return json}function writeNodes(g){return _.map(g.nodes(),function(v){var nodeValue=g.node(v);var parent=g.parent(v);var node={v:v};if(!_.isUndefined(nodeValue)){node.value=nodeValue}if(!_.isUndefined(parent)){node.parent=parent}return node})}function writeEdges(g){return _.map(g.edges(),function(e){var edgeValue=g.edge(e);var edge={v:e.v,w:e.w};if(!_.isUndefined(e.name)){edge.name=e.name}if(!_.isUndefined(edgeValue)){edge.value=edgeValue}return edge})}function read(json){var g=new Graph(json.options).setGraph(json.value);_.each(json.nodes,function(entry){g.setNode(entry.v,entry.value);if(entry.parent){g.setParent(entry.v,entry.parent)}});_.each(json.edges,function(entry){g.setEdge({v:entry.v,w:entry.w,name:entry.name},entry.value)});return g}},{"./graph":16,"./lodash":19}],19:[function(require,module,exports){ +/* global window */ +var lodash;if(typeof require==="function"){try{lodash={clone:require("lodash/clone"),constant:require("lodash/constant"),each:require("lodash/each"),filter:require("lodash/filter"),has:require("lodash/has"),isArray:require("lodash/isArray"),isEmpty:require("lodash/isEmpty"),isFunction:require("lodash/isFunction"),isUndefined:require("lodash/isUndefined"),keys:require("lodash/keys"),map:require("lodash/map"),reduce:require("lodash/reduce"),size:require("lodash/size"),transform:require("lodash/transform"),union:require("lodash/union"),values:require("lodash/values")}}catch(e){ +// continue regardless of error +}}if(!lodash){lodash=window._}module.exports=lodash},{"lodash/clone":175,"lodash/constant":176,"lodash/each":177,"lodash/filter":179,"lodash/has":182,"lodash/isArray":186,"lodash/isEmpty":190,"lodash/isFunction":191,"lodash/isUndefined":200,"lodash/keys":201,"lodash/map":203,"lodash/reduce":207,"lodash/size":208,"lodash/transform":212,"lodash/union":213,"lodash/values":214}],20:[function(require,module,exports){module.exports="2.1.8"},{}],21:[function(require,module,exports){var getNative=require("./_getNative"),root=require("./_root"); +/* Built-in method references that are verified to be native. */var DataView=getNative(root,"DataView");module.exports=DataView},{"./_getNative":114,"./_root":158}],22:[function(require,module,exports){var hashClear=require("./_hashClear"),hashDelete=require("./_hashDelete"),hashGet=require("./_hashGet"),hashHas=require("./_hashHas"),hashSet=require("./_hashSet"); +/** + * Creates a hash object. + * + * @private + * @constructor + * @param {Array} [entries] The key-value pairs to cache. + */function Hash(entries){var index=-1,length=entries==null?0:entries.length;this.clear();while(++index-1}module.exports=arrayIncludes},{"./_baseIndexOf":62}],37:[function(require,module,exports){ +/** + * This function is like `arrayIncludes` except that it accepts a comparator. + * + * @private + * @param {Array} [array] The array to inspect. + * @param {*} target The value to search for. + * @param {Function} comparator The comparator invoked per element. + * @returns {boolean} Returns `true` if `target` is found, else `false`. + */ +function arrayIncludesWith(array,value,comparator){var index=-1,length=array==null?0:array.length;while(++index0&&predicate(value)){if(depth>1){ +// Recursively flatten arrays (susceptible to call stack limits). +baseFlatten(value,depth-1,predicate,isStrict,result)}else{arrayPush(result,value)}}else if(!isStrict){result[result.length]=value}}return result}module.exports=baseFlatten},{"./_arrayPush":40,"./_isFlattenable":131}],55:[function(require,module,exports){var createBaseFor=require("./_createBaseFor"); +/** + * The base implementation of `baseForOwn` which iterates over `object` + * properties returned by `keysFunc` and invokes `iteratee` for each property. + * Iteratee functions may exit iteration early by explicitly returning `false`. + * + * @private + * @param {Object} object The object to iterate over. + * @param {Function} iteratee The function invoked per iteration. + * @param {Function} keysFunc The function to get the keys of `object`. + * @returns {Object} Returns `object`. + */var baseFor=createBaseFor();module.exports=baseFor},{"./_createBaseFor":103}],56:[function(require,module,exports){var baseFor=require("./_baseFor"),keys=require("./keys"); +/** + * The base implementation of `_.forOwn` without support for iteratee shorthands. + * + * @private + * @param {Object} object The object to iterate over. + * @param {Function} iteratee The function invoked per iteration. + * @returns {Object} Returns `object`. + */function baseForOwn(object,iteratee){return object&&baseFor(object,iteratee,keys)}module.exports=baseForOwn},{"./_baseFor":55,"./keys":201}],57:[function(require,module,exports){var castPath=require("./_castPath"),toKey=require("./_toKey"); +/** + * The base implementation of `_.get` without support for default values. + * + * @private + * @param {Object} object The object to query. + * @param {Array|string} path The path of the property to get. + * @returns {*} Returns the resolved value. + */function baseGet(object,path){path=castPath(path,object);var index=0,length=path.length;while(object!=null&&index=LARGE_ARRAY_SIZE){var set=iteratee?null:createSet(array);if(set){return setToArray(set)}isCommon=false;includes=cacheHas;seen=new SetCache}else{seen=iteratee?[]:result}outer:while(++indexarrLength)){return false} +// Assume cyclic values are equal. +var stacked=stack.get(array);if(stacked&&stack.get(other)){return stacked==other}var index=-1,result=true,seen=bitmask&COMPARE_UNORDERED_FLAG?new SetCache:undefined;stack.set(array,other);stack.set(other,array); +// Ignore non-index properties. +while(++index-1&&value%1==0&&value-1}module.exports=listCacheHas},{"./_assocIndexOf":45}],142:[function(require,module,exports){var assocIndexOf=require("./_assocIndexOf"); +/** + * Sets the list cache `key` to `value`. + * + * @private + * @name set + * @memberOf ListCache + * @param {string} key The key of the value to set. + * @param {*} value The value to set. + * @returns {Object} Returns the list cache instance. + */function listCacheSet(key,value){var data=this.__data__,index=assocIndexOf(data,key);if(index<0){++this.size;data.push([key,value])}else{data[index][1]=value}return this}module.exports=listCacheSet},{"./_assocIndexOf":45}],143:[function(require,module,exports){var Hash=require("./_Hash"),ListCache=require("./_ListCache"),Map=require("./_Map"); +/** + * Removes all key-value entries from the map. + * + * @private + * @name clear + * @memberOf MapCache + */function mapCacheClear(){this.size=0;this.__data__={hash:new Hash,map:new(Map||ListCache),string:new Hash}}module.exports=mapCacheClear},{"./_Hash":22,"./_ListCache":23,"./_Map":24}],144:[function(require,module,exports){var getMapData=require("./_getMapData"); +/** + * Removes `key` and its value from the map. + * + * @private + * @name delete + * @memberOf MapCache + * @param {string} key The key of the value to remove. + * @returns {boolean} Returns `true` if the entry was removed, else `false`. + */function mapCacheDelete(key){var result=getMapData(this,key)["delete"](key);this.size-=result?1:0;return result}module.exports=mapCacheDelete},{"./_getMapData":112}],145:[function(require,module,exports){var getMapData=require("./_getMapData"); +/** + * Gets the map value for `key`. + * + * @private + * @name get + * @memberOf MapCache + * @param {string} key The key of the value to get. + * @returns {*} Returns the entry value. + */function mapCacheGet(key){return getMapData(this,key).get(key)}module.exports=mapCacheGet},{"./_getMapData":112}],146:[function(require,module,exports){var getMapData=require("./_getMapData"); +/** + * Checks if a map value for `key` exists. + * + * @private + * @name has + * @memberOf MapCache + * @param {string} key The key of the entry to check. + * @returns {boolean} Returns `true` if an entry for `key` exists, else `false`. + */function mapCacheHas(key){return getMapData(this,key).has(key)}module.exports=mapCacheHas},{"./_getMapData":112}],147:[function(require,module,exports){var getMapData=require("./_getMapData"); +/** + * Sets the map `key` to `value`. + * + * @private + * @name set + * @memberOf MapCache + * @param {string} key The key of the value to set. + * @param {*} value The value to set. + * @returns {Object} Returns the map cache instance. + */function mapCacheSet(key,value){var data=getMapData(this,key),size=data.size;data.set(key,value);this.size+=data.size==size?0:1;return this}module.exports=mapCacheSet},{"./_getMapData":112}],148:[function(require,module,exports){ +/** + * Converts `map` to its key-value pairs. + * + * @private + * @param {Object} map The map to convert. + * @returns {Array} Returns the key-value pairs. + */ +function mapToArray(map){var index=-1,result=Array(map.size);map.forEach(function(value,key){result[++index]=[key,value]});return result}module.exports=mapToArray},{}],149:[function(require,module,exports){ +/** + * A specialized version of `matchesProperty` for source values suitable + * for strict equality comparisons, i.e. `===`. + * + * @private + * @param {string} key The key of the property to get. + * @param {*} srcValue The value to match. + * @returns {Function} Returns the new spec function. + */ +function matchesStrictComparable(key,srcValue){return function(object){if(object==null){return false}return object[key]===srcValue&&(srcValue!==undefined||key in Object(object))}}module.exports=matchesStrictComparable},{}],150:[function(require,module,exports){var memoize=require("./memoize"); +/** Used as the maximum memoize cache size. */var MAX_MEMOIZE_SIZE=500; +/** + * A specialized version of `_.memoize` which clears the memoized function's + * cache when it exceeds `MAX_MEMOIZE_SIZE`. + * + * @private + * @param {Function} func The function to have its output memoized. + * @returns {Function} Returns the new memoized function. + */function memoizeCapped(func){var result=memoize(func,function(key){if(cache.size===MAX_MEMOIZE_SIZE){cache.clear()}return key});var cache=result.cache;return result}module.exports=memoizeCapped},{"./memoize":204}],151:[function(require,module,exports){var getNative=require("./_getNative"); +/* Built-in method references that are verified to be native. */var nativeCreate=getNative(Object,"create");module.exports=nativeCreate},{"./_getNative":114}],152:[function(require,module,exports){var overArg=require("./_overArg"); +/* Built-in method references for those with the same name as other `lodash` methods. */var nativeKeys=overArg(Object.keys,Object);module.exports=nativeKeys},{"./_overArg":156}],153:[function(require,module,exports){ +/** + * This function is like + * [`Object.keys`](http://ecma-international.org/ecma-262/7.0/#sec-object.keys) + * except that it includes inherited enumerable properties. + * + * @private + * @param {Object} object The object to query. + * @returns {Array} Returns the array of property names. + */ +function nativeKeysIn(object){var result=[];if(object!=null){for(var key in Object(object)){result.push(key)}}return result}module.exports=nativeKeysIn},{}],154:[function(require,module,exports){var freeGlobal=require("./_freeGlobal"); +/** Detect free variable `exports`. */var freeExports=typeof exports=="object"&&exports&&!exports.nodeType&&exports; +/** Detect free variable `module`. */var freeModule=freeExports&&typeof module=="object"&&module&&!module.nodeType&&module; +/** Detect the popular CommonJS extension `module.exports`. */var moduleExports=freeModule&&freeModule.exports===freeExports; +/** Detect free variable `process` from Node.js. */var freeProcess=moduleExports&&freeGlobal.process; +/** Used to access faster Node.js helpers. */var nodeUtil=function(){try{ +// Use `util.types` for Node.js 10+. +var types=freeModule&&freeModule.require&&freeModule.require("util").types;if(types){return types} +// Legacy `process.binding('util')` for Node.js < 10. +return freeProcess&&freeProcess.binding&&freeProcess.binding("util")}catch(e){}}();module.exports=nodeUtil},{"./_freeGlobal":109}],155:[function(require,module,exports){ +/** Used for built-in method references. */ +var objectProto=Object.prototype; +/** + * Used to resolve the + * [`toStringTag`](http://ecma-international.org/ecma-262/7.0/#sec-object.prototype.tostring) + * of values. + */var nativeObjectToString=objectProto.toString; +/** + * Converts `value` to a string using `Object.prototype.toString`. + * + * @private + * @param {*} value The value to convert. + * @returns {string} Returns the converted string. + */function objectToString(value){return nativeObjectToString.call(value)}module.exports=objectToString},{}],156:[function(require,module,exports){ +/** + * Creates a unary function that invokes `func` with its argument transformed. + * + * @private + * @param {Function} func The function to wrap. + * @param {Function} transform The argument transform. + * @returns {Function} Returns the new function. + */ +function overArg(func,transform){return function(arg){return func(transform(arg))}}module.exports=overArg},{}],157:[function(require,module,exports){var apply=require("./_apply"); +/* Built-in method references for those with the same name as other `lodash` methods. */var nativeMax=Math.max; +/** + * A specialized version of `baseRest` which transforms the rest array. + * + * @private + * @param {Function} func The function to apply a rest parameter to. + * @param {number} [start=func.length-1] The start position of the rest parameter. + * @param {Function} transform The rest array transform. + * @returns {Function} Returns the new function. + */function overRest(func,start,transform){start=nativeMax(start===undefined?func.length-1:start,0);return function(){var args=arguments,index=-1,length=nativeMax(args.length-start,0),array=Array(length);while(++index0){if(++count>=HOT_COUNT){return arguments[0]}}else{count=0}return func.apply(undefined,arguments)}}module.exports=shortOut},{}],164:[function(require,module,exports){var ListCache=require("./_ListCache"); +/** + * Removes all key-value entries from the stack. + * + * @private + * @name clear + * @memberOf Stack + */function stackClear(){this.__data__=new ListCache;this.size=0}module.exports=stackClear},{"./_ListCache":23}],165:[function(require,module,exports){ +/** + * Removes `key` and its value from the stack. + * + * @private + * @name delete + * @memberOf Stack + * @param {string} key The key of the value to remove. + * @returns {boolean} Returns `true` if the entry was removed, else `false`. + */ +function stackDelete(key){var data=this.__data__,result=data["delete"](key);this.size=data.size;return result}module.exports=stackDelete},{}],166:[function(require,module,exports){ +/** + * Gets the stack value for `key`. + * + * @private + * @name get + * @memberOf Stack + * @param {string} key The key of the value to get. + * @returns {*} Returns the entry value. + */ +function stackGet(key){return this.__data__.get(key)}module.exports=stackGet},{}],167:[function(require,module,exports){ +/** + * Checks if a stack value for `key` exists. + * + * @private + * @name has + * @memberOf Stack + * @param {string} key The key of the entry to check. + * @returns {boolean} Returns `true` if an entry for `key` exists, else `false`. + */ +function stackHas(key){return this.__data__.has(key)}module.exports=stackHas},{}],168:[function(require,module,exports){var ListCache=require("./_ListCache"),Map=require("./_Map"),MapCache=require("./_MapCache"); +/** Used as the size to enable large array optimizations. */var LARGE_ARRAY_SIZE=200; +/** + * Sets the stack `key` to `value`. + * + * @private + * @name set + * @memberOf Stack + * @param {string} key The key of the value to set. + * @param {*} value The value to set. + * @returns {Object} Returns the stack cache instance. + */function stackSet(key,value){var data=this.__data__;if(data instanceof ListCache){var pairs=data.__data__;if(!Map||pairs.length true + */function clone(value){return baseClone(value,CLONE_SYMBOLS_FLAG)}module.exports=clone},{"./_baseClone":49}],176:[function(require,module,exports){ +/** + * Creates a function that returns `value`. + * + * @static + * @memberOf _ + * @since 2.4.0 + * @category Util + * @param {*} value The value to return from the new function. + * @returns {Function} Returns the new constant function. + * @example + * + * var objects = _.times(2, _.constant({ 'a': 1 })); + * + * console.log(objects); + * // => [{ 'a': 1 }, { 'a': 1 }] + * + * console.log(objects[0] === objects[1]); + * // => true + */ +function constant(value){return function(){return value}}module.exports=constant},{}],177:[function(require,module,exports){module.exports=require("./forEach")},{"./forEach":180}],178:[function(require,module,exports){ +/** + * Performs a + * [`SameValueZero`](http://ecma-international.org/ecma-262/7.0/#sec-samevaluezero) + * comparison between two values to determine if they are equivalent. + * + * @static + * @memberOf _ + * @since 4.0.0 + * @category Lang + * @param {*} value The value to compare. + * @param {*} other The other value to compare. + * @returns {boolean} Returns `true` if the values are equivalent, else `false`. + * @example + * + * var object = { 'a': 1 }; + * var other = { 'a': 1 }; + * + * _.eq(object, object); + * // => true + * + * _.eq(object, other); + * // => false + * + * _.eq('a', 'a'); + * // => true + * + * _.eq('a', Object('a')); + * // => false + * + * _.eq(NaN, NaN); + * // => true + */ +function eq(value,other){return value===other||value!==value&&other!==other}module.exports=eq},{}],179:[function(require,module,exports){var arrayFilter=require("./_arrayFilter"),baseFilter=require("./_baseFilter"),baseIteratee=require("./_baseIteratee"),isArray=require("./isArray"); +/** + * Iterates over elements of `collection`, returning an array of all elements + * `predicate` returns truthy for. The predicate is invoked with three + * arguments: (value, index|key, collection). + * + * **Note:** Unlike `_.remove`, this method returns a new array. + * + * @static + * @memberOf _ + * @since 0.1.0 + * @category Collection + * @param {Array|Object} collection The collection to iterate over. + * @param {Function} [predicate=_.identity] The function invoked per iteration. + * @returns {Array} Returns the new filtered array. + * @see _.reject + * @example + * + * var users = [ + * { 'user': 'barney', 'age': 36, 'active': true }, + * { 'user': 'fred', 'age': 40, 'active': false } + * ]; + * + * _.filter(users, function(o) { return !o.active; }); + * // => objects for ['fred'] + * + * // The `_.matches` iteratee shorthand. + * _.filter(users, { 'age': 36, 'active': true }); + * // => objects for ['barney'] + * + * // The `_.matchesProperty` iteratee shorthand. + * _.filter(users, ['active', false]); + * // => objects for ['fred'] + * + * // The `_.property` iteratee shorthand. + * _.filter(users, 'active'); + * // => objects for ['barney'] + */function filter(collection,predicate){var func=isArray(collection)?arrayFilter:baseFilter;return func(collection,baseIteratee(predicate,3))}module.exports=filter},{"./_arrayFilter":35,"./_baseFilter":52,"./_baseIteratee":72,"./isArray":186}],180:[function(require,module,exports){var arrayEach=require("./_arrayEach"),baseEach=require("./_baseEach"),castFunction=require("./_castFunction"),isArray=require("./isArray"); +/** + * Iterates over elements of `collection` and invokes `iteratee` for each element. + * The iteratee is invoked with three arguments: (value, index|key, collection). + * Iteratee functions may exit iteration early by explicitly returning `false`. + * + * **Note:** As with other "Collections" methods, objects with a "length" + * property are iterated like arrays. To avoid this behavior use `_.forIn` + * or `_.forOwn` for object iteration. + * + * @static + * @memberOf _ + * @since 0.1.0 + * @alias each + * @category Collection + * @param {Array|Object} collection The collection to iterate over. + * @param {Function} [iteratee=_.identity] The function invoked per iteration. + * @returns {Array|Object} Returns `collection`. + * @see _.forEachRight + * @example + * + * _.forEach([1, 2], function(value) { + * console.log(value); + * }); + * // => Logs `1` then `2`. + * + * _.forEach({ 'a': 1, 'b': 2 }, function(value, key) { + * console.log(key); + * }); + * // => Logs 'a' then 'b' (iteration order is not guaranteed). + */function forEach(collection,iteratee){var func=isArray(collection)?arrayEach:baseEach;return func(collection,castFunction(iteratee))}module.exports=forEach},{"./_arrayEach":34,"./_baseEach":51,"./_castFunction":89,"./isArray":186}],181:[function(require,module,exports){var baseGet=require("./_baseGet"); +/** + * Gets the value at `path` of `object`. If the resolved value is + * `undefined`, the `defaultValue` is returned in its place. + * + * @static + * @memberOf _ + * @since 3.7.0 + * @category Object + * @param {Object} object The object to query. + * @param {Array|string} path The path of the property to get. + * @param {*} [defaultValue] The value returned for `undefined` resolved values. + * @returns {*} Returns the resolved value. + * @example + * + * var object = { 'a': [{ 'b': { 'c': 3 } }] }; + * + * _.get(object, 'a[0].b.c'); + * // => 3 + * + * _.get(object, ['a', '0', 'b', 'c']); + * // => 3 + * + * _.get(object, 'a.b.c', 'default'); + * // => 'default' + */function get(object,path,defaultValue){var result=object==null?undefined:baseGet(object,path);return result===undefined?defaultValue:result}module.exports=get},{"./_baseGet":57}],182:[function(require,module,exports){var baseHas=require("./_baseHas"),hasPath=require("./_hasPath"); +/** + * Checks if `path` is a direct property of `object`. + * + * @static + * @since 0.1.0 + * @memberOf _ + * @category Object + * @param {Object} object The object to query. + * @param {Array|string} path The path to check. + * @returns {boolean} Returns `true` if `path` exists, else `false`. + * @example + * + * var object = { 'a': { 'b': 2 } }; + * var other = _.create({ 'a': _.create({ 'b': 2 }) }); + * + * _.has(object, 'a'); + * // => true + * + * _.has(object, 'a.b'); + * // => true + * + * _.has(object, ['a', 'b']); + * // => true + * + * _.has(other, 'a'); + * // => false + */function has(object,path){return object!=null&&hasPath(object,path,baseHas)}module.exports=has},{"./_baseHas":60,"./_hasPath":121}],183:[function(require,module,exports){var baseHasIn=require("./_baseHasIn"),hasPath=require("./_hasPath"); +/** + * Checks if `path` is a direct or inherited property of `object`. + * + * @static + * @memberOf _ + * @since 4.0.0 + * @category Object + * @param {Object} object The object to query. + * @param {Array|string} path The path to check. + * @returns {boolean} Returns `true` if `path` exists, else `false`. + * @example + * + * var object = _.create({ 'a': _.create({ 'b': 2 }) }); + * + * _.hasIn(object, 'a'); + * // => true + * + * _.hasIn(object, 'a.b'); + * // => true + * + * _.hasIn(object, ['a', 'b']); + * // => true + * + * _.hasIn(object, 'b'); + * // => false + */function hasIn(object,path){return object!=null&&hasPath(object,path,baseHasIn)}module.exports=hasIn},{"./_baseHasIn":61,"./_hasPath":121}],184:[function(require,module,exports){ +/** + * This method returns the first argument it receives. + * + * @static + * @since 0.1.0 + * @memberOf _ + * @category Util + * @param {*} value Any value. + * @returns {*} Returns `value`. + * @example + * + * var object = { 'a': 1 }; + * + * console.log(_.identity(object) === object); + * // => true + */ +function identity(value){return value}module.exports=identity},{}],185:[function(require,module,exports){var baseIsArguments=require("./_baseIsArguments"),isObjectLike=require("./isObjectLike"); +/** Used for built-in method references. */var objectProto=Object.prototype; +/** Used to check objects for own properties. */var hasOwnProperty=objectProto.hasOwnProperty; +/** Built-in value references. */var propertyIsEnumerable=objectProto.propertyIsEnumerable; +/** + * Checks if `value` is likely an `arguments` object. + * + * @static + * @memberOf _ + * @since 0.1.0 + * @category Lang + * @param {*} value The value to check. + * @returns {boolean} Returns `true` if `value` is an `arguments` object, + * else `false`. + * @example + * + * _.isArguments(function() { return arguments; }()); + * // => true + * + * _.isArguments([1, 2, 3]); + * // => false + */var isArguments=baseIsArguments(function(){return arguments}())?baseIsArguments:function(value){return isObjectLike(value)&&hasOwnProperty.call(value,"callee")&&!propertyIsEnumerable.call(value,"callee")};module.exports=isArguments},{"./_baseIsArguments":63,"./isObjectLike":195}],186:[function(require,module,exports){ +/** + * Checks if `value` is classified as an `Array` object. + * + * @static + * @memberOf _ + * @since 0.1.0 + * @category Lang + * @param {*} value The value to check. + * @returns {boolean} Returns `true` if `value` is an array, else `false`. + * @example + * + * _.isArray([1, 2, 3]); + * // => true + * + * _.isArray(document.body.children); + * // => false + * + * _.isArray('abc'); + * // => false + * + * _.isArray(_.noop); + * // => false + */ +var isArray=Array.isArray;module.exports=isArray},{}],187:[function(require,module,exports){var isFunction=require("./isFunction"),isLength=require("./isLength"); +/** + * Checks if `value` is array-like. A value is considered array-like if it's + * not a function and has a `value.length` that's an integer greater than or + * equal to `0` and less than or equal to `Number.MAX_SAFE_INTEGER`. + * + * @static + * @memberOf _ + * @since 4.0.0 + * @category Lang + * @param {*} value The value to check. + * @returns {boolean} Returns `true` if `value` is array-like, else `false`. + * @example + * + * _.isArrayLike([1, 2, 3]); + * // => true + * + * _.isArrayLike(document.body.children); + * // => true + * + * _.isArrayLike('abc'); + * // => true + * + * _.isArrayLike(_.noop); + * // => false + */function isArrayLike(value){return value!=null&&isLength(value.length)&&!isFunction(value)}module.exports=isArrayLike},{"./isFunction":191,"./isLength":192}],188:[function(require,module,exports){var isArrayLike=require("./isArrayLike"),isObjectLike=require("./isObjectLike"); +/** + * This method is like `_.isArrayLike` except that it also checks if `value` + * is an object. + * + * @static + * @memberOf _ + * @since 4.0.0 + * @category Lang + * @param {*} value The value to check. + * @returns {boolean} Returns `true` if `value` is an array-like object, + * else `false`. + * @example + * + * _.isArrayLikeObject([1, 2, 3]); + * // => true + * + * _.isArrayLikeObject(document.body.children); + * // => true + * + * _.isArrayLikeObject('abc'); + * // => false + * + * _.isArrayLikeObject(_.noop); + * // => false + */function isArrayLikeObject(value){return isObjectLike(value)&&isArrayLike(value)}module.exports=isArrayLikeObject},{"./isArrayLike":187,"./isObjectLike":195}],189:[function(require,module,exports){var root=require("./_root"),stubFalse=require("./stubFalse"); +/** Detect free variable `exports`. */var freeExports=typeof exports=="object"&&exports&&!exports.nodeType&&exports; +/** Detect free variable `module`. */var freeModule=freeExports&&typeof module=="object"&&module&&!module.nodeType&&module; +/** Detect the popular CommonJS extension `module.exports`. */var moduleExports=freeModule&&freeModule.exports===freeExports; +/** Built-in value references. */var Buffer=moduleExports?root.Buffer:undefined; +/* Built-in method references for those with the same name as other `lodash` methods. */var nativeIsBuffer=Buffer?Buffer.isBuffer:undefined; +/** + * Checks if `value` is a buffer. + * + * @static + * @memberOf _ + * @since 4.3.0 + * @category Lang + * @param {*} value The value to check. + * @returns {boolean} Returns `true` if `value` is a buffer, else `false`. + * @example + * + * _.isBuffer(new Buffer(2)); + * // => true + * + * _.isBuffer(new Uint8Array(2)); + * // => false + */var isBuffer=nativeIsBuffer||stubFalse;module.exports=isBuffer},{"./_root":158,"./stubFalse":210}],190:[function(require,module,exports){var baseKeys=require("./_baseKeys"),getTag=require("./_getTag"),isArguments=require("./isArguments"),isArray=require("./isArray"),isArrayLike=require("./isArrayLike"),isBuffer=require("./isBuffer"),isPrototype=require("./_isPrototype"),isTypedArray=require("./isTypedArray"); +/** `Object#toString` result references. */var mapTag="[object Map]",setTag="[object Set]"; +/** Used for built-in method references. */var objectProto=Object.prototype; +/** Used to check objects for own properties. */var hasOwnProperty=objectProto.hasOwnProperty; +/** + * Checks if `value` is an empty object, collection, map, or set. + * + * Objects are considered empty if they have no own enumerable string keyed + * properties. + * + * Array-like values such as `arguments` objects, arrays, buffers, strings, or + * jQuery-like collections are considered empty if they have a `length` of `0`. + * Similarly, maps and sets are considered empty if they have a `size` of `0`. + * + * @static + * @memberOf _ + * @since 0.1.0 + * @category Lang + * @param {*} value The value to check. + * @returns {boolean} Returns `true` if `value` is empty, else `false`. + * @example + * + * _.isEmpty(null); + * // => true + * + * _.isEmpty(true); + * // => true + * + * _.isEmpty(1); + * // => true + * + * _.isEmpty([1, 2, 3]); + * // => false + * + * _.isEmpty({ 'a': 1 }); + * // => false + */function isEmpty(value){if(value==null){return true}if(isArrayLike(value)&&(isArray(value)||typeof value=="string"||typeof value.splice=="function"||isBuffer(value)||isTypedArray(value)||isArguments(value))){return!value.length}var tag=getTag(value);if(tag==mapTag||tag==setTag){return!value.size}if(isPrototype(value)){return!baseKeys(value).length}for(var key in value){if(hasOwnProperty.call(value,key)){return false}}return true}module.exports=isEmpty},{"./_baseKeys":73,"./_getTag":119,"./_isPrototype":136,"./isArguments":185,"./isArray":186,"./isArrayLike":187,"./isBuffer":189,"./isTypedArray":199}],191:[function(require,module,exports){var baseGetTag=require("./_baseGetTag"),isObject=require("./isObject"); +/** `Object#toString` result references. */var asyncTag="[object AsyncFunction]",funcTag="[object Function]",genTag="[object GeneratorFunction]",proxyTag="[object Proxy]"; +/** + * Checks if `value` is classified as a `Function` object. + * + * @static + * @memberOf _ + * @since 0.1.0 + * @category Lang + * @param {*} value The value to check. + * @returns {boolean} Returns `true` if `value` is a function, else `false`. + * @example + * + * _.isFunction(_); + * // => true + * + * _.isFunction(/abc/); + * // => false + */function isFunction(value){if(!isObject(value)){return false} +// The use of `Object#toString` avoids issues with the `typeof` operator +// in Safari 9 which returns 'object' for typed arrays and other constructors. +var tag=baseGetTag(value);return tag==funcTag||tag==genTag||tag==asyncTag||tag==proxyTag}module.exports=isFunction},{"./_baseGetTag":59,"./isObject":194}],192:[function(require,module,exports){ +/** Used as references for various `Number` constants. */ +var MAX_SAFE_INTEGER=9007199254740991; +/** + * Checks if `value` is a valid array-like length. + * + * **Note:** This method is loosely based on + * [`ToLength`](http://ecma-international.org/ecma-262/7.0/#sec-tolength). + * + * @static + * @memberOf _ + * @since 4.0.0 + * @category Lang + * @param {*} value The value to check. + * @returns {boolean} Returns `true` if `value` is a valid length, else `false`. + * @example + * + * _.isLength(3); + * // => true + * + * _.isLength(Number.MIN_VALUE); + * // => false + * + * _.isLength(Infinity); + * // => false + * + * _.isLength('3'); + * // => false + */function isLength(value){return typeof value=="number"&&value>-1&&value%1==0&&value<=MAX_SAFE_INTEGER}module.exports=isLength},{}],193:[function(require,module,exports){var baseIsMap=require("./_baseIsMap"),baseUnary=require("./_baseUnary"),nodeUtil=require("./_nodeUtil"); +/* Node.js helper references. */var nodeIsMap=nodeUtil&&nodeUtil.isMap; +/** + * Checks if `value` is classified as a `Map` object. + * + * @static + * @memberOf _ + * @since 4.3.0 + * @category Lang + * @param {*} value The value to check. + * @returns {boolean} Returns `true` if `value` is a map, else `false`. + * @example + * + * _.isMap(new Map); + * // => true + * + * _.isMap(new WeakMap); + * // => false + */var isMap=nodeIsMap?baseUnary(nodeIsMap):baseIsMap;module.exports=isMap},{"./_baseIsMap":66,"./_baseUnary":85,"./_nodeUtil":154}],194:[function(require,module,exports){ +/** + * Checks if `value` is the + * [language type](http://www.ecma-international.org/ecma-262/7.0/#sec-ecmascript-language-types) + * of `Object`. (e.g. arrays, functions, objects, regexes, `new Number(0)`, and `new String('')`) + * + * @static + * @memberOf _ + * @since 0.1.0 + * @category Lang + * @param {*} value The value to check. + * @returns {boolean} Returns `true` if `value` is an object, else `false`. + * @example + * + * _.isObject({}); + * // => true + * + * _.isObject([1, 2, 3]); + * // => true + * + * _.isObject(_.noop); + * // => true + * + * _.isObject(null); + * // => false + */ +function isObject(value){var type=typeof value;return value!=null&&(type=="object"||type=="function")}module.exports=isObject},{}],195:[function(require,module,exports){ +/** + * Checks if `value` is object-like. A value is object-like if it's not `null` + * and has a `typeof` result of "object". + * + * @static + * @memberOf _ + * @since 4.0.0 + * @category Lang + * @param {*} value The value to check. + * @returns {boolean} Returns `true` if `value` is object-like, else `false`. + * @example + * + * _.isObjectLike({}); + * // => true + * + * _.isObjectLike([1, 2, 3]); + * // => true + * + * _.isObjectLike(_.noop); + * // => false + * + * _.isObjectLike(null); + * // => false + */ +function isObjectLike(value){return value!=null&&typeof value=="object"}module.exports=isObjectLike},{}],196:[function(require,module,exports){var baseIsSet=require("./_baseIsSet"),baseUnary=require("./_baseUnary"),nodeUtil=require("./_nodeUtil"); +/* Node.js helper references. */var nodeIsSet=nodeUtil&&nodeUtil.isSet; +/** + * Checks if `value` is classified as a `Set` object. + * + * @static + * @memberOf _ + * @since 4.3.0 + * @category Lang + * @param {*} value The value to check. + * @returns {boolean} Returns `true` if `value` is a set, else `false`. + * @example + * + * _.isSet(new Set); + * // => true + * + * _.isSet(new WeakSet); + * // => false + */var isSet=nodeIsSet?baseUnary(nodeIsSet):baseIsSet;module.exports=isSet},{"./_baseIsSet":70,"./_baseUnary":85,"./_nodeUtil":154}],197:[function(require,module,exports){var baseGetTag=require("./_baseGetTag"),isArray=require("./isArray"),isObjectLike=require("./isObjectLike"); +/** `Object#toString` result references. */var stringTag="[object String]"; +/** + * Checks if `value` is classified as a `String` primitive or object. + * + * @static + * @since 0.1.0 + * @memberOf _ + * @category Lang + * @param {*} value The value to check. + * @returns {boolean} Returns `true` if `value` is a string, else `false`. + * @example + * + * _.isString('abc'); + * // => true + * + * _.isString(1); + * // => false + */function isString(value){return typeof value=="string"||!isArray(value)&&isObjectLike(value)&&baseGetTag(value)==stringTag}module.exports=isString},{"./_baseGetTag":59,"./isArray":186,"./isObjectLike":195}],198:[function(require,module,exports){var baseGetTag=require("./_baseGetTag"),isObjectLike=require("./isObjectLike"); +/** `Object#toString` result references. */var symbolTag="[object Symbol]"; +/** + * Checks if `value` is classified as a `Symbol` primitive or object. + * + * @static + * @memberOf _ + * @since 4.0.0 + * @category Lang + * @param {*} value The value to check. + * @returns {boolean} Returns `true` if `value` is a symbol, else `false`. + * @example + * + * _.isSymbol(Symbol.iterator); + * // => true + * + * _.isSymbol('abc'); + * // => false + */function isSymbol(value){return typeof value=="symbol"||isObjectLike(value)&&baseGetTag(value)==symbolTag}module.exports=isSymbol},{"./_baseGetTag":59,"./isObjectLike":195}],199:[function(require,module,exports){var baseIsTypedArray=require("./_baseIsTypedArray"),baseUnary=require("./_baseUnary"),nodeUtil=require("./_nodeUtil"); +/* Node.js helper references. */var nodeIsTypedArray=nodeUtil&&nodeUtil.isTypedArray; +/** + * Checks if `value` is classified as a typed array. + * + * @static + * @memberOf _ + * @since 3.0.0 + * @category Lang + * @param {*} value The value to check. + * @returns {boolean} Returns `true` if `value` is a typed array, else `false`. + * @example + * + * _.isTypedArray(new Uint8Array); + * // => true + * + * _.isTypedArray([]); + * // => false + */var isTypedArray=nodeIsTypedArray?baseUnary(nodeIsTypedArray):baseIsTypedArray;module.exports=isTypedArray},{"./_baseIsTypedArray":71,"./_baseUnary":85,"./_nodeUtil":154}],200:[function(require,module,exports){ +/** + * Checks if `value` is `undefined`. + * + * @static + * @since 0.1.0 + * @memberOf _ + * @category Lang + * @param {*} value The value to check. + * @returns {boolean} Returns `true` if `value` is `undefined`, else `false`. + * @example + * + * _.isUndefined(void 0); + * // => true + * + * _.isUndefined(null); + * // => false + */ +function isUndefined(value){return value===undefined}module.exports=isUndefined},{}],201:[function(require,module,exports){var arrayLikeKeys=require("./_arrayLikeKeys"),baseKeys=require("./_baseKeys"),isArrayLike=require("./isArrayLike"); +/** + * Creates an array of the own enumerable property names of `object`. + * + * **Note:** Non-object values are coerced to objects. See the + * [ES spec](http://ecma-international.org/ecma-262/7.0/#sec-object.keys) + * for more details. + * + * @static + * @since 0.1.0 + * @memberOf _ + * @category Object + * @param {Object} object The object to query. + * @returns {Array} Returns the array of property names. + * @example + * + * function Foo() { + * this.a = 1; + * this.b = 2; + * } + * + * Foo.prototype.c = 3; + * + * _.keys(new Foo); + * // => ['a', 'b'] (iteration order is not guaranteed) + * + * _.keys('hi'); + * // => ['0', '1'] + */function keys(object){return isArrayLike(object)?arrayLikeKeys(object):baseKeys(object)}module.exports=keys},{"./_arrayLikeKeys":38,"./_baseKeys":73,"./isArrayLike":187}],202:[function(require,module,exports){var arrayLikeKeys=require("./_arrayLikeKeys"),baseKeysIn=require("./_baseKeysIn"),isArrayLike=require("./isArrayLike"); +/** + * Creates an array of the own and inherited enumerable property names of `object`. + * + * **Note:** Non-object values are coerced to objects. + * + * @static + * @memberOf _ + * @since 3.0.0 + * @category Object + * @param {Object} object The object to query. + * @returns {Array} Returns the array of property names. + * @example + * + * function Foo() { + * this.a = 1; + * this.b = 2; + * } + * + * Foo.prototype.c = 3; + * + * _.keysIn(new Foo); + * // => ['a', 'b', 'c'] (iteration order is not guaranteed) + */function keysIn(object){return isArrayLike(object)?arrayLikeKeys(object,true):baseKeysIn(object)}module.exports=keysIn},{"./_arrayLikeKeys":38,"./_baseKeysIn":74,"./isArrayLike":187}],203:[function(require,module,exports){var arrayMap=require("./_arrayMap"),baseIteratee=require("./_baseIteratee"),baseMap=require("./_baseMap"),isArray=require("./isArray"); +/** + * Creates an array of values by running each element in `collection` thru + * `iteratee`. The iteratee is invoked with three arguments: + * (value, index|key, collection). + * + * Many lodash methods are guarded to work as iteratees for methods like + * `_.every`, `_.filter`, `_.map`, `_.mapValues`, `_.reject`, and `_.some`. + * + * The guarded methods are: + * `ary`, `chunk`, `curry`, `curryRight`, `drop`, `dropRight`, `every`, + * `fill`, `invert`, `parseInt`, `random`, `range`, `rangeRight`, `repeat`, + * `sampleSize`, `slice`, `some`, `sortBy`, `split`, `take`, `takeRight`, + * `template`, `trim`, `trimEnd`, `trimStart`, and `words` + * + * @static + * @memberOf _ + * @since 0.1.0 + * @category Collection + * @param {Array|Object} collection The collection to iterate over. + * @param {Function} [iteratee=_.identity] The function invoked per iteration. + * @returns {Array} Returns the new mapped array. + * @example + * + * function square(n) { + * return n * n; + * } + * + * _.map([4, 8], square); + * // => [16, 64] + * + * _.map({ 'a': 4, 'b': 8 }, square); + * // => [16, 64] (iteration order is not guaranteed) + * + * var users = [ + * { 'user': 'barney' }, + * { 'user': 'fred' } + * ]; + * + * // The `_.property` iteratee shorthand. + * _.map(users, 'user'); + * // => ['barney', 'fred'] + */function map(collection,iteratee){var func=isArray(collection)?arrayMap:baseMap;return func(collection,baseIteratee(iteratee,3))}module.exports=map},{"./_arrayMap":39,"./_baseIteratee":72,"./_baseMap":75,"./isArray":186}],204:[function(require,module,exports){var MapCache=require("./_MapCache"); +/** Error message constants. */var FUNC_ERROR_TEXT="Expected a function"; +/** + * Creates a function that memoizes the result of `func`. If `resolver` is + * provided, it determines the cache key for storing the result based on the + * arguments provided to the memoized function. By default, the first argument + * provided to the memoized function is used as the map cache key. The `func` + * is invoked with the `this` binding of the memoized function. + * + * **Note:** The cache is exposed as the `cache` property on the memoized + * function. Its creation may be customized by replacing the `_.memoize.Cache` + * constructor with one whose instances implement the + * [`Map`](http://ecma-international.org/ecma-262/7.0/#sec-properties-of-the-map-prototype-object) + * method interface of `clear`, `delete`, `get`, `has`, and `set`. + * + * @static + * @memberOf _ + * @since 0.1.0 + * @category Function + * @param {Function} func The function to have its output memoized. + * @param {Function} [resolver] The function to resolve the cache key. + * @returns {Function} Returns the new memoized function. + * @example + * + * var object = { 'a': 1, 'b': 2 }; + * var other = { 'c': 3, 'd': 4 }; + * + * var values = _.memoize(_.values); + * values(object); + * // => [1, 2] + * + * values(other); + * // => [3, 4] + * + * object.a = 2; + * values(object); + * // => [1, 2] + * + * // Modify the result cache. + * values.cache.set(object, ['a', 'b']); + * values(object); + * // => ['a', 'b'] + * + * // Replace `_.memoize.Cache`. + * _.memoize.Cache = WeakMap; + */function memoize(func,resolver){if(typeof func!="function"||resolver!=null&&typeof resolver!="function"){throw new TypeError(FUNC_ERROR_TEXT)}var memoized=function(){var args=arguments,key=resolver?resolver.apply(this,args):args[0],cache=memoized.cache;if(cache.has(key)){return cache.get(key)}var result=func.apply(this,args);memoized.cache=cache.set(key,result)||cache;return result};memoized.cache=new(memoize.Cache||MapCache);return memoized} +// Expose `MapCache`. +memoize.Cache=MapCache;module.exports=memoize},{"./_MapCache":25}],205:[function(require,module,exports){ +/** + * This method returns `undefined`. + * + * @static + * @memberOf _ + * @since 2.3.0 + * @category Util + * @example + * + * _.times(2, _.noop); + * // => [undefined, undefined] + */ +function noop(){ +// No operation performed. +}module.exports=noop},{}],206:[function(require,module,exports){var baseProperty=require("./_baseProperty"),basePropertyDeep=require("./_basePropertyDeep"),isKey=require("./_isKey"),toKey=require("./_toKey"); +/** + * Creates a function that returns the value at `path` of a given object. + * + * @static + * @memberOf _ + * @since 2.4.0 + * @category Util + * @param {Array|string} path The path of the property to get. + * @returns {Function} Returns the new accessor function. + * @example + * + * var objects = [ + * { 'a': { 'b': 2 } }, + * { 'a': { 'b': 1 } } + * ]; + * + * _.map(objects, _.property('a.b')); + * // => [2, 1] + * + * _.map(_.sortBy(objects, _.property(['a', 'b'])), 'a.b'); + * // => [1, 2] + */function property(path){return isKey(path)?baseProperty(toKey(path)):basePropertyDeep(path)}module.exports=property},{"./_baseProperty":78,"./_basePropertyDeep":79,"./_isKey":133,"./_toKey":172}],207:[function(require,module,exports){var arrayReduce=require("./_arrayReduce"),baseEach=require("./_baseEach"),baseIteratee=require("./_baseIteratee"),baseReduce=require("./_baseReduce"),isArray=require("./isArray"); +/** + * Reduces `collection` to a value which is the accumulated result of running + * each element in `collection` thru `iteratee`, where each successive + * invocation is supplied the return value of the previous. If `accumulator` + * is not given, the first element of `collection` is used as the initial + * value. The iteratee is invoked with four arguments: + * (accumulator, value, index|key, collection). + * + * Many lodash methods are guarded to work as iteratees for methods like + * `_.reduce`, `_.reduceRight`, and `_.transform`. + * + * The guarded methods are: + * `assign`, `defaults`, `defaultsDeep`, `includes`, `merge`, `orderBy`, + * and `sortBy` + * + * @static + * @memberOf _ + * @since 0.1.0 + * @category Collection + * @param {Array|Object} collection The collection to iterate over. + * @param {Function} [iteratee=_.identity] The function invoked per iteration. + * @param {*} [accumulator] The initial value. + * @returns {*} Returns the accumulated value. + * @see _.reduceRight + * @example + * + * _.reduce([1, 2], function(sum, n) { + * return sum + n; + * }, 0); + * // => 3 + * + * _.reduce({ 'a': 1, 'b': 2, 'c': 1 }, function(result, value, key) { + * (result[value] || (result[value] = [])).push(key); + * return result; + * }, {}); + * // => { '1': ['a', 'c'], '2': ['b'] } (iteration order is not guaranteed) + */function reduce(collection,iteratee,accumulator){var func=isArray(collection)?arrayReduce:baseReduce,initAccum=arguments.length<3;return func(collection,baseIteratee(iteratee,4),accumulator,initAccum,baseEach)}module.exports=reduce},{"./_arrayReduce":41,"./_baseEach":51,"./_baseIteratee":72,"./_baseReduce":80,"./isArray":186}],208:[function(require,module,exports){var baseKeys=require("./_baseKeys"),getTag=require("./_getTag"),isArrayLike=require("./isArrayLike"),isString=require("./isString"),stringSize=require("./_stringSize"); +/** `Object#toString` result references. */var mapTag="[object Map]",setTag="[object Set]"; +/** + * Gets the size of `collection` by returning its length for array-like + * values or the number of own enumerable string keyed properties for objects. + * + * @static + * @memberOf _ + * @since 0.1.0 + * @category Collection + * @param {Array|Object|string} collection The collection to inspect. + * @returns {number} Returns the collection size. + * @example + * + * _.size([1, 2, 3]); + * // => 3 + * + * _.size({ 'a': 1, 'b': 2 }); + * // => 2 + * + * _.size('pebbles'); + * // => 7 + */function size(collection){if(collection==null){return 0}if(isArrayLike(collection)){return isString(collection)?stringSize(collection):collection.length}var tag=getTag(collection);if(tag==mapTag||tag==setTag){return collection.size}return baseKeys(collection).length}module.exports=size},{"./_baseKeys":73,"./_getTag":119,"./_stringSize":170,"./isArrayLike":187,"./isString":197}],209:[function(require,module,exports){ +/** + * This method returns a new empty array. + * + * @static + * @memberOf _ + * @since 4.13.0 + * @category Util + * @returns {Array} Returns the new empty array. + * @example + * + * var arrays = _.times(2, _.stubArray); + * + * console.log(arrays); + * // => [[], []] + * + * console.log(arrays[0] === arrays[1]); + * // => false + */ +function stubArray(){return[]}module.exports=stubArray},{}],210:[function(require,module,exports){ +/** + * This method returns `false`. + * + * @static + * @memberOf _ + * @since 4.13.0 + * @category Util + * @returns {boolean} Returns `false`. + * @example + * + * _.times(2, _.stubFalse); + * // => [false, false] + */ +function stubFalse(){return false}module.exports=stubFalse},{}],211:[function(require,module,exports){var baseToString=require("./_baseToString"); +/** + * Converts `value` to a string. An empty string is returned for `null` + * and `undefined` values. The sign of `-0` is preserved. + * + * @static + * @memberOf _ + * @since 4.0.0 + * @category Lang + * @param {*} value The value to convert. + * @returns {string} Returns the converted string. + * @example + * + * _.toString(null); + * // => '' + * + * _.toString(-0); + * // => '-0' + * + * _.toString([1, 2, 3]); + * // => '1,2,3' + */function toString(value){return value==null?"":baseToString(value)}module.exports=toString},{"./_baseToString":84}],212:[function(require,module,exports){var arrayEach=require("./_arrayEach"),baseCreate=require("./_baseCreate"),baseForOwn=require("./_baseForOwn"),baseIteratee=require("./_baseIteratee"),getPrototype=require("./_getPrototype"),isArray=require("./isArray"),isBuffer=require("./isBuffer"),isFunction=require("./isFunction"),isObject=require("./isObject"),isTypedArray=require("./isTypedArray"); +/** + * An alternative to `_.reduce`; this method transforms `object` to a new + * `accumulator` object which is the result of running each of its own + * enumerable string keyed properties thru `iteratee`, with each invocation + * potentially mutating the `accumulator` object. If `accumulator` is not + * provided, a new object with the same `[[Prototype]]` will be used. The + * iteratee is invoked with four arguments: (accumulator, value, key, object). + * Iteratee functions may exit iteration early by explicitly returning `false`. + * + * @static + * @memberOf _ + * @since 1.3.0 + * @category Object + * @param {Object} object The object to iterate over. + * @param {Function} [iteratee=_.identity] The function invoked per iteration. + * @param {*} [accumulator] The custom accumulator value. + * @returns {*} Returns the accumulated value. + * @example + * + * _.transform([2, 3, 4], function(result, n) { + * result.push(n *= n); + * return n % 2 == 0; + * }, []); + * // => [4, 9] + * + * _.transform({ 'a': 1, 'b': 2, 'c': 1 }, function(result, value, key) { + * (result[value] || (result[value] = [])).push(key); + * }, {}); + * // => { '1': ['a', 'c'], '2': ['b'] } + */function transform(object,iteratee,accumulator){var isArr=isArray(object),isArrLike=isArr||isBuffer(object)||isTypedArray(object);iteratee=baseIteratee(iteratee,4);if(accumulator==null){var Ctor=object&&object.constructor;if(isArrLike){accumulator=isArr?new Ctor:[]}else if(isObject(object)){accumulator=isFunction(Ctor)?baseCreate(getPrototype(object)):{}}else{accumulator={}}}(isArrLike?arrayEach:baseForOwn)(object,function(value,index,object){return iteratee(accumulator,value,index,object)});return accumulator}module.exports=transform},{"./_arrayEach":34,"./_baseCreate":50,"./_baseForOwn":56,"./_baseIteratee":72,"./_getPrototype":115,"./isArray":186,"./isBuffer":189,"./isFunction":191,"./isObject":194,"./isTypedArray":199}],213:[function(require,module,exports){var baseFlatten=require("./_baseFlatten"),baseRest=require("./_baseRest"),baseUniq=require("./_baseUniq"),isArrayLikeObject=require("./isArrayLikeObject"); +/** + * Creates an array of unique values, in order, from all given arrays using + * [`SameValueZero`](http://ecma-international.org/ecma-262/7.0/#sec-samevaluezero) + * for equality comparisons. + * + * @static + * @memberOf _ + * @since 0.1.0 + * @category Array + * @param {...Array} [arrays] The arrays to inspect. + * @returns {Array} Returns the new array of combined values. + * @example + * + * _.union([2], [1, 2]); + * // => [2, 1] + */var union=baseRest(function(arrays){return baseUniq(baseFlatten(arrays,1,isArrayLikeObject,true))});module.exports=union},{"./_baseFlatten":54,"./_baseRest":81,"./_baseUniq":86,"./isArrayLikeObject":188}],214:[function(require,module,exports){var baseValues=require("./_baseValues"),keys=require("./keys"); +/** + * Creates an array of the own enumerable string keyed property values of `object`. + * + * **Note:** Non-object values are coerced to objects. + * + * @static + * @since 0.1.0 + * @memberOf _ + * @category Object + * @param {Object} object The object to query. + * @returns {Array} Returns the array of property values. + * @example + * + * function Foo() { + * this.a = 1; + * this.b = 2; + * } + * + * Foo.prototype.c = 3; + * + * _.values(new Foo); + * // => [1, 2] (iteration order is not guaranteed) + * + * _.values('hi'); + * // => ['h', 'i'] + */function values(object){return object==null?[]:baseValues(object,keys(object))}module.exports=values},{"./_baseValues":87,"./keys":201}]},{},[1])(1)}); diff --git a/htdocs/js/external/vis-network.min.js b/htdocs/js/external/vis-network.min.js new file mode 100644 index 0000000..aaacbb9 --- /dev/null +++ b/htdocs/js/external/vis-network.min.js @@ -0,0 +1,52 @@ +/** + * vis-network + * https://visjs.github.io/vis-network/ + * + * A dynamic, browser-based visualization library. + * + * @version 8.5.4 + * @date 2020-11-23T19:52:22.114Z + * + * @copyright (c) 2011-2017 Almende B.V, http://almende.com + * @copyright (c) 2017-2019 visjs contributors, https://github.com/visjs + * + * @license + * vis.js is dual licensed under both + * + * 1. The Apache 2.0 License + * http://www.apache.org/licenses/LICENSE-2.0 + * + * and + * + * 2. The MIT License + * http://opensource.org/licenses/MIT + * + * vis.js may be distributed under either license. + */ +!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?e(exports):"function"==typeof define&&define.amd?define(["exports"],e):e((t="undefined"!=typeof globalThis?globalThis:t||self).vis=t.vis||{})}(this,(function(t){"use strict";var e="undefined"!=typeof globalThis?globalThis:"undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof self?self:{};function i(t,e){return t(e={exports:{}},e.exports),e.exports}var n=function(t){return t&&t.Math==Math&&t},o=n("object"==typeof globalThis&&globalThis)||n("object"==typeof window&&window)||n("object"==typeof self&&self)||n("object"==typeof e&&e)||function(){return this}()||Function("return this")(),r=function(t){try{return!!t()}catch(t){return!0}},s=!r((function(){return 7!=Object.defineProperty({},1,{get:function(){return 7}})[1]})),a={}.propertyIsEnumerable,h=Object.getOwnPropertyDescriptor,l={f:h&&!a.call({1:2},1)?function(t){var e=h(this,t);return!!e&&e.enumerable}:a},d=function(t,e){return{enumerable:!(1&t),configurable:!(2&t),writable:!(4&t),value:e}},c={}.toString,u=function(t){return c.call(t).slice(8,-1)},f="".split,p=r((function(){return!Object("z").propertyIsEnumerable(0)}))?function(t){return"String"==u(t)?f.call(t,""):Object(t)}:Object,v=function(t){if(null==t)throw TypeError("Can't call method on "+t);return t},g=function(t){return p(v(t))},y=function(t){return"object"==typeof t?null!==t:"function"==typeof t},m=function(t,e){if(!y(t))return t;var i,n;if(e&&"function"==typeof(i=t.toString)&&!y(n=i.call(t)))return n;if("function"==typeof(i=t.valueOf)&&!y(n=i.call(t)))return n;if(!e&&"function"==typeof(i=t.toString)&&!y(n=i.call(t)))return n;throw TypeError("Can't convert object to primitive value")},b={}.hasOwnProperty,w=function(t,e){return b.call(t,e)},k=o.document,_=y(k)&&y(k.createElement),x=function(t){return _?k.createElement(t):{}},E=!s&&!r((function(){return 7!=Object.defineProperty(x("div"),"a",{get:function(){return 7}}).a})),O=Object.getOwnPropertyDescriptor,S={f:s?O:function(t,e){if(t=g(t),e=m(e,!0),E)try{return O(t,e)}catch(t){}if(w(t,e))return d(!l.f.call(t,e),t[e])}},C=/#|\.prototype\./,T=function(t,e){var i=M[D(t)];return i==I||i!=P&&("function"==typeof e?r(e):!!e)},D=T.normalize=function(t){return String(t).replace(C,".").toLowerCase()},M=T.data={},P=T.NATIVE="N",I=T.POLYFILL="P",B=T,z={},N=function(t){if("function"!=typeof t)throw TypeError(String(t)+" is not a function");return t},A=function(t,e,i){if(N(t),void 0===e)return t;switch(i){case 0:return function(){return t.call(e)};case 1:return function(i){return t.call(e,i)};case 2:return function(i,n){return t.call(e,i,n)};case 3:return function(i,n,o){return t.call(e,i,n,o)}}return function(){return t.apply(e,arguments)}},F=function(t){if(!y(t))throw TypeError(String(t)+" is not an object");return t},j=Object.defineProperty,R={f:s?j:function(t,e,i){if(F(t),e=m(e,!0),F(i),E)try{return j(t,e,i)}catch(t){}if("get"in i||"set"in i)throw TypeError("Accessors not supported");return"value"in i&&(t[e]=i.value),t}},L=s?function(t,e,i){return R.f(t,e,d(1,i))}:function(t,e,i){return t[e]=i,t},H=S.f,W=function(t){var e=function(e,i,n){if(this instanceof t){switch(arguments.length){case 0:return new t;case 1:return new t(e);case 2:return new t(e,i)}return new t(e,i,n)}return t.apply(this,arguments)};return e.prototype=t.prototype,e},q=function(t,e){var i,n,r,s,a,h,l,d,c=t.target,u=t.global,f=t.stat,p=t.proto,v=u?o:f?o[c]:(o[c]||{}).prototype,g=u?z:z[c]||(z[c]={}),y=g.prototype;for(r in e)i=!B(u?r:c+(f?".":"#")+r,t.forced)&&v&&w(v,r),a=g[r],i&&(h=t.noTargetGet?(d=H(v,r))&&d.value:v[r]),s=i&&h?h:e[r],i&&typeof a==typeof s||(l=t.bind&&i?A(s,o):t.wrap&&i?W(s):p&&"function"==typeof s?A(Function.call,s):s,(t.sham||s&&s.sham||a&&a.sham)&&L(l,"sham",!0),g[r]=l,p&&(w(z,n=c+"Prototype")||L(z,n,{}),z[n][r]=s,t.real&&y&&!y[r]&&L(y,r,s)))},V=[].slice,U={},Y=function(t,e,i){if(!(e in U)){for(var n=[],o=0;o0?J:Q)(t)},et=Math.min,it=function(t){return t>0?et(tt(t),9007199254740991):0},nt=Math.max,ot=Math.min,rt=function(t,e){var i=tt(t);return i<0?nt(i+e,0):ot(i,e)},st=function(t){return function(e,i,n){var o,r=g(e),s=it(r.length),a=rt(n,s);if(t&&i!=i){for(;s>a;)if((o=r[a++])!=o)return!0}else for(;s>a;a++)if((t||a in r)&&r[a]===i)return t||a||0;return!t&&-1}},at={includes:st(!0),indexOf:st(!1)},ht={},lt=at.indexOf,dt=function(t,e){var i,n=g(t),o=0,r=[];for(i in n)!w(ht,i)&&w(n,i)&&r.push(i);for(;e.length>o;)w(n,i=e[o++])&&(~lt(r,i)||r.push(i));return r},ct=["constructor","hasOwnProperty","isPrototypeOf","propertyIsEnumerable","toLocaleString","toString","valueOf"],ut=Object.keys||function(t){return dt(t,ct)},ft={f:Object.getOwnPropertySymbols},pt=function(t){return Object(v(t))},vt=Object.assign,gt=Object.defineProperty,yt=!vt||r((function(){if(s&&1!==vt({b:1},vt(gt({},"a",{enumerable:!0,get:function(){gt(this,"b",{value:3,enumerable:!1})}}),{b:2})).b)return!0;var t={},e={},i=Symbol(),n="abcdefghijklmnopqrst";return t[i]=7,n.split("").forEach((function(t){e[t]=t})),7!=vt({},t)[i]||ut(vt({},e)).join("")!=n}))?function(t,e){for(var i=pt(t),n=arguments.length,o=1,r=ft.f,a=l.f;n>o;)for(var h,d=p(arguments[o++]),c=r?ut(d).concat(r(d)):ut(d),u=c.length,f=0;u>f;)h=c[f++],s&&!a.call(d,h)||(i[h]=d[h]);return i}:vt;q({target:"Object",stat:!0,forced:Object.assign!==yt},{assign:yt});var mt=z.Object.assign;function bt(t,e,i,n){t.beginPath(),t.arc(e,i,n,0,2*Math.PI,!1),t.closePath()}function wt(t,e,i,n,o,r){var s=Math.PI/180;n-2*r<0&&(r=n/2),o-2*r<0&&(r=o/2),t.beginPath(),t.moveTo(e+r,i),t.lineTo(e+n-r,i),t.arc(e+n-r,i+r,r,270*s,360*s,!1),t.lineTo(e+n,i+o-r),t.arc(e+n-r,i+o-r,r,0,90*s,!1),t.lineTo(e+r,i+o),t.arc(e+r,i+o-r,r,90*s,180*s,!1),t.lineTo(e,i+r),t.arc(e+r,i+r,r,180*s,270*s,!1),t.closePath()}function kt(t,e,i,n,o){var r=.5522848,s=n/2*r,a=o/2*r,h=e+n,l=i+o,d=e+n/2,c=i+o/2;t.beginPath(),t.moveTo(e,c),t.bezierCurveTo(e,c-a,d-s,i,d,i),t.bezierCurveTo(d+s,i,h,c-a,h,c),t.bezierCurveTo(h,c+a,d+s,l,d,l),t.bezierCurveTo(d-s,l,e,c+a,e,c),t.closePath()}function _t(t,e,i,n,o){var r=o*(1/3),s=.5522848,a=n/2*s,h=r/2*s,l=e+n,d=i+r,c=e+n/2,u=i+r/2,f=i+(o-r/2),p=i+o;t.beginPath(),t.moveTo(l,u),t.bezierCurveTo(l,u+h,c+a,d,c,d),t.bezierCurveTo(c-a,d,e,u+h,e,u),t.bezierCurveTo(e,u-h,c-a,i,c,i),t.bezierCurveTo(c+a,i,l,u-h,l,u),t.lineTo(l,f),t.bezierCurveTo(l,f+h,c+a,p,c,p),t.bezierCurveTo(c-a,p,e,f+h,e,f),t.lineTo(e,u)}function xt(t,e,i,n,o,r){t.beginPath(),t.moveTo(e,i);for(var s=r.length,a=n-e,h=o-i,l=h/a,d=Math.sqrt(a*a+h*h),c=0,u=!0,f=0,p=+r[0];d>=.1;)(p=+r[c++%s])>d&&(p=d),f=Math.sqrt(p*p/(1+l*l)),e+=f=a<0?-f:f,i+=l*f,!0===u?t.lineTo(e,i):t.moveTo(e,i),d-=p,u=!u}var Et={circle:bt,dashedLine:xt,database:_t,diamond:function(t,e,i,n){t.beginPath(),t.lineTo(e,i+n),t.lineTo(e+n,i),t.lineTo(e,i-n),t.lineTo(e-n,i),t.closePath()},ellipse:kt,ellipse_vis:kt,hexagon:function(t,e,i,n){t.beginPath();var o=2*Math.PI/6;t.moveTo(e+n,i);for(var r=1;r<6;r++)t.lineTo(e+n*Math.cos(o*r),i+n*Math.sin(o*r));t.closePath()},roundRect:wt,square:function(t,e,i,n){t.beginPath(),t.rect(e-n,i-n,2*n,2*n),t.closePath()},star:function(t,e,i,n){t.beginPath(),i+=.1*(n*=.82);for(var o=0;o<10;o++){var r=o%2==0?1.3*n:.5*n;t.lineTo(e+r*Math.sin(2*o*Math.PI/10),i-r*Math.cos(2*o*Math.PI/10))}t.closePath()},triangle:function(t,e,i,n){t.beginPath(),i+=.275*(n*=1.15);var o=2*n,r=o/2,s=Math.sqrt(3)/6*o,a=Math.sqrt(o*o-r*r);t.moveTo(e,i-(a-s)),t.lineTo(e+r,i+s),t.lineTo(e-r,i+s),t.lineTo(e,i-(a-s)),t.closePath()},triangleDown:function(t,e,i,n){t.beginPath(),i-=.275*(n*=1.15);var o=2*n,r=o/2,s=Math.sqrt(3)/6*o,a=Math.sqrt(o*o-r*r);t.moveTo(e,i+(a-s)),t.lineTo(e+r,i-s),t.lineTo(e-r,i-s),t.lineTo(e,i+(a-s)),t.closePath()}};var Ot=i((function(t){function e(t){if(t)return function(t){for(var i in e.prototype)t[i]=e.prototype[i];return t}(t)}t.exports=e,e.prototype.on=e.prototype.addEventListener=function(t,e){return this._callbacks=this._callbacks||{},(this._callbacks["$"+t]=this._callbacks["$"+t]||[]).push(e),this},e.prototype.once=function(t,e){function i(){this.off(t,i),e.apply(this,arguments)}return i.fn=e,this.on(t,i),this},e.prototype.off=e.prototype.removeListener=e.prototype.removeAllListeners=e.prototype.removeEventListener=function(t,e){if(this._callbacks=this._callbacks||{},0==arguments.length)return this._callbacks={},this;var i,n=this._callbacks["$"+t];if(!n)return this;if(1==arguments.length)return delete this._callbacks["$"+t],this;for(var o=0;or;)R.f(t,i=n[r++],e[i]);return t};q({target:"Object",stat:!0,forced:!s,sham:!s},{defineProperties:Tt});var Dt=i((function(t){var e=z.Object,i=t.exports=function(t,i){return e.defineProperties(t,i)};e.defineProperties.sham&&(i.sham=!0)})),Mt=function(t){return"function"==typeof t?t:void 0},Pt=function(t,e){return arguments.length<2?Mt(z[t])||Mt(o[t]):z[t]&&z[t][e]||o[t]&&o[t][e]},It=ct.concat("length","prototype"),Bt={f:Object.getOwnPropertyNames||function(t){return dt(t,It)}},zt=Pt("Reflect","ownKeys")||function(t){var e=Bt.f(F(t)),i=ft.f;return i?e.concat(i(t)):e},Nt=function(t,e,i){var n=m(e);n in t?R.f(t,n,d(0,i)):t[n]=i};q({target:"Object",stat:!0,sham:!s},{getOwnPropertyDescriptors:function(t){for(var e,i,n=g(t),o=S.f,r=zt(n),s={},a=0;r.length>a;)void 0!==(i=o(n,e=r[a++]))&&Nt(s,e,i);return s}});var At=z.Object.getOwnPropertyDescriptors,Ft=S.f,jt=r((function(){Ft(1)}));q({target:"Object",stat:!0,forced:!s||jt,sham:!s},{getOwnPropertyDescriptor:function(t,e){return Ft(g(t),e)}});var Rt,Lt=i((function(t){var e=z.Object,i=t.exports=function(t,i){return e.getOwnPropertyDescriptor(t,i)};e.getOwnPropertyDescriptor.sham&&(i.sham=!0)})),Ht=Lt,Wt=!!Object.getOwnPropertySymbols&&!r((function(){return!String(Symbol())})),qt=Wt&&!Symbol.sham&&"symbol"==typeof Symbol.iterator,Vt=Array.isArray||function(t){return"Array"==u(t)},Ut=Pt("document","documentElement"),Yt="__core-js_shared__",Xt=o[Yt]||function(t,e){try{L(o,t,e)}catch(i){o[t]=e}return e}(Yt,{}),Gt=i((function(t){(t.exports=function(t,e){return Xt[t]||(Xt[t]=void 0!==e?e:{})})("versions",[]).push({version:"3.7.0",mode:"pure",copyright:"© 2020 Denis Pushkarev (zloirock.ru)"})})),Kt=0,$t=Math.random(),Zt=function(t){return"Symbol("+String(void 0===t?"":t)+")_"+(++Kt+$t).toString(36)},Qt=Gt("keys"),Jt=function(t){return Qt[t]||(Qt[t]=Zt(t))},te=Jt("IE_PROTO"),ee=function(){},ie=function(t){return" + + + +
+ ` + + // queued jobs + html += ''; + + // upcoming events + html += '
'; + html += '
'; + html += '
'; + html += ''; // container + + this.div.html( html ); + }, + + onActivate: function(args) { + // page activation + if (!this.requireLogin(args)) return true; + + if (!args) args = {}; + this.args = args; + + app.setWindowTitle('Home'); + app.showTabBar(true); + + this.upcoming_offset = 0; + + // presort some stuff for the filter menus + app.categories.sort( function(a, b) { + // return (b.title < a.title) ? 1 : -1; + return a.title.toLowerCase().localeCompare( b.title.toLowerCase() ); + } ); + app.plugins.sort( function(a, b) { + // return (b.title < a.title) ? 1 : -1; + return a.title.toLowerCase().localeCompare( b.title.toLowerCase() ); + } ); + + // render upcoming event filters + var html = ''; + html += 'Upcoming Events'; + + html += '
 
'; + + html += `
 
`; + html += '
 
'; + html += '
 
'; + html += '
 
'; + + html += '
'; + + $('#d_home_upcoming_header').html( html ); + + setTimeout( function() { + $('#fe_home_keywords').keypress( function(event) { + if (event.keyCode == '13') { // enter key + event.preventDefault(); + $P().set_search_filters(); + } + } ); + }, 1 ); + + // refresh datas + $('#d_home_active_jobs').html( this.get_active_jobs_html() ); + this.refresh_upcoming_events(); + this.refresh_header_stats(); + this.refresh_completed_job_chart(); + this.refresh_event_queues(); + + return true; + }, + + refresh_header_stats: function () { + // refresh daemons stats in header fieldset + var html = ''; + var stats = app.state ? (app.state.stats || {}) : {}; + var servers = app.servers || {}; + var active_events = find_objects( app.schedule, { enabled: 1 } ); + var mserver = servers[ app.managerHostname ] || {}; + + var total_cpu = 0; + var total_mem = 0; + for (var hostname in servers) { + // daemon process cpu, all servers + var server = servers[hostname]; + if (server.data && !server.disabled) { + total_cpu += (server.data.cpu || 0); + total_mem += (server.data.mem || 0); + } + } + for (var id in app.activeJobs) { + // active job process cpu, all jobs + var job = app.activeJobs[id]; + if (job.cpu) total_cpu += (job.cpu.current || 0); + if (job.mem) total_mem += (job.mem.current || 0); + } + html += ` +
Server Stats +
EVENTS:  ${ active_events.length} 
+
CATEGORIES:  ${app.categories.length} 
+
PLUGINS:  ${app.plugins.length} 
+
JOBS COMPLETED TODAY:  ${stats.jobs_completed || 0 } 
+
FAILED:  ${stats.jobs_failed || 0} 
+
SUCCESS RATE:  ${pct( (stats.jobs_completed || 0) - (stats.jobs_failed || 0), stats.jobs_completed || 1 ) } 
+
AVG LOG SIZE:  2K 
+ +
MANAGER UPTIME:  ${get_text_from_seconds( mserver.uptime || 0, false, true )} 
+
CPU:  ${short_float(total_cpu)}% 
+
MEMORY:  ${get_text_from_bytes(total_mem)} 
+
SERVERS:  ${num_keys(servers)} 
+
+ ` + + $('#d_home_header_stats').html(html); + }, + + refresh_upcoming_events: function() { + // send message to worker to refresh upcoming + this.worker_start_time = hires_time_now(); + this.worker.postMessage({ + default_tz: app.tz, + schedule: app.schedule, + state: app.state, + categories: app.categories, + plugins: app.plugins + }); + }, + + nav_upcoming: function(offset) { + // refresh upcoming events with new offset + this.upcoming_offset = offset; + this.render_upcoming_events({ + data: this.upcoming_events + }); + }, + + set_search_filters: function() { + // grab values from search filters, and refresh + var args = this.args; + + args.plugin = $('#fe_home_plugin').val(); + if (!args.plugin) delete args.plugin; + + args.target = $('#fe_home_target').val(); + if (!args.target) delete args.target; + + args.category = $('#fe_home_cat').val(); + if (!args.category) delete args.category; + + args.keywords = $('#fe_home_keywords').val(); + if (!args.keywords) delete args.keywords; + + this.nav_upcoming(0); + }, + + render_upcoming_events: function(e) { + // receive data from worker, render table now + var self = this; + var html = ''; + var now = app.epoch || hires_time_now(); + var args = this.args; + this.upcoming_events = e.data; + + var viewType = $("#fe_up_eventlimit").val(); // compact or show all + + // apply filters + var events = []; + var stubCounter = {} + var stubTitle = {} + var maxSchedRows = 25; + + for (var idx = 0, len = e.data.length; idx < len; idx++) { + var stub = e.data[idx]; + var item = find_object( app.schedule, { id: stub.id } ) || {}; + + if (viewType == "Compact View") { // one row per event, use badge for job count + + var currSched = moment.tz(stub.epoch * 1000, item.timezone || app.tz).format("h:mm A z"); + var currCD = get_text_from_seconds_round(Math.max(60, stub.epoch - now), false); + + if (!stubCounter[stub.id]) { + stubCounter[stub.id] = 1; + stubTitle[stub.id] = `` + } + else { + stubCounter[stub.id] += 1; + if (stubCounter[stub.id] <= maxSchedRows) stubTitle[stub.id] += `` + continue + } + } + + + // category filter + if (args.category && (item.category != args.category)) continue; + + // plugin filter + if (args.plugin && (item.plugin != args.plugin)) continue; + + // server group filter + if (args.target && (item.target != args.target)) continue; + + // keyword filter + var words = [item.title, item.username, item.notes, item.target].join(' ').toLowerCase(); + if (args.keywords && words.indexOf(args.keywords.toLowerCase()) == -1) continue; + + events.push( stub ); + } // foreach item in schedule + + var size = get_inner_window_size(); + var col_width = Math.floor( ((size.width * 0.9) + 50) / 7 ); + + var cols = ['Event Name', 'Category', 'Plugin', 'Target', 'Scheduled Time', 'Countdown', 'Actions']; + var limit = 25; + + html += this.getPaginatedTable({ + resp: { + rows: events.slice(this.upcoming_offset, this.upcoming_offset + limit), + list: { + length: events.length + } + }, + cols: cols, + data_type: 'pending event', + limit: limit, + offset: this.upcoming_offset, + pagination_link: '$P().nav_upcoming', + + callback: function(stub, idx) { + var item = find_object( app.schedule, { id: stub.id } ) || {}; + // var dargs = get_date_args( stub.epoch ); + var margs = moment.tz(stub.epoch * 1000, item.timezone || app.tz); + + var actions = [ + 'Edit Event' + ]; + + var cat = item.category ? find_object( app.categories, { id: item.category } ) : null; + var group = item.target ? find_object( app.server_groups, { id: item.target } ) : null; + var plugin = item.plugin ? find_object( app.plugins, { id: item.plugin } ) : null; + + var nice_countdown = 'Now'; + if (stub.epoch > now) { + nice_countdown = get_text_from_seconds_round( Math.max(60, stub.epoch - now), false ); + } + + if (group && item.multiplex) { + group = copy_object(group); + group.multiplex = 1; + } + + var badge = ''; + if(viewType == "Compact View") { + var overLimitRows = stubCounter[stub.id] > maxSchedRows ? ` + ${stubCounter[stub.id] - maxSchedRows} more` : ''; + var scheduleList = stubTitle[stub.id] + `
No.ScheduleCountdown
1| ${currSched}   | ${currCD}
${stubCounter[stub.id]} | ${currSched}  | ${currCD}
${overLimitRows}` + var jobCount = stubCounter[stub.id] + if(jobCount < 10) jobCount = ` ${jobCount} `; + var badge = `${jobCount}`; + } + + var eventName = self.getNiceEvent('' + item.title + '', col_width, 'float:left', '  ') + + var tds = [ + ` ${eventName} ${badge} `, + self.getNiceCategory( cat, col_width ), + self.getNicePlugin( plugin, col_width ), + self.getNiceGroup( group, item.target, col_width ), + // dargs.hour12 + ':' + dargs.mi + ' ' + dargs.ampm.toUpperCase(), + margs.format("h:mm A z"), + nice_countdown, + actions.join(' | ') + ]; + + if (cat && cat.color) { + if (tds.className) tds.className += ' '; else tds.className = ''; + tds.className += cat.color; + } + + return tds; + } // row callback + }); // table + + $('#d_home_upcoming_events').removeClass('loading').html( html ); + }, + + refresh_completed_job_chart: function () { + + var statusMap = { 0: 'lightgreen', 255: 'orange' } + + app.api.post('app/get_history', { offset: 0, limit: ($('#fe_cmp_job_chart_limit').val() || 50) }, function (d) { + var jobs = d.rows.reverse(); + var labels = jobs.map((j, i) => i == 0 ? j.event_title.substring(0, 4) : j.event_title); + var datasets = [{ + label: 'Completed Jobs', + // data: jobs.map(j => Math.ceil(j.elapsed/60)), + data: jobs.map(j => Math.ceil(j.elapsed) + 1), + backgroundColor: jobs.map(j => statusMap[j.code] || 'pink'), + jobs: jobs + // borderWidth: 0.3 + }]; + var scaleType = $('#fe_cmp_job_chart_scale').val() || 'logarithmic'; + + // if chart is already generated only update data + if(this.jobHistoryChart) { + this.jobHistoryChart.data.datasets = datasets; + this.jobHistoryChart.data.labels = labels; + this.jobHistoryChart.options.scales.yAxes[0].type = scaleType; + this.jobHistoryChart.update() + return + } + + var ctx = document.getElementById('d_home_completed_jobs'); + var self = this; + jobHistoryChart = new Chart(ctx, { + type: 'bar', + data: { + //labels: jobs.map(j => moment.unix(j.epoch).format('MM/D, H:mm:ss')), + labels: labels, + datasets: datasets + }, + options: { + + legend: { display: false } + , tooltips: { + yAlign: 'top', + titleFontSize: 14, + titleFontColor: 'orange', + displayColors: false, + callbacks: { + title: function (ti, dt) { return dt.datasets[0].jobs[ti[0].index].event_title }, + label: function (ti, dt) { + //var job = jobs[ti.index] + var job = dt.datasets[0].jobs[ti.index] ; + return [ + "Started on " + job.hostname + ' @ ' + moment.unix(job.time_start).format('HH:mm:ss, MMM D'), + "plugin: " + job.plugin_title, + "elapsed in " + get_text_from_seconds_round_custom(job.elapsed), + (job.description || ''), + + + ] + } + } + } + , scales: { + yAxes: [{ + type: scaleType, + ticks: { + display: false, + beginAtZero: true, + //stepSize: 1, + //suggestedMax: 10 + } + }] + } + } + }); + + ctx.ondblclick = function(evt){ + var activePoints = jobHistoryChart.getElementsAtEvent(evt); + var firstPoint = activePoints[0]; + var job = jobHistoryChart.data.datasets[firstPoint._datasetIndex].jobs[firstPoint._index] + window.open("#JobDetails?id=" + job.id, "_blank"); + }; + + }); // callback + }, + + get_active_jobs_html: function() { + // get html for active jobs table + var html = ''; + + var size = get_inner_window_size(); + var col_width = Math.floor( ((size.width * 0.9) + 50) / 8 ); + + // copy jobs to array + var jobs = []; + for (var id in app.activeJobs) { + jobs.push( app.activeJobs[id] ); + } + + // sort events by time_start descending + this.jobs = jobs.sort( function(a, b) { + return (a.time_start < b.time_start) ? 1 : -1; + } ); + + var cols = ['Job ID', 'Event Name', 'Category', 'Hostname', 'Elapsed', 'Progress', 'Remaining', 'Actions']; + + // render table + var self = this; + html += this.getBasicTable( this.jobs, cols, 'active job', function(job, idx) { + var actions = [ + // 'Details', + 'Abort Job' + ]; + + var cat = job.category ? find_object( app.categories || [], { id: job.category } ) : { title: 'n/a' }; + // var group = item.target ? find_object( app.server_groups || [], { id: item.target } ) : null; + var plugin = job.plugin ? find_object( app.plugins || [], { id: job.plugin } ) : { title: 'n/a' }; + var tds = null; + + if (job.pending && job.log_file) { + // job in retry delay + tds = [ + '
' + self.getNiceJob(job.id) + '
', + self.getNiceEvent( job.event_title, col_width ), + self.getNiceCategory( cat, col_width ), + // self.getNicePlugin( plugin ), + self.getNiceGroup( null, job.hostname, col_width ), + '
' + self.getNiceJobElapsedTime(job) + '
', + '
' + self.getNiceJobPendingText(job) + '
', + 'n/a', + actions.join(' | ') + ]; + } + else if (job.pending) { + // multiplex stagger delay + tds = [ + '
' + self.getNiceJob(job.id) + '
', + self.getNiceEvent( job.event_title, col_width ), + self.getNiceCategory( cat, col_width ), + // self.getNicePlugin( plugin ), + self.getNiceGroup( null, job.hostname, col_width ), + 'n/a', + '
' + self.getNiceJobPendingText(job) + '
', + 'n/a', + actions.join(' | ') + ]; + } // pending job + else { + // active job + tds = [ + '
' + self.getNiceJob(job.id) + '
', + self.getNiceEvent( job.event_title, col_width ), + self.getNiceCategory( cat, col_width ), + // self.getNicePlugin( plugin ), + self.getNiceGroup( null, job.hostname, col_width ), + '
' + self.getNiceJobElapsedTime(job) + '
', + '
' + self.getNiceJobProgressBar(job) + '
', + '
' + self.getNiceJobRemainingTime(job) + '
', + actions.join(' | ') + ]; + } // active job + + if (cat && cat.color) { + if (tds.className) tds.className += ' '; else tds.className = ''; + tds.className += cat.color; + } + + return tds; + } ); + + return html; + }, + + refresh_event_queues: function() { + // update display of event queues, if any + var self = this; + var total_count = 0; + for (var key in app.eventQueue) { + total_count += app.eventQueue[key] || 0; + } + + if (!total_count) { + $('#d_home_queue_container').hide(); + return; + } + + var size = get_inner_window_size(); + var col_width = Math.floor( ((size.width * 0.9) + 50) / 6 ); + var cols = ['Event Name', 'Category', 'Plugin', 'Target', 'Queued Jobs', 'Actions']; + + var stubs = []; + var sorted_ids = hash_keys_to_array(app.eventQueue).sort( function(a, b) { + return (app.eventQueue[a] < app.eventQueue[b]) ? 1 : -1; + } ); + sorted_ids.forEach( function(id) { + if (app.eventQueue[id]) stubs.push({ id: id }); + } ); + + this.queue_stubs = stubs; + + // render table + var html = ''; + html += this.getBasicTable( stubs, cols, 'event', function(stub, idx) { + var queue_count = app.eventQueue[ stub.id ] || 0; + var item = find_object( app.schedule, { id: stub.id } ) || {}; + + // for flush dialog + stub.title = item.title; + + var cat = item.category ? find_object( app.categories, { id: item.category } ) : null; + var group = item.target ? find_object( app.server_groups, { id: item.target } ) : null; + var plugin = item.plugin ? find_object( app.plugins, { id: item.plugin } ) : null; + + var actions = [ + 'Flush Queue' + ]; + + var tds = [ + '', + self.getNiceCategory( cat, col_width ), + self.getNicePlugin( plugin, col_width ), + self.getNiceGroup( group, item.target, col_width ), + commify( queue_count ), + actions.join(' | ') + ]; + + if (cat && cat.color) { + if (tds.className) tds.className += ' '; else tds.className = ''; + tds.className += cat.color; + } + + return tds; + + } ); // getBasicTable + + $('#d_home_queued_jobs').html( html ); + $('#d_home_queue_container').show(); + }, + + go_job_details: function(idx) { + // jump to job details page + var job = this.jobs[idx]; + Nav.go( '#JobDetails?id=' + job.id ); + }, + + abort_job: function(idx) { + // abort job, after confirmation + var job = this.jobs[idx]; + + app.confirm( 'Abort Job', "Are you sure you want to abort the job “"+job.id+"”?
(Event: "+job.event_title+")", "Abort", function(result) { + if (result) { + app.showProgress( 1.0, "Aborting job..." ); + app.api.post( 'app/abort_job', job, function(resp) { + app.hideProgress(); + app.showMessage('success', "Job '"+job.event_title+"' was aborted successfully."); + } ); + } + } ); + }, + + flush_event_queue: function(idx) { + // abort job, after confirmation + var stub = this.queue_stubs[idx]; + + app.confirm( 'Flush Event Queue', "Are you sure you want to flush the queue for event “"+stub.title+"”?", "Flush", function(result) { + if (result) { + app.showProgress( 1.0, "Flushing event queue..." ); + app.api.post( 'app/flush_event_queue', stub, function(resp) { + app.hideProgress(); + app.showMessage('success', "Event queue for '"+stub.title+"' was flushed successfully."); + } ); + } + } ); + }, + + getNiceJobElapsedTime: function(job) { + // render nice elapsed time display + var elapsed = Math.floor( Math.max( 0, app.epoch - job.time_start ) ); + return get_text_from_seconds( elapsed, true, false ); + }, + + getNiceJobProgressBar: function(job) { + // render nice progress bar for job + var html = ''; + var counter = Math.min(1, Math.max(0, job.progress || 1)); + var cx = Math.floor( counter * this.bar_width ); + var extra_classes = ''; + var extra_attribs = ''; + if (counter == 1.0) extra_classes = 'indeterminate'; + else extra_attribs = 'title="'+Math.floor( (counter / 1.0) * 100 )+'%"'; + + html += '
'; + html += `
`; + html += '
'; + + return html; + }, + + getNiceJobRemainingTime: function(job) { + // get nice job remaining time, using elapsed and progress + var elapsed = Math.floor( Math.max( 0, app.epoch - job.time_start ) ); + var progress = job.progress || 0; + if ((elapsed >= 10) && (progress > 0) && (progress < 1.0)) { + var sec_remain = Math.floor(((1.0 - progress) * elapsed) / progress); + return get_text_from_seconds( sec_remain, true, true ); + } + else return 'n/a'; + }, + + getNiceJobPendingText: function(job) { + // get nice display for pending job status + var html = ''; + + // if job has a log_file, it's in a retry delay, otherwise it's pending (multiplex stagger) + html += (job.log_file ? 'Retry' : 'Pending'); + + // countdown to actual launch + var nice_countdown = get_text_from_seconds( Math.max(0, job.when - app.epoch), true, true ); + html += ' (' + nice_countdown + ')'; + + return html; + }, + + onStatusUpdate: function(data) { + // received status update (websocket), update page if needed + if (data.jobs_changed) { + // refresh tables + $('#d_home_active_jobs').html( this.get_active_jobs_html() ); + } + else { + // update progress, time remaining, no refresh + for (var id in app.activeJobs) { + var job = app.activeJobs[id]; + + if (job.pending) { + // update countdown + $('#d_home_jt_progress_' + job.id).html( this.getNiceJobPendingText(job) ); + + if (job.log_file) { + // retry delay + $('#d_home_jt_elapsed_' + job.id).html( this.getNiceJobElapsedTime(job) ); + } + } // pending job + else { + $('#d_home_jt_elapsed_' + job.id).html( this.getNiceJobElapsedTime(job) ); + $('#d_home_jt_remaining_' + job.id).html( this.getNiceJobRemainingTime(job) ); + + // update progress bar without redrawing it (so animation doesn't jitter) + var counter = job.progress || 1; + var cx = Math.floor( counter * this.bar_width ); + var prog_cont = $('#d_home_jt_progress_' + job.id + ' > div.progress_bar_container'); + + if ((counter == 1.0) && !prog_cont.hasClass('indeterminate')) { + prog_cont.addClass('indeterminate').attr('title', ""); + } + else if ((counter < 1.0) && prog_cont.hasClass('indeterminate')) { + prog_cont.removeClass('indeterminate'); + } + + if (counter < 1.0) prog_cont.attr('title', '' + Math.floor( (counter / 1.0) * 100 ) + '%'); + + prog_cont.find('> div.progress_bar_inner').css( 'width', '' + cx + 'px' ); + } // active job + } // foreach job + } // quick update + }, + + onDataUpdate: function(key, value) { + // recieved data update (websocket) + switch (key) { + case 'state': + // update chart only on job completion + if(this.curr_compl_job_count != value.stats.jobs_completed) { + this.refresh_completed_job_chart() + } + this.curr_compl_job_count = value.stats.jobs_completed; + this.refresh_upcoming_events(); + this.refresh_header_stats(); + + break; + case 'schedule': + // state update (new cursors) + // $('#d_home_upcoming_events').html( this.get_upcoming_events_html() ); + this.refresh_upcoming_events(); + this.refresh_header_stats(); + break; + + case 'eventQueue': + this.refresh_event_queues(); + break; + } + }, + + onResizeDelay: function(size) { + // called 250ms after latest window resize + // so we can run more expensive redraw operations + $('#d_home_active_jobs').html( this.get_active_jobs_html() ); + this.refresh_completed_job_chart() + this.refresh_header_stats(); + this.refresh_event_queues(); + + if (this.upcoming_events) { + this.render_upcoming_events({ + data: this.upcoming_events + }); + } + }, + + onDeactivate: function() { + // called when page is deactivated + // this.div.html( '' ); + return true; + } + +} ); diff --git a/htdocs/js/pages/JobDetails.class.js b/htdocs/js/pages/JobDetails.class.js new file mode 100644 index 0000000..cd5ec3e --- /dev/null +++ b/htdocs/js/pages/JobDetails.class.js @@ -0,0 +1,1421 @@ +// Cronicle JobDetails Page + +Class.subclass(Page.Base, "Page.JobDetails", { + + pie_colors: { + cool: 'green', + warm: 'rgb(240,240,0)', + hot: '#F7464A', + progress: '#3f7ed5', + empty: 'rgba(0, 0, 0, 0.05)' + }, + + onInit: function () { + // called once at page load + // var html = ''; + // this.div.html( html ); + this.charts = {}; + }, + + onActivate: function (args) { + // page activation + if (!this.requireLogin(args)) return true; + + if (!args) args = {}; + this.args = args; + + if (!args.id) { + app.doError("The Job Details page requires a Job ID."); + return true; + } + + app.setWindowTitle("Job Details: #" + args.id); + app.showTabBar(true); + + this.tab.show(); + this.tab[0]._page_id = Nav.currentAnchor(); + + this.retry_count = 3; + this.go_when_ready(); + + return true; + }, + + go_when_ready: function () { + // make sure we're not in the limbo state between starting a manual job, + // and waiting for activeJobs to be updated + var self = this; + var args = this.args; + + if (this.find_job(args.id)) { + // job is currently active -- jump to real-time view + args.sub = 'live'; + this.gosub_live(args); + } + else { + // job must be completed -- jump to archive view + args.sub = 'archive'; + this.gosub_archive(args); + } + }, + + gosub_archive: function (args) { + // show job archive + var self = this; + Debug.trace("Showing archived job: " + args.id); + this.div.addClass('loading'); + + app.api.post('app/get_job_details', { id: args.id }, this.receive_details.bind(this), function (resp) { + // error capture + if (self.retry_count >= 0) { + Debug.trace("Failed to get_job_details, trying again in 1s..."); + self.retry_count--; + setTimeout(function () { self.go_when_ready(); }, 1000); + } + else { + // show error + app.doError("Error: " + resp.description); + self.div.removeClass('loading'); + } + }); + }, + + get_job_result_banner: function (job) { + // render banner based on job result + var icon = ''; + var type = ''; + if (job.abort_reason || job.unknown || job.code == 255) { + type = 'warning'; + icon = 'exclamation-circle'; + } + else if (job.code) { + type = 'error'; + icon = 'exclamation-triangle'; + } + else { + type = 'success'; + icon = 'check-circle'; + } + + if (!job.description && job.code) { + job.description = "Job failed with code: " + job.code; + } + if (!job.code && (!job.description || job.description.replace(/\W+/, '').match(/^success(ful)?$/i))) { + job.description = "Job completed successfully at " + get_nice_date_time(job.time_end, false, true); + + // add timezone abbreviation + job.description += " " + moment.tz(job.time_end * 1000, app.tz).format('z'); + } + if (job.code && !job.description.match(/^\s*error/i)) { + var desc = job.description; + job.description = "Error"; + if (job.code != 1) job.description += " " + job.code; + if (job.code == 255) { job.description = "Warning" }; + job.description += ": " + desc; + } + + var job_desc_html = trim(job.description.replace(/\r\n/g, "\n")); + var multiline = !!job.description.match(/\n/); + job_desc_html = encode_entities(job_desc_html).replace(/\n/g, "
\n"); + + var html = ''; + html += '
'; + + if (multiline) { + html += job_desc_html; + } + else { + html += '   ' + job_desc_html; + } + html += '
'; + return html; + }, + + delete_job: function () { + // delete job, after confirmation + var self = this; + var job = this.job; + + app.confirm('Delete Job', "Are you sure you want to delete the current job log and history?", "Delete", function (result) { + if (result) { + app.showProgress(1.0, "Deleting job..."); + app.api.post('app/delete_job', job, function (resp) { + app.hideProgress(); + app.showMessage('success', "Job ID '" + job.id + "' was deleted successfully."); + $('#tab_History').trigger('click'); + self.tab.hide(); + }); + } + }); + }, + + run_again: function () { + // run job again + var self = this; + var event = find_object(app.schedule, { id: this.job.event }) || null; + if (!event) return app.doError("Could not locate event in schedule: " + this.job.event_title + " (" + this.job.event + ")"); + + var job = deep_copy_object(event); + job.now = this.job.now; + job.params = this.job.params; + + app.showProgress(1.0, "Starting job..."); + + app.api.post('app/run_event', job, function (resp) { + // app.showMessage('success', "Event '"+event.title+"' has been started."); + self.jump_live_job_id = resp.ids[0]; + self.jump_live_time_start = hires_time_now(); + self.jump_to_live_when_ready(); + }); + }, + + jump_to_live_when_ready: function () { + // make sure live view is ready (job may still be starting) + var self = this; + if (!this.active) return; // user navigated away from page + + if (app.activeJobs[this.jump_live_job_id] || ((hires_time_now() - this.jump_live_time_start) >= 3.0)) { + app.hideProgress(); + Nav.go('JobDetails?id=' + this.jump_live_job_id); + delete this.jump_live_job_id; + delete this.jump_live_time_start; + } + else { + setTimeout(self.jump_to_live_when_ready.bind(self), 250); + } + }, + + receive_details: function (resp) { + // receive job details from server, render them + var html = ''; + var job = this.job = resp.job; + this.div.removeClass('loading'); + + var size = get_inner_window_size(); + var col_width = Math.floor(((size.width * 0.9) - 300) / 4); + + // locate objects + var event = find_object(app.schedule, { id: job.event }) || {}; + var cat = job.category ? find_object(app.categories, { id: job.category }) : null; + var group = event.target ? find_object(app.server_groups, { id: event.target }) : null; + var plugin = job.plugin ? find_object(app.plugins, { id: job.plugin }) : null; + + if (group && event.multiplex) { + group = copy_object(group); + group.multiplex = 1; + } + + html += '
'; + html += 'Completed Job'; + if (event.id && !event.multiplex) html += '
 Run Again
'; + //adding edit button on job detail page + if (event.id) html += ''; + if (app.isAdmin()) html += '
 Delete Job
'; + html += '
'; + html += '
'; + + // result banner + // (adding replace to remove ansi color characters) + html += this.get_job_result_banner(job).replace(/\x1B\[[0-?]*[ -/]*[@-~]/g, ""); + + // fieldset header + html += '
Job Details'; + + // if (event.id && !event.multiplex) html += '
Run Again
'; + + html += '
'; + html += '
JOB ID
'; + html += '
' + job.id + '
'; + + html += '
EVENT NAME
'; + html += '
'; + if (event.id) html += '' + this.getNiceEvent(job.event_title, col_width) + ''; + else if (job.event_title) html += this.getNiceEvent(job.event_title, col_width); + else html += '(None)'; + html += '
'; + + html += '
EVENT TIMING
'; + html += '
' + (event.enabled ? summarize_event_timing(event.timing, event.timezone) : '(Disabled)') + '
'; + html += '
'; + + html += '
'; + html += '
CATEGORY NAME
'; + html += '
'; + if (cat) html += this.getNiceCategory(cat, col_width); + else if (job.category_title) html += this.getNiceCategory({ title: job.category_title }, col_width); + else html += '(None)'; + html += '
'; + + html += '
PLUGIN NAME
'; + html += '
'; + if (plugin) html += this.getNicePlugin(plugin, col_width); + else if (job.plugin_title) html += this.getNicePlugin({ title: job.plugin_title }, col_width); + else html += '(None)'; + html += '
'; + + html += '
EVENT TARGET
'; + html += '
'; + if (group || event.target) html += this.getNiceGroup(group, event.target, col_width); + else if (job.nice_target) html += '
' + job.nice_target + '
'; + else html += '(None)'; + html += '
'; + html += '
'; + + html += '
'; + html += '
JOB SOURCE
'; + html += '
' + (job.source || 'Scheduler') + '
'; + + html += '
SERVER HOSTNAME
'; + html += '
' + this.getNiceGroup(null, job.hostname, col_width) + '
'; + + html += '
PROCESS ID
'; + html += '
' + (job.detached_pid || job.pid || '(Unknown)') + '
'; + html += '
'; + + html += '
'; + html += '
JOB STARTED
'; + html += '
'; + if ((job.time_start - job.now >= 60) && !event.multiplex && !job.source) { + html += ''; + html += get_nice_date_time(job.time_start, false, true); + html += ''; + } + else html += get_nice_date_time(job.time_start, false, true); + html += '
'; + + html += '
JOB COMPLETED
'; + html += '
' + get_nice_date_time(job.time_end, false, true) + '
'; + + html += '
ELAPSED TIME
'; + html += '
' + get_text_from_seconds(job.elapsed, false, false) + '
'; + html += '
'; + + html += '
'; + html += '
'; + + // pies + html += '
'; + + html += '
'; + html += '
Performance Metrics
'; + html += '
'; + // html += ''; + html += '
'; + html += '
'; + + html += '
'; + html += '
'; + html += '
Memory Usage
'; + html += '
'; + // html += ''; + html += '
'; + html += '
'; + + html += '
'; + html += '
'; + html += '
CPU Usage
'; + html += '
'; + // html += ''; + html += '
'; + html += '
'; + + html += '
'; + + // custom data table + if (job.table && job.table.rows && job.table.rows.length) { + var table = job.table; + html += '
' + (table.title || 'Job Stats') + '
'; + html += ''; + + if (table.header && table.header.length) { + html += ''; + for (var idx = 0, len = table.header.length; idx < len; idx++) { + html += ''; + } + html += ''; + } + + var filters = table.filters || []; + + for (var idx = 0, len = table.rows.length; idx < len; idx++) { + var row = table.rows[idx]; + if (row && row.length) { + html += ''; + + for (var idy = 0, ley = row.length; idy < ley; idy++) { + var col = row[idy]; + html += ''; + } // foreach col + + html += ''; + } // good row + } // foreach row + + html += '
' + table.header[idx] + '
'; + if (typeof (col) != 'undefined') { + if (filters[idy] && window[filters[idy]]) html += window[filters[idy]](col); + else if ((typeof (col) == 'string') && col.match(/^filter\:(\w+)\((.+)\)$/)) { + var filter = RegExp.$1; + var value = RegExp.$2; + if (window[filter]) html += window[filter](value); + else html += value; + } + else html += col; + } + html += '
'; + if (table.caption) html += '
' + table.caption + '
'; + } // custom data table + + // custom html table (and also error output on job detail page) + //adding replace to remove ansi color characters + if (job.html) { + html += '
' + (job.html.title || 'Job Report') + '
'; + html += '
' + job.html.content.replace(/\x1B\[[0-?]*[ -/]*[@-~]/g, "") + '
'; + if (job.html.caption) html += '
' + job.html.caption + '
'; + } + + // job log (IFRAME) + html += '
'; + html += 'Console Output'; + var logSize = "" + if (job.log_file_size) logSize += ' (' + get_text_from_bytes(job.log_file_size, 1) + ')'; + html += ''; + html += ''; + html += '
'; + html += '
'; + + var max_log_file_size = config.max_log_file_size || 10485760; + if (job.log_file_size && (job.log_file_size >= max_log_file_size)) { + // too big to show? ask user + html += '
'; + html += '
'; + html += '
Warning: Job event log file is ' + get_text_from_bytes(job.log_file_size, 1) + '. Please consider downloading instead of viewing in browser.
'; + html += '
Download Log
'; + html += '
View Log
'; + html += '
'; + html += '
'; + html += '
'; + } + else { + var size = get_inner_window_size(); + var iheight = size.height - 100; + //html += ''; + + // replace iframe with ajax output. This will make log output look like terminal, and also fixes ansi colors + html += '
test
' + var ansi_up = new AnsiUp; + var logurl = '/api/app/get_job_log?id=' + job.id + + $.get(logurl, function (data) { + data = data.split("\n").slice(4, -4).join("\n").replace(/\u001B=/g, ''); // removeing Esc= sequence generated by powershell pipe + $("#console_output").html(ansi_up.ansi_to_html(data)); + + }); + } + + this.div.html(html); + + // arch perf chart + var suffix = ' sec'; + var pscale = 1; + if (!job.perf) job.perf = { total: job.elapsed }; + if (!isa_hash(job.perf)) job.perf = parse_query_string(job.perf.replace(/\;/g, '&')); + + if (job.perf.scale) { + pscale = job.perf.scale; + delete job.perf.scale; + } + + var perf = job.perf.perf ? job.perf.perf : job.perf; + + // remove counters from pie + for (var key in perf) { + if (key.match(/^c_/)) delete perf[key]; + } + + // clean up total, add other + if (perf.t) { perf.total = perf.t; delete perf.t; } + if ((num_keys(perf) > 1) && perf.total) { + if (!perf.other) { + var totes = 0; + for (var key in perf) { + if (key != 'total') totes += perf[key]; + } + if (totes < perf.total) { + perf.other = perf.total - totes; + } + } + delete perf.total; // only show total if by itself + } + + // remove outer 'umbrella' perf keys if inner ones are more specific + // (i.e. remove "db" if we have "db_query" and/or "db_connect") + for (var key in perf) { + for (var subkey in perf) { + if ((subkey.indexOf(key + '_') == 0) && (subkey.length > key.length + 1)) { + delete perf[key]; + break; + } + } + } + + // divide everything by scale, so we get seconds + for (var key in perf) { + perf[key] /= pscale; + } + + var colors = this.graph_colors; + var color_idx = 0; + + var p_data = []; + var p_colors = []; + var p_labels = []; + + var perf_keys = hash_keys_to_array(perf).sort(); + + for (var idx = 0, len = perf_keys.length; idx < len; idx++) { + var key = perf_keys[idx]; + var value = perf[key]; + + p_data.push(short_float(value)); + p_colors.push('rgb(' + colors[color_idx] + ')'); + p_labels.push(key); + + color_idx = (color_idx + 1) % colors.length; + } + + var ctx = $("#c_arch_perf").get(0).getContext("2d"); + + var perf_chart = new Chart(ctx, { + type: 'pie', + data: { + datasets: [{ + data: p_data, + backgroundColor: p_colors, + label: '' + }], + labels: p_labels + }, + options: { + responsive: true, + responsiveAnimationDuration: 0, + maintainAspectRatio: false, + legend: { + display: false, + position: 'right', + }, + title: { + display: false, + text: '' + }, + animation: { + animateScale: true, + animateRotate: true + } + } + }); + + var legend_html = ''; + legend_html += '
'; + for (var idx = 0, len = perf_keys.length; idx < len; idx++) { + legend_html += '
' + p_labels[idx] + '
'; + } + legend_html += '
'; + + var perf_legend = $('#d_arch_perf_legend'); + perf_legend.html(legend_html); + + + this.charts.perf = perf_chart; + + // arch cpu pie + var cpu_avg = 0; + if (!job.cpu) job.cpu = {}; + if (job.cpu.total && job.cpu.count) { + cpu_avg = short_float(job.cpu.total / job.cpu.count); + } + + var jcm = 100; + var ctx = $("#c_arch_cpu").get(0).getContext("2d"); + + var cpu_chart = new Chart(ctx, { + type: 'doughnut', + data: { + datasets: [{ + data: [ + Math.min(cpu_avg, jcm), + jcm - Math.min(cpu_avg, jcm), + ], + backgroundColor: [ + (cpu_avg < jcm * 0.5) ? this.pie_colors.cool : + ((cpu_avg < jcm * 0.75) ? this.pie_colors.warm : this.pie_colors.hot), + this.pie_colors.empty + ], + label: '' + }], + labels: [] + }, + options: { + events: [], + responsive: true, + responsiveAnimationDuration: 0, + maintainAspectRatio: false, + legend: { + display: false, + position: 'right', + }, + title: { + display: false, + text: '' + }, + animation: { + animateScale: true, + animateRotate: true + } + } + }); + + // arch cpu overlay + var html = ''; + html += '
' + cpu_avg + '%
'; + html += '
Average
'; + $('#d_arch_cpu_overlay').html(html); + + // arch cpu legend + var html = ''; + + html += '
MIN
'; + html += '
' + short_float(job.cpu.min || 0) + '%
'; + + html += '
PEAK
'; + html += '
' + short_float(job.cpu.max || 0) + '%
'; + + $('#d_arch_cpu_legend').html(html); + + this.charts.cpu = cpu_chart; + + // arch mem pie + var mem_avg = 0; + if (!job.mem) job.mem = {}; + if (job.mem.total && job.mem.count) { + mem_avg = Math.floor(job.mem.total / job.mem.count); + } + + var jmm = config.job_memory_max || 1073741824; + var ctx = $("#c_arch_mem").get(0).getContext("2d"); + + var mem_chart = new Chart(ctx, { + type: 'doughnut', + data: { + datasets: [{ + data: [ + Math.min(mem_avg, jmm), + jmm - Math.min(mem_avg, jmm), + ], + backgroundColor: [ + (mem_avg < jmm * 0.5) ? this.pie_colors.cool : + ((mem_avg < jmm * 0.75) ? this.pie_colors.warm : this.pie_colors.hot), + this.pie_colors.empty + ], + label: '' + }], + labels: [] + }, + options: { + events: [], + responsive: true, + responsiveAnimationDuration: 0, + maintainAspectRatio: false, + legend: { + display: false, + position: 'right', + }, + title: { + display: false, + text: '' + }, + animation: { + animateScale: true, + animateRotate: true + } + } + }); + + // arch mem overlay + var html = ''; + html += '
' + get_text_from_bytes(mem_avg, 10) + '
'; + html += '
Average
'; + $('#d_arch_mem_overlay').html(html); + + // arch mem legend + var html = ''; + + html += '
MIN
'; + html += '
' + get_text_from_bytes(job.mem.min || 0, 1) + '
'; + + html += '
PEAK
'; + html += '
' + get_text_from_bytes(job.mem.max || 0, 1) + '
'; + + $('#d_arch_mem_legend').html(html); + + this.charts.mem = mem_chart; + }, + + do_download_log: function () { + // download job log file + var job = this.job; + window.location = '/api/app/get_job_log?id=' + job.id + '&download=1' + '&session_id=' + localStorage.session_id; + }, + + do_view_inline_log: function () { + // swap out job log size warning with IFRAME containing inline log + var job = this.job; + var html = ''; + + var size = get_inner_window_size(); + var iheight = size.height - 100; + html += ''; + + $('#d_job_log_warning').html(html); + }, + + abort_job: function () { + // abort job, after confirmation + var job = this.find_job(this.args.id); + + app.confirm('Abort Job', "Are you sure you want to abort the current job?", "Abort", function (result) { + if (result) { + app.showProgress(1.0, "Aborting job..."); + app.api.post('app/abort_job', job, function (resp) { + app.hideProgress(); + app.showMessage('success', "Job '" + job.event_title + "' was aborted successfully."); + }); + } + }); + }, + + check_watch_enabled: function (job) { + // check if watch is enabled on current live job + var watch_enabled = 0; + var email = app.user.email.toLowerCase(); + if (email && job.notify_success && (job.notify_success.toLowerCase().indexOf(email) > -1)) watch_enabled++; + if (email && job.notify_fail && (job.notify_fail.toLowerCase().indexOf(email) > -1)) watch_enabled++; + return (watch_enabled == 2); + }, + + watch_add_me: function (job, key) { + // add current user's e-mail to job property + if (!job[key]) job[key] = ''; + var value = trim(job[key].replace(/\,\s*\,/g, ',').replace(/^\s*\,\s*/, '').replace(/\s*\,\s*$/, '')); + var email = app.user.email.toLowerCase(); + var regexp = new RegExp("\\b" + escape_regexp(email) + "\\b", "i"); + + if (!value.match(regexp)) { + if (value) value += ', '; + job[key] = value + app.user.email; + } + }, + + watch_remove_me: function (job, key) { + // remove current user's email from job property + if (!job[key]) job[key] = ''; + var value = trim(job[key].replace(/\,\s*\,/g, ',').replace(/^\s*\,\s*/, '').replace(/\s*\,\s*$/, '')); + var email = app.user.email.toLowerCase(); + var regexp = new RegExp("\\b" + escape_regexp(email) + "\\b", "i"); + + value = value.replace(regexp, '').replace(/\,\s*\,/g, ',').replace(/^\s*\,\s*/, '').replace(/\s*\,\s*$/, ''); + job[key] = trim(value); + }, + + toggle_watch: function () { + // toggle watch on/off on current live job + var job = this.find_job(this.args.id); + var watch_enabled = this.check_watch_enabled(job); + + if (!watch_enabled) { + this.watch_add_me(job, 'notify_success'); + this.watch_add_me(job, 'notify_fail'); + } + else { + this.watch_remove_me(job, 'notify_success'); + this.watch_remove_me(job, 'notify_fail'); + } + + // update on server + $('#s_watch_job > i').removeClass().addClass('fa fa-spin fa-spinner'); + + app.api.post('app/update_job', { id: job.id, notify_success: job.notify_success, notify_fail: job.notify_fail }, function (resp) { + watch_enabled = !watch_enabled; + if (watch_enabled) { + app.showMessage('success', "You will now be notified via e-mail when the job completes (success or fail)."); + $('#s_watch_job').css('color', '#3f7ed5'); + $('#s_watch_job > i').removeClass().addClass('fa fa-check-square-o'); + } + else { + app.showMessage('success', "You will no longer be notified about this job."); + $('#s_watch_job').css('color', '#777'); + $('#s_watch_job > i').removeClass().addClass('fa fa-square-o'); + } + }); + }, + + gosub_live: function (args) { + // show live job status + Debug.trace("Showing live job: " + args.id); + var job = this.find_job(args.id); + var html = ''; + this.div.removeClass('loading'); + + var size = get_inner_window_size(); + var col_width = Math.floor(((size.width * 0.9) - 300) / 4); + + // locate objects + var event = find_object(app.schedule, { id: job.event }) || {}; + var cat = job.category ? find_object(app.categories, { id: job.category }) : { title: 'n/a' }; + var group = event.target ? find_object(app.server_groups, { id: event.target }) : null; + var plugin = job.plugin ? find_object(app.plugins, { id: job.plugin }) : { title: 'n/a' }; + + if (group && event.multiplex) { + group = copy_object(group); + group.multiplex = 1; + } + + // new header with watch & abort + var watch_enabled = this.check_watch_enabled(job); + + html += '
'; + html += 'Live Job Progress'; + html += '
 Abort Job
'; + html += '
 Watch Job
'; + html += '
'; + html += '
'; + + // fieldset header + html += '
Job Details'; + + // html += '
Abort Job...
'; + + html += '
'; + html += '
JOB ID
'; + html += ``; + + html += '
EVENT NAME
'; + html += ''; + + html += '
EVENT TIMING
'; + html += '
' + (event.enabled ? summarize_event_timing(event.timing, event.timezone) : '(Disabled)') + '
'; + html += '
'; + + html += '
'; + html += '
CATEGORY NAME
'; + html += '
' + this.getNiceCategory(cat, col_width) + '
'; + + html += '
PLUGIN NAME
'; + html += '
' + this.getNicePlugin(plugin, col_width) + '
'; + + html += '
EVENT TARGET
'; + html += '
' + this.getNiceGroup(group, event.target, col_width) + '
'; + html += '
'; + + html += '
'; + html += '
JOB SOURCE
'; + html += '
' + (job.source || 'Scheduler') + '
'; + + html += '
SERVER HOSTNAME
'; + html += '
' + this.getNiceGroup(null, job.hostname, col_width) + '
'; + + html += '
PROCESS ID
'; + html += '
' + job.pid + '
'; + html += '
'; + + html += '
'; + html += '
JOB STARTED
'; + html += '
' + get_nice_date_time(job.time_start, false, true) + '
'; + + html += '
ELAPSED TIME
'; + var elapsed = Math.floor(Math.max(0, app.epoch - job.time_start)); + html += '
' + get_text_from_seconds(elapsed, false, false) + '
'; + + var progress = job.progress || 0; + var nice_remain = 'n/a'; + if (job.pending && job.when) { + nice_remain = 'Retry in ' + get_text_from_seconds(Math.max(0, job.when - app.epoch), true, true) + ''; + } + else if ((elapsed >= 10) && (progress > 0) && (progress < 1.0)) { + var sec_remain = Math.floor(((1.0 - progress) * elapsed) / progress); + nice_remain = get_text_from_seconds(sec_remain, false, true); + } + html += '
REMAINING TIME
'; + html += '
' + nice_remain + '
'; + html += '
'; + + html += '
'; + html += '
'; + + // pies + html += '
'; + + html += '
'; + html += '
'; + html += '
Job Progress
'; + html += '
'; + // html += ''; + // html += '
'; + html += '
'; + + html += '
'; + html += '
'; + html += '
Memory Usage
'; + html += '
'; + // html += ''; + html += '
'; + html += '
'; + + html += '
'; + html += '
'; + html += '
CPU Usage
'; + html += '
'; + // html += ''; + html += '
'; + html += '
'; + + html += '
'; + + // live job log tail + var remote_api_url = app.proto + job.hostname + ':' + app.port + config.base_api_uri; + if (config.custom_live_log_socket_url) { + // custom websocket URL. Can use object (map) for multi-node setup + remote_api_url = config.custom_live_log_socket_url[job.hostname] + // if string (typically single master) + if( typeof config.custom_live_log_socket_url === "string" ) remote_api_url = config.custom_live_log_socket_url ; + // if object (for multi-node) + if(config.custom_live_log_socket_url[job.hostname]) remote_api_url = config.custom_live_log_socket_url[job.hostname]; + } + else if (!config.web_socket_use_hostnames && app.servers && app.servers[job.hostname] && app.servers[job.hostname].ip) { + // use ip if available, may work better in some setups + remote_api_url = app.proto + app.servers[job.hostname].ip + ':' + app.port + config.base_api_uri; + } + + html += '
'; + html += `Live Job Event Log `; + //html += ''; + html += ``; + html += '
'; + html += '
'; + + var size = get_inner_window_size(); + var iheight = size.height - 100; + html += '
'; + + this.div.html(html); + + // open websocket for log tail stream + this.start_live_log_watcher(job); + + // live progress pie + if (!job.progress) job.progress = 0; + var progress = Math.min(1, Math.max(0, job.progress)); + var prog_pct = short_float(progress * 100); + + var ctx = $("#c_live_progress").get(0).getContext("2d"); + var progress_chart = new Chart(ctx, { + type: 'doughnut', + data: { + datasets: [{ + data: [ + prog_pct, + 100 - prog_pct + ], + backgroundColor: [ + this.pie_colors.progress, + this.pie_colors.empty + ], + label: '' + }], + labels: [] + }, + options: { + events: [], + responsive: true, + responsiveAnimationDuration: 0, + maintainAspectRatio: false, + legend: { + display: false, + position: 'right', + }, + title: { + display: false, + text: '' + }, + animation: { + animateScale: true, + animateRotate: true + } + } + }); + + this.charts.progress = progress_chart; + + // live cpu pie + if (!job.cpu) job.cpu = {}; + if (!job.cpu.current) job.cpu.current = 0; + var cpu_cur = job.cpu.current; + var cpu_avg = 0; + if (job.cpu.total && job.cpu.count) { + cpu_avg = short_float(job.cpu.total / job.cpu.count); + } + var jcm = 100; + var ctx = $("#c_live_cpu").get(0).getContext("2d"); + var cpu_chart = new Chart(ctx, { + type: 'doughnut', + data: { + datasets: [{ + data: [ + Math.min(cpu_cur, jcm), + jcm - Math.min(cpu_cur, jcm), + ], + backgroundColor: [ + (cpu_cur < jcm * 0.5) ? this.pie_colors.cool : + ((cpu_cur < jcm * 0.75) ? this.pie_colors.warm : this.pie_colors.hot), + this.pie_colors.empty + ], + label: '' + }], + labels: [] + }, + options: { + events: [], + responsive: true, + responsiveAnimationDuration: 0, + maintainAspectRatio: false, + legend: { + display: false, + position: 'right', + }, + title: { + display: false, + text: '' + }, + animation: { + animateScale: true, + animateRotate: true + } + } + }); + + this.charts.cpu = cpu_chart; + + // live mem pie + if (!job.mem) job.mem = {}; + if (!job.mem.current) job.mem.current = 0; + var mem_cur = job.mem.current; + var mem_avg = 0; + if (job.mem.total && job.mem.count) { + mem_avg = short_float(job.mem.total / job.mem.count); + } + var jmm = config.job_memory_max || 1073741824; + var ctx = $("#c_live_mem").get(0).getContext("2d"); + var mem_chart = new Chart(ctx, { + type: 'doughnut', + data: { + datasets: [{ + data: [ + Math.min(mem_cur, jmm), + jmm - Math.min(mem_cur, jmm), + ], + backgroundColor: [ + (mem_cur < jmm * 0.5) ? this.pie_colors.cool : + ((mem_cur < jmm * 0.75) ? this.pie_colors.warm : this.pie_colors.hot), + this.pie_colors.empty + ], + label: '' + }], + labels: [] + }, + options: { + events: [], + responsive: true, + responsiveAnimationDuration: 0, + maintainAspectRatio: false, + legend: { + display: false, + position: 'right', + }, + title: { + display: false, + text: '' + }, + animation: { + animateScale: true, + animateRotate: true + } + } + }); + + this.charts.mem = mem_chart; + + // update dynamic data + this.update_live_progress(job); + }, + + start_live_log_watcher: function(job) { + + if(app.config.ui.live_log_ws) { + this.start_live_log_watcher_ws(job) // use classic websocket live log + } + else {this.start_live_log_watcher_poll(job)} + + }, + + start_live_log_watcher_poll: function (job) { + let self = this; + self.curr_live_log_job = job.id; + + let ansi_up = new AnsiUp; + webConsole = document.getElementById('d_live_job_log'); + // poll live_console api until job is running or some error occur + function refresh() { + if(self.curr_live_log_job != job.id) return; // prevent double logging + app.api.post('/api/app/get_live_console', { id: job.id, tail: parseInt(app.config.ui.live_log_tail_size) || 80 } + , (data) => { // success callback + if (!data.data) return; // stop polling if no data + webConsole.innerHTML = `
${ansi_up.ansi_to_html(data.data.replace(/\u001B=/g, ''))} 
`; + pollInterval = parseInt(app.config.ui.live_log) + if(!pollInterval || pollInterval < 1000) pollInterval = 1000; + setTimeout(refresh, 1000); + } + // stop polling on error, report unexpected errors + , (e) => { + if(e.code != 'job') console.error('Live log poll error: ', e) + return + } + ) + } + + refresh(); + + }, + + start_live_log_watcher_ws: function (job) { + // open special websocket to target server for live log feed + var self = this; + var $cont = null; + var chunk_count = 0; + var error_shown = false; + + var url = app.proto + job.hostname + ':' + app.port; + if (config.custom_live_log_socket_url) { + // custom websocket URL + + // if string (single node) + if( typeof config.custom_live_log_socket_url === "string" ) url = config.custom_live_log_socket_url ; + // if object (multi-node) + url = config.custom_live_log_socket_url[job.hostname] || url + + } + else if (!config.web_socket_use_hostnames && app.servers && app.servers[job.hostname] && app.servers[job.hostname].ip) { + // use ip if available, may work better in some setups + url = app.proto + app.servers[job.hostname].ip + ':' + app.port; + } + + $('#d_live_job_log').append( + '
Log Watcher: Connecting to server: ' + url + '...
' + ); + + this.socket = io(url, { + forceNew: true, + transports: config.socket_io_transports || ['websocket'], + reconnection: true, + reconnectionDelay: 1000, + reconnectionDelayMax: 5000, + reconnectionAttempts: 9999, + timeout: 5000 + }); + + this.socket.on('connect', function () { + Debug.trace("JobDetails socket.io connected successfully: " + url); + + // cache this for later + $cont = $('#d_live_job_log'); + + $cont.append( + '
Log Watcher: Connected successfully!
' + ); + + // get auth token from manager server (uses session) + app.api.post('app/get_log_watch_auth', { id: job.id }, function (resp) { + // now request log watch stream on target server + self.socket.emit('watch_job_log', { + token: resp.token, + id: job.id + }); + }); // api.post + + }); + this.socket.on('connect_error', function (err) { + Debug.trace("JobDetails socket.io connect error: " + err); + $('#d_live_job_log').append( + '
Log Watcher: Server Connect Error: ' + err + ' (' + url + ')
' + ); + error_shown = true; + }); + this.socket.on('connect_timeout', function (err) { + Debug.trace("JobDetails socket.io connect timeout"); + if (!error_shown) $('#d_live_job_log').append( + '
Log Watcher: Server Connect Timeout: ' + err + ' (' + url + ')
' + ); + }); + this.socket.on('reconnect', function () { + Debug.trace("JobDetails socket.io reconnected successfully"); + }); + + this.socket.on('log_data', function (lines) { + // received log data, as array of lines + var scroll_y = $cont.scrollTop(); + var scroll_max = Math.max(0, $cont.prop('scrollHeight') - $cont.height()); + var need_scroll = ((scroll_max - scroll_y) <= 10); + + let chunk_data = lines.map(l => l.replace(/' + chunk_data + '
' + ); + + // only show newest 1K chunks + chunk_count++; + if (chunk_count >= 1000) { + $cont.children().first().remove(); + chunk_count--; + } + + if (need_scroll) $cont.scrollTop($cont.prop('scrollHeight')); + }); + }, + + update_live_progress: function (job) { + // update job progress, elapsed time, time remaining, cpu pie, mem pie + if (job.complete && !app.progress) app.showProgress(1.0, "Job is finishing..."); + + // pid may have changed (retry) + $('#d_live_pid').html(job.pid || 'n/a'); + + // elapsed time + var elapsed = Math.floor(Math.max(0, app.epoch - job.time_start)); + $('#d_live_elapsed').html(get_text_from_seconds(elapsed, false, false)); + + // remaining time + var progress = job.progress || 0; + var nice_remain = 'n/a'; + if (job.pending && job.when) { + nice_remain = 'Retry in ' + get_text_from_seconds(Math.max(0, job.when - app.epoch), true, true) + ''; + } + else if ((elapsed >= 10) && (progress > 0) && (progress < 1.0)) { + var sec_remain = Math.floor(((1.0 - progress) * elapsed) / progress); + nice_remain = get_text_from_seconds(sec_remain, false, true); + } + $('#d_live_remain').html(nice_remain); + + // progress pie + if (!job.progress) job.progress = 0; + var progress = Math.min(1, Math.max(0, job.progress)); + var prog_pct = short_float(progress * 100); + + if (prog_pct != this.charts.progress.__cronicle_prog_pct) { + this.charts.progress.__cronicle_prog_pct = prog_pct; + this.charts.progress.config.data.datasets[0].data[0] = prog_pct; + this.charts.progress.config.data.datasets[0].data[1] = 100 - prog_pct; + this.charts.progress.update(); + } + + // progress overlay + var html = ''; + html += '
' + Math.floor(prog_pct) + '%
'; + html += '
Current
'; + $('#d_live_progress_overlay').html(html); + + // cpu pie + if (!job.cpu) job.cpu = {}; + if (!job.cpu.current) job.cpu.current = 0; + var cpu_cur = job.cpu.current; + var cpu_avg = 0; + if (job.cpu.total && job.cpu.count) { + cpu_avg = short_float(job.cpu.total / job.cpu.count); + } + + var jcm = 100; + if (cpu_cur != this.charts.cpu.__cronicle_cpu_cur) { + this.charts.cpu.__cronicle_cpu_cur = cpu_cur; + + this.charts.cpu.config.data.datasets[0].data[0] = Math.min(cpu_cur, jcm); + this.charts.cpu.config.data.datasets[0].data[1] = jcm - Math.min(cpu_cur, jcm); + + this.charts.cpu.config.data.datasets[0].backgroundColor[0] = (cpu_cur < jcm * 0.5) ? this.pie_colors.cool : ((cpu_cur < jcm * 0.75) ? this.pie_colors.warm : this.pie_colors.hot); + + this.charts.cpu.update(); + } + + // live cpu overlay + var html = ''; + html += '
' + short_float(cpu_cur) + '%
'; + html += '
Current
'; + $('#d_live_cpu_overlay').html(html); + + // live cpu legend + var html = ''; + + html += '
MIN
'; + html += '
' + short_float(job.cpu.min || 0) + '%
'; + + html += '
AVERAGE
'; + html += '
' + (cpu_avg || 0) + '%
'; + + html += '
PEAK
'; + html += '
' + short_float(job.cpu.max || 0) + '%
'; + + $('#d_live_cpu_legend').html(html); + + // mem pie + if (!job.mem) job.mem = {}; + if (!job.mem.current) job.mem.current = 0; + var mem_cur = job.mem.current; + var mem_avg = 0; + if (job.mem.total && job.mem.count) { + mem_avg = short_float(job.mem.total / job.mem.count); + } + + var jmm = config.job_memory_max || 1073741824; + if (mem_cur != this.charts.mem.__cronicle_mem_cur) { + this.charts.mem.__cronicle_mem_cur = mem_cur; + + this.charts.mem.config.data.datasets[0].data[0] = Math.min(mem_cur, jmm); + this.charts.mem.config.data.datasets[0].data[1] = jmm - Math.min(mem_cur, jmm); + + this.charts.mem.config.data.datasets[0].backgroundColor[0] = (mem_cur < jmm * 0.5) ? this.pie_colors.cool : ((mem_cur < jmm * 0.75) ? this.pie_colors.warm : this.pie_colors.hot); + + this.charts.mem.update(); + } + + // live mem overlay + var html = ''; + html += '
' + get_text_from_bytes(mem_cur, 10) + '
'; + html += '
Current
'; + $('#d_live_mem_overlay').html(html); + + // live mem legend + var html = ''; + + html += '
MIN
'; + html += '
' + get_text_from_bytes(job.mem.min || 0, 1) + '
'; + + html += '
AVERAGE
'; + html += '
' + get_text_from_bytes(mem_avg || 0, 1) + '
'; + + html += '
PEAK
'; + html += '
' + get_text_from_bytes(job.mem.max || 0, 1) + '
'; + + $('#d_live_mem_legend').html(html); + }, + + jump_to_archive_when_ready: function () { + // make sure archive view is ready (job log may still be uploading) + var self = this; + if (!this.active) return; // user navigated away from page + + app.api.post('app/get_job_details', { id: this.args.id, need_log: 1 }, + function (resp) { + // got it, ready to switch + app.hideProgress(); + Nav.refresh(); + }, + function (err) { + // job not complete yet + if (!app.progress) app.showProgress(1.0, "Job is finishing..."); + // self.jump_timer = setTimeout( self.jump_to_archive_when_ready.bind(self), 1000 ); + } + ); + }, + + find_job: function (id) { + // locate active or pending (retry delay) job + if (!id) id = this.args.id; + var job = app.activeJobs[id]; + + if (!job) { + for (var key in app.activeJobs) { + var temp_job = app.activeJobs[key]; + if (temp_job.pending && (temp_job.id == id)) { + job = temp_job; + break; + } + } + } + + return job; + }, + + onStatusUpdate: function (data) { + // received status update (websocket), update sub-page if needed + if (this.args && (this.args.sub == 'live')) { + if (!app.activeJobs[this.args.id]) { + // check for pending job (retry delay) + var pending_job = null; + for (var key in app.activeJobs) { + var job = app.activeJobs[key]; + if (job.pending && (job.id == this.args.id)) { + pending_job = job; + break; + } + } + + if (pending_job) { + // job switched to pending (retry delay) + if (app.progress) app.hideProgress(); + this.update_live_progress(pending_job); + } + else { + // the live job we were watching just completed, jump to archive view + this.jump_to_archive_when_ready(); + } + } + else { + // job is still active + this.update_live_progress(app.activeJobs[this.args.id]); + } + } + }, + + onResize: function (size) { + // window was resized + var iheight = size.height - 110; + if (this.args.sub == 'live') { + $('#d_live_job_log').css('height', '' + iheight + 'px'); + } + else { + $('#i_arch_job_log').css('height', '' + iheight + 'px'); + } + }, + + onResizeDelay: function (size) { + // called 250ms after latest window resize + // so we can run more expensive redraw operations + }, + + onDeactivate: function () { + // called when page is deactivated + for (var key in this.charts) { + this.charts[key].destroy(); + } + if (this.jump_timer) { + clearTimeout(this.jump_timer); + delete this.jump_timer; + } + if (this.socket) { + this.socket.disconnect(); + delete this.socket; + } + this.charts = {}; + this.div.html(''); + // this.tab.hide(); + return true; + } + +}); diff --git a/htdocs/js/pages/Login.class.js b/htdocs/js/pages/Login.class.js new file mode 100644 index 0000000..5064540 --- /dev/null +++ b/htdocs/js/pages/Login.class.js @@ -0,0 +1,416 @@ +Class.subclass( Page.Base, "Page.Login", { + + onInit: function() { + // called once at page load + // var html = 'Now is the time (LOGIN)'; + // this.div.html( html ); + }, + + onActivate: function(args) { + // page activation + if (app.user) { + // user already logged in + setTimeout( function() { Nav.go(app.navAfterLogin || config.DefaultPage) }, 1 ); + return true; + } + else if (args.u && args.h) { + this.showPasswordResetForm(args); + return true; + } + else if (args.create) { + this.showCreateAccountForm(); + return true; + } + else if (args.recover) { + this.showRecoverPasswordForm(); + return true; + } + + app.setWindowTitle('Login'); + app.showTabBar(false); + + this.div.css({ 'padding-top':'75px', 'padding-bottom':'75px' }); + var html = ''; + // html += ''; + // html += '
'; + + html += '
'; + html += '
User Login
'; + html += '
'; + html += '
'; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += '
Username:
Password:
' + app.get_password_toggle_html() + '
'; + html += '
'; + + html += '
'; + if (config.free_accounts) { + html += ''; + html += ''; + } + html += ''; + html += ''; + html += ''; + html += '
Create Account...
 
Forgot Password...
 
Login
'; + html += '
'; + + // html += ''; + html += '
'; + this.div.html( html ); + + setTimeout( function() { + $( app.getPref('username') ? '#fe_login_password' : '#fe_login_username' ).focus(); + + $('#fe_login_username, #fe_login_password').keypress( function(event) { + if (event.keyCode == '13') { // enter key + event.preventDefault(); + $P().doLogin(); + } + } ); + + }, 1 ); + + return true; + }, + + /*doLoginFormSubmit: function() { + // force login form to submit + $('#f_login')[0].submit(); + }, + + doFrameLogin: function(resp) { + // login from IFRAME redirect + // alert("GOT HERE FROM IFRAME " + JSON.stringify(resp)); + this.tempFrameResp = JSON.parse( JSON.stringify(resp) ); + setTimeout( '$P().doFrameLogin2()', 1 ); + }, + + doFrameLogin2: function() { + // login from IFRAME redirect + var resp = this.tempFrameResp; + delete this.tempFrameResp; + + Debug.trace("IFRAME Response: " + JSON.stringify(resp)); + + if (resp.code) { + return app.doError( resp.description ); + } + + Debug.trace("IFRAME User Login: " + resp.username + ": " + resp.session_id); + + app.clearError(); + app.hideProgress(); + app.doUserLogin( resp ); + + Nav.go( app.navAfterLogin || config.DefaultPage ); + // alert("GOT HERE: " + (app.navAfterLogin || config.DefaultPage) ); + },*/ + + doLogin: function() { + // attempt to log user in + var username = $('#fe_login_username').val().toLowerCase(); + var password = $('#fe_login_password').val(); + + if (username && password) { + app.showProgress(1.0, "Logging in..."); + + app.api.post( 'user/login', { + username: username, + password: password + }, + function(resp, tx) { + Debug.trace("User Login: " + username + ": " + resp.session_id); + + app.hideProgress(); + app.doUserLogin( resp ); + + Nav.go( app.navAfterLogin || config.DefaultPage ); + } ); // post + } + }, + + cancel: function() { + // return to login page + app.clearError(); + Nav.go('Login', true); + }, + + navCreateAccount: function() { + // nav to create account form + app.clearError(); + Nav.go('Login?create=1', true); + }, + + showCreateAccountForm: function() { + // allow user to create a new account + app.setWindowTitle('Create Account'); + app.showTabBar(false); + + this.div.css({ 'padding-top':'75px', 'padding-bottom':'75px' }); + var html = ''; + + html += '
'; + html += '
Create Account
'; + html += '
'; + html += '
'; + + html += get_form_table_row( 'Username:', + '
' + + '' + + '' + + '
' + ); + + html += get_form_table_caption('Choose a unique alphanumeric username for your account.') + + get_form_table_spacer() + + get_form_table_row('Password:', '' + app.get_password_toggle_html()) + + get_form_table_caption('Enter a secure password that you will not forget.') + + get_form_table_spacer() + + get_form_table_row('Full Name:', '') + + get_form_table_caption('This is used for display purposes only.') + + get_form_table_spacer() + + get_form_table_row('Email Address:', '') + + get_form_table_caption('This is used only to recover your password should you lose it.'); + + html += '
'; + html += '
'; + + html += '
'; + html += ''; + html += ''; + html += ''; + html += '
Cancel
 
  Create
'; + html += '
'; + + this.div.html( html ); + + setTimeout( function() { + $( '#fe_ca_username' ).focus(); + app.password_strengthify( '#fe_ca_password' ); + }, 1 ); + }, + + doCreateAccount: function(force) { + // actually create account + app.clearError(); + + var username = trim($('#fe_ca_username').val().toLowerCase()); + var email = trim($('#fe_ca_email').val()); + var full_name = trim($('#fe_ca_fullname').val()); + var password = trim($('#fe_ca_password').val()); + + if (!username.length) { + return app.badField('#fe_ca_username', "Please enter a username for your account."); + } + if (!username.match(/^[\w\.\-]+@?[\w\.\-]+$/)) { + return app.badField('#fe_ca_username', "Please make sure your username contains only alphanumerics, dashes and periods."); + } + if (!email.length) { + return app.badField('#fe_ca_email', "Please enter an e-mail address where you can be reached."); + } + if (!email.match(/^\S+\@\S+$/)) { + return app.badField('#fe_ca_email', "The e-mail address you entered does not appear to be correct."); + } + if (!full_name.length) { + return app.badField('#fe_ca_fullname', "Please enter your first and last names. These are used only for display purposes."); + } + if (!password.length) { + return app.badField('#fe_ca_password', "Please enter a secure password to protect your account."); + } + if (!force && (app.last_password_strength.score < 3)) { + app.confirm( 'Insecure Password Warning', app.get_password_warning(), "Proceed", function(result) { + if (result) $P().doCreateAccount('force'); + } ); + return; + } // insecure password + + Dialog.hide(); + app.showProgress( 1.0, "Creating account..." ); + + app.api.post( 'user/create', { + username: username, + email: email, + password: password, + full_name: full_name + }, + function(resp, tx) { + app.hideProgress(); + app.showMessage('success', "Account created successfully."); + + app.setPref('username', username); + Nav.go( 'Login', true ); + } ); // api.post + }, + + navPasswordRecovery: function() { + // nav to recover password form + app.clearError(); + Nav.go('Login?recover=1', true); + }, + + showRecoverPasswordForm: function() { + // allow user to create a new account + app.setWindowTitle('Forgot Password'); + app.showTabBar(false); + + this.div.css({ 'padding-top':'75px', 'padding-bottom':'75px' }); + var html = ''; + + html += '
'; + html += '
Forgot Password
'; + html += '
'; + html += '
'; + + html += get_form_table_row('Username:', '') + + get_form_table_spacer() + + get_form_table_row('Email Address:', ''); + + html += '
'; + + html += '
Please enter the username and e-mail address associated with your account, and we will send you instructions for resetting your password.
'; + + html += '
'; + + html += '
'; + html += ''; + html += ''; + html += ''; + html += '
Cancel
 
  Send Email
'; + html += '
'; + + this.div.html( html ); + + setTimeout( function() { + $('#fe_pr_username, #fe_pr_email').keypress( function(event) { + if (event.keyCode == '13') { // enter key + event.preventDefault(); + $P().doSendEmail(); + } + } ); + $( '#fe_pr_username' ).focus(); + }, 1 ); + }, + + doSendRecoveryEmail: function() { + // send password recovery e-mail + app.clearError(); + + var username = trim($('#fe_pr_username').val()).toLowerCase(); + var email = trim($('#fe_pr_email').val()); + + if (username.match(/^[\w.-]+$/)) { + if (email.match(/.+\@.+/)) { + Dialog.hide(); + app.showProgress( 1.0, "Sending e-mail..." ); + app.api.post( 'user/forgot_password', { + username: username, + email: email + }, + function(resp, tx) { + app.hideProgress(); + app.showMessage('success', "Password reset instructions sent successfully."); + Nav.go('Login', true); + } ); // api.post + } // good address + else app.badField('#fe_pr_email', "The e-mail address you entered does not appear to be correct."); + } // good username + else app.badField('#fe_pr_username', "The username you entered does not appear to be correct."); + }, + + showPasswordResetForm: function(args) { + // show password reset form + this.recoveryKey = args.h; + + app.setWindowTitle('Reset Password'); + app.showTabBar(false); + + this.div.css({ 'padding-top':'75px', 'padding-bottom':'75px' }); + var html = ''; + + html += '
'; + html += '
Reset Password
'; + html += '
'; + html += '
'; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += '
Username:
New Password:
' + app.get_password_toggle_html() + '
'; + html += '
'; + + html += '
'; + html += ''; + html += '
  Reset Password
'; + html += '
'; + + this.div.html( html ); + + setTimeout( function() { + $( '#fe_reset_password' ).focus(); + $('#fe_reset_password').keypress( function(event) { + if (event.keyCode == '13') { // enter key + event.preventDefault(); + $P().doResetPassword(); + } + } ); + app.password_strengthify( '#fe_reset_password' ); + }, 1 ); + }, + + doResetPassword: function(force) { + // reset password now + var username = $('#fe_reset_username').val().toLowerCase(); + var new_password = $('#fe_reset_password').val(); + var recovery_key = this.recoveryKey; + + if (username && new_password) { + if (!force && (app.last_password_strength.score < 3)) { + app.confirm( 'Insecure Password Warning', app.get_password_warning(), "Proceed", function(result) { + if (result) $P().doResetPassword('force'); + } ); + return; + } // insecure password + + app.showProgress(1.0, "Resetting password..."); + + app.api.post( 'user/reset_password', { + username: username, + key: recovery_key, + new_password: new_password + }, + function(resp, tx) { + Debug.trace("User password was reset: " + username); + + app.hideProgress(); + app.setPref('username', username); + + Nav.go( 'Login', true ); + + setTimeout( function() { + app.showMessage('success', "Your password was reset successfully."); + }, 100 ); + } ); // post + } + }, + + onDeactivate: function() { + // called when page is deactivated + this.div.html( '' ); + return true; + } + +} ); diff --git a/htdocs/js/pages/MyAccount.class.js b/htdocs/js/pages/MyAccount.class.js new file mode 100644 index 0000000..c863095 --- /dev/null +++ b/htdocs/js/pages/MyAccount.class.js @@ -0,0 +1,183 @@ +Class.subclass( Page.Base, "Page.MyAccount", { + + onInit: function() { + // called once at page load + var html = ''; + this.div.html( html ); + }, + + onActivate: function(args) { + // page activation + if (!this.requireLogin(args)) return true; + + if (!args) args = {}; + this.args = args; + + app.setWindowTitle('My Account'); + app.showTabBar(true); + + this.receive_user({ user: app.user }); + + return true; + }, + + receive_user: function(resp, tx) { + var self = this; + var html = ''; + var user = resp.user; + + html += '
'; + html += '
'; + + html += ''; + html += ''; + html += ''; + html += '
'; + + html += ''; + + // user id + html += get_form_table_row( 'Username', '
' + app.username + '
' ); + html += get_form_table_caption( "Your username cannot be changed." ); + html += get_form_table_spacer(); + + // full name + html += get_form_table_row( 'Full Name', '' ); + html += get_form_table_caption( "Your first and last names, used for display purposes only."); + html += get_form_table_spacer(); + + // email + html += get_form_table_row( 'Email Address', '' ); + html += get_form_table_caption( "This is used to generate your profile pic, and to
recover your password if you forget it." ); + html += get_form_table_spacer(); + + var disableIfExternal = user.ext_auth ? "disabled" : " "; + + // current password + html += get_form_table_row('Current Password', `` + app.get_password_toggle_html()); + html += get_form_table_caption( "Enter your current account password to make changes." ); + html += get_form_table_spacer(); + + // reset password + html += get_form_table_row('New Password', `` + app.get_password_toggle_html()); + html += get_form_table_caption( "If you need to change your password, enter the new one here." ); + html += get_form_table_spacer(); + + html += ''; + + html += '
'; + html += '
'; + + html += ''; + html += ''; + html += ''; + html += ''; + html += '
Delete Account...
 
  Save Changes
'; + + html += '
'; + html += ''; + + html += '
'; + // gravar profile image and edit button + html += '
Profile Picture'; + if (app.config.external_users) { + html += '
'; + } + else { + html += '
'; + html += '
Edit Image...
'; + html += '
Image services provided by Gravatar.com.
'; + } + html += '
'; + html += '
'; + + html += '
'; // table wrapper div + + this.div.html( html ); + + setTimeout( function() { + app.password_strengthify( '#fe_ma_new_password' ); + + if (app.config.external_users) { + app.showMessage('warning', "Users are managed by an external system, so you cannot make changes here."); + self.div.find('input').prop('disabled', true); + } + }, 1 ); + }, + + edit_gravatar: function() { + // edit profile pic at gravatar.com + window.open( 'https://en.gravatar.com/connect/' ); + }, + + save_changes: function(force) { + // save changes to user info + app.clearError(); + if (app.config.external_users) { + return app.doError("Users are managed by an external system, so you cannot make changes here."); + } + if (!$('#fe_ma_old_password').val()) return app.badField('#fe_ma_old_password', "Please enter your current account password to make changes."); + + if ($('#fe_ma_new_password').val() && !force && (app.last_password_strength.score < 3)) { + app.confirm( 'Insecure Password Warning', app.get_password_warning(), "Proceed", function(result) { + if (result) $P().save_changes('force'); + } ); + return; + } // insecure password + + app.showProgress( 1.0, "Saving account..." ); + + app.api.post( 'user/update', { + username: app.username, + full_name: trim($('#fe_ma_fullname').val()), + email: trim($('#fe_ma_email').val()), + old_password: $('#fe_ma_old_password').val(), + new_password: $('#fe_ma_new_password').val() + }, + function(resp) { + // save complete + app.hideProgress(); + app.showMessage('success', "Your account settings were updated successfully."); + + $('#fe_ma_old_password').val(''); + $('#fe_ma_new_password').val(''); + + app.user = resp.user; + app.updateHeaderInfo(); + + $('#d_ma_image').css( 'background-image', 'url('+app.getUserAvatarURL(128)+')' ); + } ); + }, + + show_delete_account_dialog: function() { + // show dialog confirming account delete action + var self = this; + + app.clearError(); + if (app.config.external_users) { + return app.doError("Users are managed by an external system, so you cannot make changes here."); + } + if (!$('#fe_ma_old_password').val()) return app.badField('#fe_ma_old_password', "Please enter your current account password."); + + app.confirm( "Delete My Account", "Are you sure you want to permanently delete your user account? There is no way to undo this action, and no way to recover your data.", "Delete", function(result) { + if (result) { + app.showProgress( 1.0, "Deleting Account..." ); + app.api.post( 'user/delete', { + username: app.username, + password: $('#fe_ma_old_password').val() + }, + function(resp) { + // finished deleting, immediately log user out + app.doUserLogout(); + } ); + } + } ); + }, + + onDeactivate: function() { + // called when page is deactivated + // this.div.html( '' ); + return true; + } + +} ); diff --git a/htdocs/js/pages/Schedule.class.js b/htdocs/js/pages/Schedule.class.js new file mode 100644 index 0000000..bacf71e --- /dev/null +++ b/htdocs/js/pages/Schedule.class.js @@ -0,0 +1,2313 @@ +Class.subclass(Page.Base, "Page.Schedule", { + + default_sub: 'events', + + onInit: function () { + // called once at page load + var html = ''; + this.div.html(html); + }, + + onActivate: function (args) { + // page activation + if (!this.requireLogin(args)) return true; + + if (!args) args = {}; + if (!args.sub) args.sub = this.default_sub; + this.args = args; + + app.showTabBar(true); + // this.tab[0]._page_id = Nav.currentAnchor(); + + this.div.addClass('loading'); + this['gosub_' + args.sub](args); + + return true; + }, + + export_schedule: function (args) { + app.api.post('app/export', this, function (resp) { + //app.hideProgress(); + app.show_info(` + Back Up Scheduler


+
Use this output to restore scheduler data later using Import API or storage-cli.js import command
+ `, '', function (result) { + + console.log(result) + + }); + //app.showMessage('success', resp.data); + // self.gosub_servers(self.args); + }); + //app.api.get('app/export?session_id=' + localStorage.session_id ) + }, + + import_schedule: function (args) { + + app.confirm(` Restore Scheduler

+ +
Restore scheduler data. Use output of Export API or storage-cli.js export command. To avoid side effects server and plugin data will not be imported.
+ `, '', "Import", function (result) { + if (result) { + var importData = document.getElementById('conf_import').value; + app.showProgress(1.0, "Importing..."); + app.api.post('app/import', { txt: importData }, function (resp) { + app.hideProgress(); + var resultList = resp.result || [] + var report = '' + var codes = { 0: '✔️', 1: '❌', 2: '⚠️' } + if (resultList.length > 0) { + resultList.forEach(val => { + report += ` + ${codes[val.code]} + ${val.key} + ${val.desc} + ${val.count || ''} + ` + }); + } + + report = report || ' Nothing to Report' + + setTimeout(function () { + Nav.go('Schedule', 'force'); // refresh categories + app.show_info(`
${report}
`, ''); + + }, 50); + + }); + } + }); + }, + + toggle_token: function () { //zzz + if ($('#fe_ee_token').is(':checked')) { + $('#fe_ee_token_label').text("") + if(!this.event.salt) this.event.salt = hex_md5(get_unique_id()).substring(0, 8) + let apiUrl = window.location.origin + '/api/app/run_event?id=' + (this.event.id || 'eventId') + '&post_data=1' + app.api.post('app/get_event_token', this.event, resp => { + $('#fe_ee_token_val').text(resp.token ? ` ${apiUrl}&token=${resp.token}` : "(error)"); + }); + } + else { + this.event.salt = "" + $('#fe_ee_token_label').text("Generate Webhook Url"); + $('#fe_ee_token_val').text(""); + this.event.salt = ""; + } + }, + + toggle_schedule_view: function () { //show/hide event graph + + setTimeout(function () { app.network.moveTo({ scale: 1.2 }); }, 20); + + if ($('#fe_sch_graph').is(':checked') && !app.scheduleAsGraph) { + $('#schedule_table').hide() + $('#schedule_graph').show() + $('#graph_fit_button').show() + app.scheduleAsGraph = true + } else { + $('#schedule_table').show() + $('#schedule_graph').hide() + $('#graph_fit_button').hide() + app.scheduleAsGraph = false; + if(app.network) app.network.unselectAll(); + } + + app.network.fit() + }, + + + + render_schedule_graph: function () { + var sNodes = [] + var sEdges = [] + var catMap = Object.fromEntries(app.categories.map(i => [i.id, i])) + + this.events.forEach((job, index) => { + let jobGroup = job.enabled ? job.category : 'disabled'; + let jobCat = catMap[job.category] + let jobIcon = String.fromCharCode(parseInt(job.graph_icon || 'f111', 16)); + let jobColor = job.enabled ? (jobCat.gcolor || "#3498DB" ) : "lightgray" // #3f7ed5 + sNodes.push({ + id: job.id, + label: ` ${job.title} \n ${jobCat.title}`, + font: `12px lato ${job.enabled ? '#777' : 'lightgray' }`, + group: jobGroup, + shape: 'icon', + icon: {face: "'FontAwesome'", code: jobIcon, color: jobColor } + + }) + if (job.chain) sEdges.push({ from: job.id, to: job.chain, arrows: "to", color: "green", length: 160 }) + if (job.chain_error) sEdges.push({ from: job.id, to: job.chain_error, arrows: "to", color: "red", length: 160 }) + }); + + var sGraph = { nodes: new vis.DataSet(sNodes), edges: new vis.DataSet(sEdges) } + + var options = { + // physics: { + // "barnesHut": { + // "springConstant": 0.1, + // "centralGravity": 0.2, + // "gravitationalConstant": -10000, + // "avoidOverlap": 0.01, + // } + // }, + nodes: { shape: 'box' }, + groups: { disabled: { color: 'lightgray', font: { color: 'gray' } } }, + } + + app.network = new vis.Network(document.getElementById("schedule_graph"), sGraph, options) + + // allow delete event by pressing del key + $(document).keyup(function (e) { + if (e.keyCode == 46) { // delete button pressed + var eventId = app.network.getSelectedNodes()[0] + var idx = $P().events.findIndex(i => i.id === eventId) + if (eventId) $P().delete_event(idx) + } + }) + + // open event edit page on double click + app.network.on("doubleClick", function (params) { + if (params.nodes.length === 1) { + var node = params.nodes[0] + window.open('#Schedule?sub=edit_event&id=' + node, '_self'); + } + }); + + // select nodes on graph on event filtering + // if(app.schedule.length !== this.events.length) app.network.selectNodes(this.events.map(e=>e.id)); + + }, + + gosub_events: function (args) { + // render table of events with filters and search + this.div.removeClass('loading'); + app.setWindowTitle("Scheduled Events"); + + var size = get_inner_window_size(); + var col_width = Math.floor(((size.width * 0.9) + 200) / 8); + var group_by = app.getPref('schedule_group_by'); + var html = ''; + + /* html += this.getSidebarTabs( 'categories', + [ + ['categories', "Categories"], + ['servers', "Servers"], + ['plugins', "Plugins"], + ['users', "Users"] + ] + ); */ + + // presort some stuff for the filter menus + app.categories.sort(function (a, b) { + // return (b.title < a.title) ? 1 : -1; + return a.title.toLowerCase().localeCompare(b.title.toLowerCase()); + }); + app.plugins.sort(function (a, b) { + // return (b.title < a.title) ? 1 : -1; + return a.title.toLowerCase().localeCompare(b.title.toLowerCase()); + }); + + // render table + var cols = [ + '', + 'Event Name', + 'Category', + 'Plugin', + 'Target', + 'Timing', + 'Status', + 'Actions' + ]; + + // apply filters + this.events = []; + app.chained_jobs = {}; + app.event_map = {}; + var g = new graphlib.Graph(); + + for (var idx = 0, len = app.schedule.length; idx < len; idx++) { + var item = app.schedule[idx]; + + // set up graph to detect cycles + g.setNode(item.id); + if (item.chain) g.setEdge(item.id, item.chain) + if (item.chain_error) g.setEdge(item.id, item.chain_error) + + app.event_map[item.id] = item.title; // map for: id -> title + + // check if job is chained by other jobs to display it on tooltip + var niceSchedule = summarize_event_timing(item.timing, item.timezone) + // on succuss or both + if (item.chain) { + var chainData = `${item.title}: ${niceSchedule} ${item.chain == item.chain_error ? '(any)' : '(success)'}
` + if (app.chained_jobs[item.chain]) app.chained_jobs[item.chain] += chainData + else app.chained_jobs[item.chain] = 'Chained by:
' + chainData + } + // on error + if (item.chain_error) { + if (item.chain_error != item.chain) { + var chainData = `${item.title}: ${niceSchedule} (error)
` + if (app.chained_jobs[item.chain_error]) app.chained_jobs[item.chain_error] += chainData + else app.chained_jobs[item.chain_error] = 'Chained by:
' + chainData + } + } + + + // category filter + if (args.category && (item.category != args.category)) continue; + + // plugin filter + if (args.plugin && (item.plugin != args.plugin)) continue; + + // server group filter + if (args.target && (item.target != args.target)) continue; + + // keyword filter + var words = [item.title, item.username, item.notes, item.target].join(' ').toLowerCase(); + if (args.keywords && words.indexOf(args.keywords.toLowerCase()) == -1) continue; + + // enabled filter + if ((args.enabled == 1) && !item.enabled) continue; + if ((args.enabled == -1) && item.enabled) continue; + + this.events.push(copy_object(item)); + } // foreach item in schedule + + // calculate job graph cycles + var cycleWarning = '' + var cycles = graphlib.alg.findCycles(g) // return array of arrays (or empty array) + if (cycles.length) { + cycleWarningTitle = ' ! Schedule contains cycled event chains:
' + cycles.forEach(function (item, index) { + // item.unshift(item[item.length-1]); + cycleWarningTitle += (item.map((e) => app.event_map[e]).join(" ← ") + '
'); + }); + cycleWarning = ` ⚠️ ` + } + + // Scheduled Event page: + + html += '
'; + + html += '
'; + html += `Scheduled Events ${cycleWarning}`; + + var graphChecked = app.scheduleAsGraph ? 'checked' : '' + + html += '
 
'; + + html += '
 
'; + html += '
 
'; + html += '
 
'; + + html += '
 
'; + html += `
` + + html += '
'; + html += '
'; + + + // prep events for sort + this.events.forEach(function (item) { + var cat = item.category ? find_object(app.categories, { id: item.category }) : null; + var group = item.target ? find_object(app.server_groups, { id: item.target }) : null; + var plugin = item.plugin ? find_object(app.plugins, { id: item.plugin }) : null; + + item.category_title = cat ? cat.title : 'Uncategorized'; + item.group_title = group ? group.title : item.target; + item.plugin_title = plugin ? plugin.title : 'No Plugin'; + }); + + // sort events by title ascending + this.events = this.events.sort(function (a, b) { + var key = group_by ? (group_by + '_title') : 'title'; + if (group_by && (a[key].toLowerCase() == b[key].toLowerCase())) key = 'title'; + return a[key].toLowerCase().localeCompare(b[key].toLowerCase()); + // return (b.title < a.title) ? 1 : -1; + }); + + // header center (group by buttons) + var chtml = ''; + chtml += '
'; + chtml += ''; + chtml += ''; + chtml += ''; + chtml += ''; + chtml += '
'; + cols.headerRight = chtml; + + // render table + var self = this; + var last_group = ''; + + var htmlTab = this.getBasicTable(this.events, cols, 'event', function (item, idx) { + var actions = [ + 'Run', + 'Edit', + 'Stats', + 'History', + 'Delete', + // 'Delete' + ]; + + var cat = item.category ? find_object(app.categories, { id: item.category }) : null; + var group = item.target ? find_object(app.server_groups, { id: item.target }) : null; + var plugin = item.plugin ? find_object(app.plugins, { id: item.plugin }) : null; + + var jobs = find_objects(app.activeJobs, { event: item.id }); + var status_html = jobs.length ? ('Running (' + jobs.length + ')') : 'Idle'; + + if (group && item.multiplex) { + group = copy_object(group); + group.multiplex = 1; + } + + // prepare chain info tooltip + // on child + var chainInfo = app.chained_jobs[item.id] ? `  ` : ''; + // on parent + var chain_tooltip = []; // tooltip for chaining parent + if (item.chain) chain_tooltip.push('success: ' + app.event_map[item.chain]) + if (item.chain_error) chain_tooltip.push('error: ' + app.event_map[item.chain_error]) + + // warn if chain/chain_error event is removed but still referenced + var chain_error = ''; + if (item.chain && !app.event_map[item.chain]) chain_error += '' + item.chain + '
'; + if (item.chain_error && !app.event_map[item.chain_error]) chain_error += '' + item.chain_error + '
'; + var chain_error_msg = chain_error ? ` ` : ''; + + var evt_name = self.getNiceEvent(item, col_width, 'float:left', '  '); + if (chain_tooltip.length > 0) evt_name += `  ${chain_error_msg}`; + + var tds = [ + '', + '
' + evt_name + '
', + self.getNiceCategory(cat, col_width), + self.getNicePlugin(plugin, col_width), + self.getNiceGroup(group, item.target, col_width), + summarize_event_timing(item.timing, item.timezone, item.ticks) + chainInfo, + status_html, + actions.join(' | ') + ]; + + if (!item.enabled) tds.className = 'disabled'; + if (cat && !cat.enabled) tds.className = 'disabled'; + if (plugin && !plugin.enabled) tds.className = 'disabled'; + + if (cat && cat.color) { + if (tds.className) tds.className += ' '; else tds.className = ''; + tds.className += cat.color; + } + + // group by + if (group_by) { + var cur_group = item[group_by + '_title']; + if (cur_group != last_group) { + last_group = cur_group; + var insert_html = '
'; + switch (group_by) { + case 'category': insert_html += self.getNiceCategory(cat); break; + case 'plugin': insert_html += self.getNicePlugin(plugin); break; + case 'group': insert_html += self.getNiceGroup(group, item.target); break; + } + insert_html += '
'; + tds.insertAbove = insert_html; + } // group changed + } // group_by + + return tds; + }); + + // table and graph (hide latter by default) + html += ` +
${htmlTab}
+
+ + ` + + html += '
'; + html += '
'; + if (app.hasPrivilege('create_events')) { + html += ''; + html += ''; + html += ''; + html += ''; + } + + // backup/restore buttons - admin only + if (app.isAdmin()) { + html += ''; + html += ''; + + if (app.schedule.length === 0) { // only show import button if there are no scheduled jobs yet + html += ''; + html += ''; + } + } + + html += ''; + html += ``; + html += '
  Add Event...
 
  Random
 
  Backup
 
  Import
  
  Fit
'; + + html += '
'; // padding + // html += ''; // sidebar tabs + + this.div.html(html); + + setTimeout(function () { + $('#fe_sch_keywords').keypress(function (event) { + if (event.keyCode == '13') { // enter key + event.preventDefault(); + $P().set_search_filters(); + } + }); + }, 1); + }, + + change_group_by: function (group_by) { + // change grop by setting and refresh schedule display + app.setPref('schedule_group_by', group_by); + this.gosub_events(this.args); + }, + + change_event_enabled: function (idx) { + // toggle event on / off + var event = this.events[idx]; + event.enabled = event.enabled ? 0 : 1; + + var stub = { + id: event.id, + title: event.title, + enabled: event.enabled, + catch_up: event.catch_up || false + }; + + app.api.post('app/update_event', stub, function (resp) { + app.showMessage('success', "Event '" + event.title + "' has been " + (event.enabled ? 'enabled' : 'disabled') + "."); + }); + }, + + run_event: function (event_idx, e) { + // run event ad-hoc style + var self = this; + var event = (event_idx == 'edit') ? this.event : this.events[event_idx]; + + if (e.shiftKey || e.ctrlKey || e.altKey) { + // allow use to select the "now" time + this.choose_date_time({ + when: time_now(), + title: "Set Current Event Date/Time", + description: "Configure the internal date/time for the event to run immediately. This is the timestamp which the Plugin will see as the current time.", + button: "Run Now", + timezone: event.timezone || app.tz, + + callback: function (new_epoch) { + self.run_event_now(event_idx, new_epoch); + } + }); + } + else this.run_event_now(event_idx); + }, + + run_event_now: function (idx, now) { + // run event ad-hoc style + var event = (idx == 'edit') ? this.event : this.events[idx]; + if (!now) now = time_now(); + + app.api.post('app/run_event', merge_objects(event, { now: now }), function (resp) { + var msg = ''; + if (resp.ids.length > 1) { + // multiple jobs (multiplex) + var num = resp.ids.length; + msg = 'Event "' + event.title + '" has been started (' + num + ' jobs). View their progress on the Home Tab.'; + } + else if (resp.ids.length == 1) { + // single job + var id = resp.ids[0]; + msg = 'Event "' + event.title + '" has been started. Click here to view its progress.'; + } + else { + // queued + msg = 'Event "' + event.title + '" could not run right away, but was queued up. View the queue progress on the Home Tab.'; + } + app.showMessage('success', msg); + }); + }, + + edit_event: function (idx) { + // edit or create new event + if (idx == -1) { + Nav.go('Schedule?sub=new_event'); + return; + } + + // edit existing + var event = this.events[idx]; + Nav.go('Schedule?sub=edit_event&id=' + event.id); + }, + + delete_event: function (idx) { + // delete selected event + var self = this; + var event = (idx == 'edit') ? this.event : this.events[idx]; + + // check for active jobs first + var jobs = find_objects(app.activeJobs, { event: event.id }); + if (jobs.length) return app.doError("Sorry, you cannot delete an event that has active jobs running."); + + var msg = "Are you sure you want to delete the event " + event.title + "?"; + + if (event.queue && app.eventQueue[event.id]) { + msg += " The event's job queue will also be flushed."; + } + else { + msg += " There is no way to undo this action."; + } + + // proceed with delete + app.confirm('Delete Event', msg, "Delete", function (result) { + if (result) { + app.showProgress(1.0, "Deleting Event..."); + app.api.post('app/delete_event', event, function (resp) { + app.hideProgress(); + app.showMessage('success', "Event '" + event.title + "' was deleted successfully."); + + if (idx == 'edit') Nav.go('Schedule?sub=events'); + }); + } + }); + }, + + set_search_filters: function () { + // grab values from search filters, and refresh + var args = this.args; + + args.plugin = $('#fe_sch_plugin').val(); + if (!args.plugin) delete args.plugin; + + args.target = $('#fe_sch_target').val(); + if (!args.target) delete args.target; + + args.category = $('#fe_sch_cat').val(); + if (!args.category) delete args.category; + + args.keywords = $('#fe_sch_keywords').val(); + if (!args.keywords) delete args.keywords; + + args.enabled = $('#fe_sch_enabled').val(); + if (!args.enabled) delete args.enabled; + + Nav.go('Schedule' + compose_query_string(args)); + }, + + gosub_new_event: function (args) { + // create new event + var html = ''; + app.setWindowTitle("Add New Event"); + this.div.removeClass('loading'); + + html += this.getSidebarTabs('new_event', + [ + ['events', "Schedule"], + ['new_event', "Add New Event"] + ] + ); + + html += '
Add New Event
'; + + html += '
'; + html += '
'; + + if (this.event_copy) { + // copied from existing event + this.event = this.event_copy; + delete this.event_copy; + } + else if (config.new_event_template) { + // app has a custom event template + this.event = deep_copy_object(config.new_event_template); + if (!this.event.timezone) this.event.timezone = app.tz; + } + else { + // default blank event + this.event = { + enabled: 1, + params: {}, + timing: { minutes: [0] }, + max_children: 1, + timeout: 3600, + catch_up: 0, + timezone: app.tz + }; + } + + html += this.get_event_edit_html(); + + // buttons at bottom + html += ''; + html += '
'; + html += '
'; + + html += ''; + html += ''; + html += ''; + html += ''; + html += '
Cancel
 
  Create Event
'; + + html += '
'; + + html += '
'; // table wrapper div + html += ''; // sidebar tabs + + this.div.html(html); + + setTimeout(function () { + $('#fe_ee_title').focus(); + }, 1); + }, + + cancel_event_edit: function () { + // cancel edit, nav back to schedule + Nav.go('Schedule'); + }, + + do_random_event: function (force) { + // create random event + app.clearError(); + var event = this.get_random_event(); + if (!event) return; // error + + this.event = event; + + app.showProgress(1.0, "Creating event..."); + app.api.post('app/create_event', event, this.new_event_finish.bind(this)); + }, + + do_new_event: function (force) { + // create new event + app.clearError(); + var event = this.get_event_form_json(); + if (!event) return; // error + + // pro-tip: embed id in title as bracketed prefix + if (event.title.match(/^\[(\w+)\]\s*(.+)$/)) { + event.id = RegExp.$1; + event.title = RegExp.$2; + } + + this.event = event; + + app.showProgress(1.0, "Creating event..."); + app.api.post('app/create_event', event, this.new_event_finish.bind(this)); + }, + + new_event_finish: function (resp) { + // new event created successfully + var self = this; + app.hideProgress(); + + Nav.go('Schedule'); + + setTimeout(function () { + app.showMessage('success', "Event '" + self.event.title + "' was created successfully."); + }, 150); + }, + + gosub_edit_event: function (args) { + // edit event subpage + var event = find_object(app.schedule, { id: args.id }); + if (!event) return app.doError("Could not locate Event with ID: " + args.id); + + // check for autosave recovery + if (app.autosave_event) { + if (args.id == app.autosave_event.id) { + Debug.trace("Recovering autosave data for: " + args.id); + event = app.autosave_event; + } + delete app.autosave_event; + } + + // make local copy so edits don't affect main app list until save + this.event = deep_copy_object(event); + + var html = ''; + app.setWindowTitle("Editing Event \"" + event.title + "\""); + this.div.removeClass('loading'); + + var side_tabs = []; + side_tabs.push(['events', "Schedule"]); + if (app.hasPrivilege('create_events')) side_tabs.push(['new_event', "Add New Event"]); + side_tabs.push(['edit_event', "Edit Event"]); + + html += this.getSidebarTabs('edit_event', side_tabs); + + // html += '
Editing Event “' + event.title + '”
'; + + html += '
'; + html += '
'; + html += 'Editing Event “' + event.title + '”'; + html += ''; + html += ''; + html += '
'; + html += '
'; + html += '
'; + + html += '
'; + html += '
'; + html += ''; + + // Internal ID + if (this.isAdmin()) { + html += get_form_table_row('Event ID', '
' + event.id + '
'); + html += get_form_table_caption("The internal event ID used for API calls. This cannot be changed."); + html += get_form_table_spacer(); + } + + html += this.get_event_edit_html(); + + html += ''; + + html += '
'; + html += '
'; + + html += ''; + // cancel + html += ''; + + // delete + if (app.hasPrivilege('delete_events')) { + html += ''; + html += ''; + } + + // copy + if (app.hasPrivilege('create_events')) { + html += ''; + html += ''; + } + + // run + if (app.hasPrivilege('run_events')) { + html += ''; + html += ''; + } + + // save + html += ''; + html += ''; + html += '
Cancel
 
Delete Event...
 
Copy Event...
 
Run Now
 
  Save Changes
'; + + html += '
'; + html += '
'; + html += '
'; // table wrapper div + + html += ''; // sidebar tabs + + this.div.html(html); + }, + + do_copy_event: function () { + // make copy of event and jump into new workflow + app.clearError(); + + var event = this.get_event_form_json(); + if (!event) return; // error + + delete event.id; + delete event.created; + delete event.modified; + delete event.username; + + event.title = "Copy of " + event.title; + + this.event_copy = event; + Nav.go('Schedule?sub=new_event'); + }, + + run_event_from_edit: function (e) { + // run event in its current (possibly edited, unsaved) state + app.clearError(); + + var event = this.get_event_form_json(); + if (!event) return; // error + + // debug options + if ($("#fe_ee_debug_chain").is(":checked")) { event.chain = ""; } + if ($("#fe_ee_debug_email").is(":checked")) { event.notify_success = ""; event.notify_fail = ""; } + if ($("#fe_ee_debug_webhook").is(":checked")) { event.web_hook = ""; event.web_hook_start = "" } + + this.event = event; + + this.run_event('edit', e); + }, + + do_save_event: function () { + // save changes to existing event + app.clearError(); + + this.old_event = JSON.parse(JSON.stringify(this.event)); + + var event = this.get_event_form_json(); + if (!event) return; // error + + this.event = event; + + app.showProgress(1.0, "Saving event..."); + app.api.post('app/update_event', event, this.save_event_finish.bind(this)); + }, + + save_event_finish: function (resp, tx) { + // existing event saved successfully + var self = this; + var event = this.event; + + app.hideProgress(); + app.showMessage('success', "The event was saved successfully."); + window.scrollTo(0, 0); + + // copy active jobs to array + var jobs = []; + for (var id in app.activeJobs) { + var job = app.activeJobs[id]; + if ((job.event == event.id) && !job.detached) jobs.push(job); + } + + // if the event was disabled and there are running jobs, ask user to abort them + if (this.old_event.enabled && !event.enabled && jobs.length) { + app.confirm('Abort Jobs', "There " + ((jobs.length != 1) ? 'are' : 'is') + " currently still " + jobs.length + " active " + pluralize('job', jobs.length) + " using the disabled event " + event.title + ". Do you want to abort " + ((jobs.length != 1) ? 'these' : 'it') + " now?", "Abort", function (result) { + if (result) { + app.showProgress(1.0, "Aborting " + pluralize('Job', jobs.length) + "..."); + app.api.post('app/abort_jobs', { event: event.id }, function (resp) { + app.hideProgress(); + if (resp.count > 0) { + app.showMessage('success', "The " + pluralize('job', resp.count) + " " + ((resp.count != 1) ? 'were' : 'was') + " aborted successfully."); + } + else { + app.showMessage('warning', "No jobs were aborted. It is likely they completed while the dialog was up."); + } + }); + } // clicked Abort + }); // app.confirm + } // disabled + jobs + else { + // if certain key properties were changed and event has active jobs, ask user to update them + var need_update = false; + var updates = {}; + var keys = ['title', 'timeout', 'retries', 'retry_delay', 'chain', 'chain_error', 'notify_success', 'notify_fail', 'web_hook', 'cpu_limit', 'cpu_sustain', 'memory_limit', 'memory_sustain', 'log_max_size']; + + for (var idx = 0, len = keys.length; idx < len; idx++) { + var key = keys[idx]; + if (event[key] != this.old_event[key]) { + updates[key] = event[key]; + need_update = true; + } + } // foreach key + + // recount active jobs, including detached this time + jobs = []; + for (var id in app.activeJobs) { + var job = app.activeJobs[id]; + if (job.event == event.id) jobs.push(job); + } + + if (need_update && jobs.length) { + app.confirm('Update Jobs', "This event currently has " + jobs.length + " active " + pluralize('job', jobs.length) + ". Do you want to update " + ((jobs.length != 1) ? 'these' : 'it') + " as well?", "Update", function (result) { + if (result) { + app.showProgress(1.0, "Updating " + pluralize('Job', jobs.length) + "..."); + app.api.post('app/update_jobs', { event: event.id, updates: updates }, function (resp) { + app.hideProgress(); + if (resp.count > 0) { + app.showMessage('success', "The " + pluralize('job', resp.count) + " " + ((resp.count != 1) ? 'were' : 'was') + " updated successfully."); + } + else { + app.showMessage('warning', "No jobs were updated. It is likely they completed while the dialog was up."); + } + }); + } // clicked Update + }); // app.confirm + } // jobs need update + } // check for update + + delete this.old_event; + }, + + get_event_edit_html: function () { + // get html for editing a event (or creating a new one) + var html = ''; + var event = this.event; + + // event title + //let evt_tip = event.id ? "" : "pro-tip: embed id in title as bracketed prefix, e.g. [event_id] event_title" + html += get_form_table_row('Event Name', `'); + html += get_form_table_caption("Enter a title for the event, which will be displayed on the main schedule."); + html += get_form_table_spacer(); + + // event enabled + html += get_form_table_row('Schedule', ''); + html += get_form_table_caption("Select whether the event should be enabled or disabled in the schedule."); + html += get_form_table_spacer(); + + // category + app.categories.sort(function (a, b) { + // return (b.title < a.title) ? 1 : -1; + return a.title.toLowerCase().localeCompare(b.title.toLowerCase()); + }); + + html += get_form_table_row('Category', + '' + + '' + + (app.isAdmin() ? '' : '') + + '
« Add New...
' + ); + html += get_form_table_caption("Select a category for the event (this may limit the max concurrent jobs, etc.)"); + html += get_form_table_spacer(); + + // target (server group or individual server) + html += get_form_table_row('Target', + '' + ); + + /*html += get_form_table_row( 'Target', + '' + + '' + + '' + + '
' + );*/ + html += get_form_table_caption( + "Select a target server group or individual server to run the event on." + // "Multiplex means that the event will run on all matched servers simultaneously." + ); + html += get_form_table_spacer(); + + // algo selection + var algo_classes = 'algogroup'; + var target_group = !event.target || find_object(app.server_groups, { id: event.target }); + if (!target_group) algo_classes += ' collapse'; + + var algo_items = [['random', "Random"], ['round_robin', "Round Robin"], ['least_cpu', "Least CPU Usage"], ['least_mem', "Least Memory Usage"], ['prefer_first', "Prefer First (Alphabetically)"], ['prefer_last', "Prefer Last (Alphabetically)"], ['multiplex', "Multiplex"]]; + + html += get_form_table_row(algo_classes, 'Algorithm', ''); + + html += get_form_table_caption(algo_classes, + "Select the desired algorithm for choosing a server from the target group.
" + + "'Multiplex' means that the event will run on all group servers simultaneously." + ); + html += get_form_table_spacer(algo_classes, ''); + + // multiplex stagger + var mp_classes = 'mpgroup'; + if (!event.multiplex || !target_group) mp_classes += ' collapse'; + + var stagger_units = 60; + var stagger = parseInt(event.stagger || 0); + if ((stagger >= 3600) && (stagger % 3600 == 0)) { + // hours + stagger_units = 3600; + stagger = stagger / 3600; + } + else if ((stagger >= 60) && (stagger % 60 == 0)) { + // minutes + stagger_units = 60; + stagger = Math.floor(stagger / 60); + } + else { + // seconds + stagger_units = 1; + } + + // stagger + html += get_form_table_row(mp_classes, 'Stagger', + '' + + '' + + '' + + '
' + ); + html += get_form_table_caption(mp_classes, + "For multiplexed events, optionally stagger the jobs across the servers.
" + + "Each server will delay its launch by a multiple of the specified time." + ); + html += get_form_table_spacer(mp_classes, ''); + + // plugin + app.plugins.sort(function (a, b) { + // return (b.title < a.title) ? 1 : -1; + return a.title.toLowerCase().localeCompare(b.title.toLowerCase()); + }); + + html += get_form_table_row('Plugin', ''); + + // plugin params + html += get_form_table_row('', '
' + this.get_plugin_params_html() + '
'); + html += get_form_table_spacer(); + + // arguments + html += get_form_table_row('Arguments', ``); + html += get_form_table_caption("Specify a list of comma separated arguments. Access via ARG[1-9] env variable or [/ARG] params"); + html += get_form_table_spacer(); + + // timing + var timing = event.timing; + var tmode = ''; + if (!timing) tmode = 'demand'; + else if (timing.years && timing.years.length) tmode = 'custom'; + else if (timing.months && timing.months.length && timing.weekdays && timing.weekdays.length) tmode = 'custom'; + else if (timing.days && timing.days.length && timing.weekdays && timing.weekdays.length) tmode = 'custom'; + else if (timing.months && timing.months.length) tmode = 'yearly'; + else if (timing.weekdays && timing.weekdays.length) tmode = 'weekly'; + else if (timing.days && timing.days.length) tmode = 'monthly'; + else if (timing.hours && timing.hours.length) tmode = 'daily'; + else if (timing.minutes && timing.minutes.length) tmode = 'hourly'; + else if (!num_keys(timing)) tmode = 'hourly'; + + var timing_items = [ + ['demand', 'On Demand'], + ['custom', 'Custom'], + ['yearly', 'Yearly'], + ['monthly', 'Monthly'], + ['weekly', 'Weekly'], + ['daily', 'Daily'], + ['hourly', 'Hourly'] + ]; + + html += get_form_table_row('Timing', + '
' + + '' + + '' + + '' + + '
Timezone: 
' + + '
' + + + '' + + '' + + '' + + '
« Import...
' + + + '
' + ); + + // timing params + this.show_all_minutes = false; + + html += get_form_table_row('', '
' + this.get_timing_params_html(tmode) + '
'); + html += get_form_table_spacer(); + + // show token (admin only) + if (app.user.privileges.admin && event.id) { + html += get_form_table_row('Allow Token', ` + + + + `); + html += get_form_table_caption("Allow invoking this event via token"); + html += get_form_table_spacer(); + } + + // max children + html += get_form_table_row('Concurrency', ''); + html += get_form_table_caption("Select the maximum number of jobs that can run simultaneously."); + html += get_form_table_spacer(); + + // timeout + html += get_form_table_row('Timeout', this.get_relative_time_combo_box('fe_ee_timeout', event.timeout)); + html += get_form_table_caption("Enter the maximum time allowed for jobs to complete, 0 to disable."); + html += get_form_table_spacer(); + + // retries + html += get_form_table_row('Retries', + '' + + '' + + '' + + '' + + '
Delay: ' + this.get_relative_time_combo_box('fe_ee_retry_delay', event.retry_delay, '', true) + '
' + ); + html += get_form_table_caption("Select the number of retries to be attempted before an error is reported."); + html += get_form_table_spacer(); + + // catch-up mode (run all) + // method (interruptable, non-interruptable) + html += get_form_table_row('Misc. Options', + '
' + + '
Automatically run all missed events after server downtime or scheduler/event disabled.
' + + + '
' + + '
Run event as a detached background process that is never interrupted.
' + + + '
' + + '
Jobs will be queued that cannot run immediately.
' + + + '
' + + '
Hide job from common reporting (for maintenance/debug).
' + ); + html += get_form_table_spacer(); + + // reset cursor (only for catch_up and edit mode) + var rc_epoch = normalize_time(time_now(), { sec: 0 }); + if (event.id && app.state && app.state.cursors && app.state.cursors[event.id]) { + rc_epoch = app.state.cursors[event.id]; + } + + var rc_classes = 'rcgroup'; + if (!event.catch_up || !event.id) rc_classes += ' collapse'; + + html += get_form_table_row(rc_classes, 'Time Machine', + '' + + '' + + '' + + '' + + '
 « Reset
' + ); + html += get_form_table_caption(rc_classes, + "Optionally reset the internal clock for this event, to repeat past jobs, or jump over a queue." + ); + html += get_form_table_spacer(rc_classes, ''); + + // event queue max + var eq_classes = 'eqgroup'; + if (!event.queue) eq_classes += ' collapse'; + + html += get_form_table_row(eq_classes, 'Queue Limit', + '' + ); + html += get_form_table_caption(eq_classes, + "Set the maximum number of jobs that can be queued up for this event (or '0' for no limit)." + ); + html += get_form_table_spacer(eq_classes, ''); + + // chain reaction + var sorted_events = app.schedule.sort(function (a, b) { + return a.title.toLowerCase().localeCompare(b.title.toLowerCase()); + }); + + var chain_expanded = !!(event.chain || event.chain_error); + html += get_form_table_row('Chain Reaction', + '
 Chain Options
' + + '
 Chain Options' + + '
Run Event on Success:
' + + '
' + + + '
Run Event on Failure:
' + + '
' + + + '
' + ); + html += get_form_table_caption("Select events to run automatically after this event completes."); + html += get_form_table_spacer(); + + // notification + var notif_expanded = !!(event.notify_success || event.notify_fail || event.web_hook || event.web_hook_start); + html += get_form_table_row('Notification', + '
 Notification Options
' + + '
 Notification Options' + + '
Email on Success:
' + + '
' + + + '
Email on Failure:
' + + '
' + + + '
Web Hook URL (start):
' + + '
' + + '
Web Hook URL (complete):
' + + '
' + + '
' + + '

' + + + '
' + ); + html += get_form_table_caption("Enter one or more e-mail addresses for notification (comma-separated), and optionally a web hook URL."); + html += get_form_table_spacer(); + + // resource limits + var res_expanded = !!(event.memory_limit || event.memory_sustain || event.cpu_limit || event.cpu_sustain || event.log_max_size); + html += get_form_table_row('Limits', + '
 Resource Limits
' + + '
 Resource Limits' + + + '
CPU Limit:
' + + '
' + + '' + + '' + + '' + + '' + + '' + + '
%for' + this.get_relative_time_combo_box('fe_ee_cpu_sustain', event.cpu_sustain, 'fieldset_params_table') + '
' + + + '
Memory Limit:
' + + '
' + + '' + + '' + + '' + + '' + + '' + + '
' + this.get_relative_size_combo_box('fe_ee_memory_limit', event.memory_limit, 'fieldset_params_table') + 'for' + this.get_relative_time_combo_box('fe_ee_memory_sustain', event.memory_sustain, 'fieldset_params_table') + '
' + + + '
Log Size Limit:
' + + '
' + + '' + + '' + + '' + + '
' + this.get_relative_size_combo_box('fe_ee_log_limit', event.log_max_size, 'fieldset_params_table') + '
' + + + '
' + ); + html += get_form_table_caption( + "Optionally set CPU load, memory usage and log size limits for the event." + ); + html += get_form_table_spacer(); + + // graph icon + let faOpts = Object.keys(app.fa_icons).map(k => ``).join("\n") + html += get_form_table_row('Graph Icon', ``); + html += `` + html += get_form_table_caption("Select icon to display event node on graph view"); + html += get_form_table_spacer(); + + // notes + html += get_form_table_row('Notes', ''); + html += get_form_table_caption("Optionally enter notes for the event, which will be included in all e-mail notifications."); + html += get_form_table_spacer(); + + // debugging options (avoid emails/webhooks/history), existing events only + if (event.id) { + html += get_form_table_row('Debug', ` + +
+
+
+
+ + `); + html += get_form_table_caption("Temporarily override chaining if running job manually (debug)"); + html += get_form_table_spacer(); + } // + + + setTimeout(function () { + $P().update_add_remove_me($('#fe_ee_notify_success, #fe_ee_notify_fail')); + }, 1); + + return html; + }, + + set_event_target: function (target) { + // event target has changed (from menu selection) + // hide / show sections as necessary + var target_group = find_object(app.server_groups, { id: target }); + var algo = $('#fe_ee_algo').val(); + + this.setGroupVisible('algo', !!target_group); + this.setGroupVisible('mp', !!target_group && (algo == 'multiplex')); + }, + + set_algo: function (algo) { + // target server algo has changed + // hide / show multiplex stagger as necessary + this.setGroupVisible('mp', (algo == 'multiplex')); + }, + + change_retry_amount: function () { + // user has selected a retry amount from the menu + // adjust the visibility of the retry delay controls accordingly + var retries = parseInt($('#fe_ee_retries').val()); + if (retries) { + if (!$('#td_ee_retry1').hasClass('yup')) { + $('#td_ee_retry1, #td_ee_retry2').css({ display: 'table-cell', opacity: 0 }).fadeTo(250, 1.0, function () { + $(this).addClass('yup'); + }); + } + } + else { + $('#td_ee_retry1, #td_ee_retry2').fadeTo(250, 0.0, function () { + $(this).css({ display: 'none', opacity: 0 }).removeClass('yup'); + }); + } + }, + + show_crontab_import_dialog: function () { + // allow user to paste in crontab syntax to set timing + var self = this; + var html = ''; + + html += '
Use this to import event timing settings from a Crontab expression. This is a string comprising five (or six) fields separated by white space that represents a set of dates/times. Example: 30 4 1 * * (First day of every month at 4:30 AM)
'; + + html += '
' + + // get_form_table_spacer() + + get_form_table_row('Crontab:', '') + + get_form_table_caption("Enter your crontab date/time expression here.") + + '
'; + + app.confirm(' Import from Crontab', html, "Import", function (result) { + app.clearError(); + + if (result) { + var cron_exp = $('#fe_ee_crontab').val().toLowerCase(); + if (!cron_exp) return app.badField('fe_ee_crontab', "Please enter a crontab date/time expression."); + + // validate, convert to timing object + var timing = null; + try { + timing = parse_crontab(cron_exp, $('#fe_ee_title').val()); + } + catch (e) { + return app.badField('fe_ee_crontab', e.toString()); + } + + // hide dialog + Dialog.hide(); + + // replace event timing object + self.event.timing = timing; + + // redraw display + var tmode = ''; + if (timing.years && timing.years.length) tmode = 'custom'; + else if (timing.months && timing.months.length && timing.weekdays && timing.weekdays.length) tmode = 'custom'; + else if (timing.days && timing.days.length && timing.weekdays && timing.weekdays.length) tmode = 'custom'; + else if (timing.months && timing.months.length) tmode = 'yearly'; + else if (timing.weekdays && timing.weekdays.length) tmode = 'weekly'; + else if (timing.days && timing.days.length) tmode = 'monthly'; + else if (timing.hours && timing.hours.length) tmode = 'daily'; + else if (timing.minutes && timing.minutes.length) tmode = 'hourly'; + else if (!num_keys(timing)) tmode = 'hourly'; + + $('#fe_ee_timing').val(tmode); + $('#d_ee_timing_params').html(self.get_timing_params_html(tmode)); + + // and we're done + app.showMessage('success', "Crontab date/time expression was imported successfully."); + + } // user clicked add + }); // app.confirm + + setTimeout(function () { + $('#fe_ee_crontab').focus(); + }, 1); + }, + + show_quick_add_cat_dialog: function () { + // allow user to quickly add a category + var self = this; + var html = ''; + + html += '
Use this to quickly add a new category. Note that you should visit the Admin Categories page later so you can set additional options, add a descripton, etc.
'; + + html += '
' + + // get_form_table_spacer() + + get_form_table_row('Category Title:', '') + + get_form_table_caption("Enter a title for your category here.") + + '
'; + + app.confirm(' Quick Add Category', html, "Add", function (result) { + app.clearError(); + + if (result) { + var cat_title = $('#fe_ee_cat_title').val(); + if (!cat_title) return app.badField('fe_ee_cat_title', "Please enter a title for the category."); + Dialog.hide(); + + var category = {}; + category.title = cat_title; + category.description = ''; + category.max_children = 0; + category.notify_success = ''; + category.notify_fail = ''; + category.web_hook = ''; + category.enabled = 1; + let baseColors = ["#5dade2 ", "#ec7063 ", "#58d68d", "#f4d03f", , "#af7ac5", "#dc7633", "#99a3a4", " #45b39d", "#a93226"] + + category.gcolor = baseColors[(app.categories || []).length % 7 ]; + + app.showProgress(1.0, "Adding category..."); + app.api.post('app/create_category', category, function (resp) { + app.hideProgress(); + app.showMessage('success', "Category was added successfully."); + + // set event to new category + category.id = resp.id; + self.event.category = category.id; + + // due to race conditions with websocket, app.categories may or may not have our new cat at this point + // so add it manually if needed + if (!find_object(app.categories, { id: category.id })) { + app.categories.push(category); + } + + // resort cats for menu rebuild + app.categories.sort(function (a, b) { + // return (b.title < a.title) ? 1 : -1; + return a.title.toLowerCase().localeCompare(b.title.toLowerCase()); + }); + + // rebuild menu and select new cat + $('#fe_ee_cat').html( + '' + + render_menu_options(app.categories, self.event.category, false) + ); + }); // api.post + + } // user clicked add + }); // app.confirm + + setTimeout(function () { + $('#fe_ee_cat_title').focus(); + }, 1); + }, + + rc_get_short_date_time: function (epoch) { + // get short date/time with tz abbrev using moment + var tz = this.event.timezone || app.tz; + // return moment.tz( epoch * 1000, tz).format("MMM D, YYYY h:mm A z"); + return moment.tz(epoch * 1000, tz).format("lll z"); + }, + + rc_click: function () { + // click in 'reset cursor' text field, popup edit dialog + var self = this; + $('#fe_ee_rc_time').blur(); + + if ($('#fe_ee_rc_enabled').is(':checked')) { + var epoch = parseInt($('#fe_ee_rc_time').data('epoch')); + + this.choose_date_time({ + when: epoch, + title: "Set Event Clock", + timezone: this.event.timezone || app.tz, + + callback: function (rc_epoch) { + $('#fe_ee_rc_time').data('epoch', rc_epoch).val(self.rc_get_short_date_time(rc_epoch)); + $('#fe_ee_rc_time').blur(); + } + }); + } + }, + + reset_rc_time_now: function () { + // reset cursor value to now, from click + var rc_epoch = normalize_time(time_now(), { sec: 0 }); + $('#fe_ee_rc_time').data('epoch', rc_epoch).val(this.rc_get_short_date_time(rc_epoch)); + }, + + update_rc_value: function () { + // received state update from server, event cursor may have changed + // only update field if in edit mode, catch_up, and field is disabled + var event = this.event; + + if (event.id && $('#fe_ee_catch_up').is(':checked') && !$('#fe_ee_rc_enabled').is(':checked') && app.state && app.state.cursors && app.state.cursors[event.id]) { + $('#fe_ee_rc_time').data('epoch', app.state.cursors[event.id]).val(this.rc_get_short_date_time(app.state.cursors[event.id])); + } + }, + + toggle_rc_textfield: function (state) { + // set 'disabled' attribute of 'reset cursor' text field, based on checkbox + var event = this.event; + + if (state) { + $('#fe_ee_rc_time').removeAttr('disabled').css('cursor', 'pointer'); + $('#s_ee_rc_reset').fadeTo(250, 1.0); + } + else { + $('#fe_ee_rc_time').attr('disabled', 'disabled').css('cursor', 'default'); + $('#s_ee_rc_reset').fadeTo(250, 0.0); + + // reset value just in case it changed while field was enabled + if (event.id && app.state && app.state.cursors && app.state.cursors[event.id]) { + $('#fe_ee_rc_time').data('epoch', app.state.cursors[event.id]).val(this.rc_get_short_date_time(app.state.cursors[event.id])); + } + } + }, + + change_timezone: function () { + // change timezone setting + var event = this.event; + + // update 'reset cursor' text field to reflect new timezone + var new_cursor = parseInt($('#fe_ee_rc_time').data('epoch')); + if (!new_cursor || isNaN(new_cursor)) { + new_cursor = app.state.cursors[event.id] || normalize_time(time_now(), { sec: 0 }); + } + new_cursor = normalize_time(new_cursor, { sec: 0 }); + + // update timezone + event.timezone = $('#fe_ee_timezone').val(); + this.change_edit_timing_param(); + + // render out new RC date/time + $('#fe_ee_rc_time').data('epoch', new_cursor).val(this.rc_get_short_date_time(new_cursor)); + }, + + parseTicks: function() { + let tickString = $("#fe_ee_ticks").val() + if(tickString) { + let parsed = tickString.trim().replace(/\s+/g, ' ').split(/[\,\|]/).map(e=>{ + let format = e.trim().length > 8 ? 'YYYY-MM-DD HH:mm A' : 'HH:mm A'; + let t = moment(e, format); + return t._isValid ? t.format(e.trim().length > 8 ? 'YYYY-MM-DD HH:mm' : 'HH:mm') : null; + }).filter(e => e).join(" | ") + $("#fe_ee_parsed_ticks").text(' parsed ticks: ' + parsed); + } else { + $("#fe_ee_parsed_ticks").text(''); + } + }, + + ticks_add_now: function() { + let currTicks = $("#fe_ee_ticks").val() + let tme = moment().add(1, 'minute').format('YYYY-MM-DD HH:mm') + if(currTicks.trim()) { + $("#fe_ee_ticks").val(currTicks + ', ' + tme); + } + else { $("#fe_ee_ticks").val(tme)} + this.parseTicks(); + }, + + change_edit_timing: function () { + // change edit timing mode + var event = this.event; + var timing = event.timing; + var tmode = $('#fe_ee_timing').val(); + var dargs = get_date_args(time_now()); + + // clean up timing object, add sane defaults for the new tmode + switch (tmode) { + case 'demand': + timing = false; + event.timing = false; + break; + + case 'custom': + if (!timing) timing = event.timing = {}; + break; + + case 'yearly': + if (!timing) timing = event.timing = {}; + delete timing.years; + if (!timing.months) timing.months = []; + if (!timing.months.length) timing.months.push(dargs.mon); + + if (!timing.days) timing.days = []; + if (!timing.days.length) timing.days.push(dargs.mday); + + if (!timing.hours) timing.hours = []; + if (!timing.hours.length) timing.hours.push(dargs.hour); + break; + + case 'weekly': + if (!timing) timing = event.timing = {}; + delete timing.years; + delete timing.months; + delete timing.days; + if (!timing.weekdays) timing.weekdays = []; + if (!timing.weekdays.length) timing.weekdays.push(dargs.wday); + + if (!timing.hours) timing.hours = []; + if (!timing.hours.length) timing.hours.push(dargs.hour); + break; + + case 'monthly': + if (!timing) timing = event.timing = {}; + delete timing.years; + delete timing.months; + delete timing.weekdays; + if (!timing.days) timing.days = []; + if (!timing.days.length) timing.days.push(dargs.mday); + + if (!timing.hours) timing.hours = []; + if (!timing.hours.length) timing.hours.push(dargs.hour); + break; + + case 'daily': + if (!timing) timing = event.timing = {}; + delete timing.years; + delete timing.months; + delete timing.weekdays; + delete timing.days; + if (!timing.hours) timing.hours = []; + if (!timing.hours.length) timing.hours.push(dargs.hour); + break; + + case 'hourly': + if (!timing) timing = event.timing = {}; + delete timing.years; + delete timing.months; + delete timing.weekdays; + delete timing.days; + delete timing.hours; + break; + } + + if (timing) { + if (!timing.minutes) timing.minutes = []; + if (!timing.minutes.length) timing.minutes.push(0); + } + + $('#d_ee_timing_params').html(this.get_timing_params_html(tmode)); + }, + + get_timing_params_html: function (tmode) { + // get timing param editor html + var html = ''; + var event = this.event; + var timing = event.timing; + + html += '
 Timing Details
'; + html += '
 Timing Details'; + + // html += '
Timing Details'; + + // only show years in custom mode + if (tmode == 'custom') { + html += '
Years
'; + var year = (new Date()).getFullYear(); + html += '
' + this.get_timing_checkbox_set('year', [year, year + 1, year + 2, year + 3, year + 4, year + 5, year + 6, year + 7, year + 8, year + 9, year + 10], timing.years || [], true) + '
'; + } // years + + if (tmode.match(/(custom|yearly)/)) { + // show months + html += '
Months
'; + html += '
' + this.get_timing_checkbox_set('month', _months, timing.months || []) + '
'; + } // months + + if (tmode.match(/(custom|weekly)/)) { + // show weekdays + var wday_items = [[0, 'Sunday'], [1, 'Monday'], [2, 'Tuesday'], [3, 'Wednesday'], + [4, 'Thursday'], [5, 'Friday'], [6, 'Saturday']]; + + html += '
Weekdays
'; + html += '
' + this.get_timing_checkbox_set('weekday', wday_items, timing.weekdays || []) + '
'; + } // weekdays + + if (tmode.match(/(custom|yearly|monthly)/)) { + // show days of month + var mday_items = []; + for (var idx = 1; idx < 32; idx++) { + var num_str = '' + idx; + var num_label = num_str + _number_suffixes[parseInt(num_str.substring(num_str.length - 1))]; + mday_items.push([idx, num_label]); + } + + html += '
Days of the Month
'; + html += '
' + this.get_timing_checkbox_set('day', mday_items, timing.days || []) + '
'; + } // days + + if (tmode.match(/(custom|yearly|monthly|weekly|daily)/)) { + // show hours + var hour_items = []; + for (var idx = 0; idx < 24; idx++) { + hour_items.push([idx, _hour_names[idx].toUpperCase()]); + } + + html += '
Hours
'; + html += '
' + this.get_timing_checkbox_set('hour', hour_items, timing.hours || []) + '
'; + } // hours + + // always show minutes (if timing is enabled) + if (timing) { + var min_items = []; + for (var idx = 0; idx < 60; idx += this.show_all_minutes ? 1 : 5) { + var num_str = ':' + ((idx < 10) ? '0' : '') + idx; + min_items.push([idx, num_str, (idx % 5 == 0) ? '' : 'plain']); + } // minutes + + html += '
Minutes'; + html += ' (' + (this.show_all_minutes ? 'Show Less' : 'Show All') + ')'; + html += '
'; + + html += '
'; + html += this.get_timing_checkbox_set('minute', min_items, timing.minutes || [], function (idx) { + var num_str = ':' + ((idx < 10) ? '0' : '') + idx; + return ([idx, num_str, (idx % 5 == 0) ? '' : 'plain']); + }); + html += '
'; + } + + // summary + html += '
The event will run:
'; + html += '
' + summarize_event_timing(timing, event.timezone).replace(/(every\s+minute)/i, '$1'); + // add event webhook info if "On demand" is selected + let apiUrl = '/api/app/run_event?id=' + (event.id || 'eventId') + '&post_data=1&api_key=API_KEY' + let webhookInfo = !timing ? '

[webhook]
' + window.location.origin + apiUrl : ' ' + html += webhookInfo + '
'; + + html += '
'; + html += '
Choose when and how often the event should run.
'; + + html += ` +

+
Extra Ticks:   + + check   | + add timestamp +
Optional extra minute ticks (extends regular schedule). Separate by comma or pipe.
Use HH:mm fromat for daily recurring or YYYY-MM-DD HH:mm for onetime ticks
+
+
+
+ ` + + setTimeout(function () { + $('.ccbox_timing').mouseup(function () { + // need another delay for event listener race condition + // we want this to happen LAST, after the CSS classes are updated + setTimeout(function () { + $P().change_edit_timing_param(); + }, 1); + }); + }, 1); + + return html; + }, + + toggle_show_all_minutes: function () { + // toggle showing every minutes from 0 - 59, to just the 5s + this.show_all_minutes = !this.show_all_minutes; + var tmode = $('#fe_ee_timing').val(); + $('#d_ee_timing_params').html(this.get_timing_params_html(tmode)); + }, + + change_edit_timing_param: function () { + // edit timing param has changed, refresh entire timing block + // rebuild entire event.timing object from scratch + var event = this.event; + event.timing = {}; + var timing = event.timing; + + // if tmode is demand, wipe timing object + if ($('#fe_ee_timing').val() == 'demand') { + event.timing = false; + timing = false; + } + + $('.ccbox_timing_year.checked').each(function () { + if (this.id.match(/_(\d+)$/)) { + var year = parseInt(RegExp.$1); + if (!timing.years) timing.years = []; + timing.years.push(year); + } + }); + + $('.ccbox_timing_month.checked').each(function () { + if (this.id.match(/_(\d+)$/)) { + var month = parseInt(RegExp.$1); + if (!timing.months) timing.months = []; + timing.months.push(month); + } + }); + + $('.ccbox_timing_weekday.checked').each(function () { + if (this.id.match(/_(\d+)$/)) { + var weekday = parseInt(RegExp.$1); + if (!timing.weekdays) timing.weekdays = []; + timing.weekdays.push(weekday); + } + }); + + $('.ccbox_timing_day.checked').each(function () { + if (this.id.match(/_(\d+)$/)) { + var day = parseInt(RegExp.$1); + if (!timing.days) timing.days = []; + timing.days.push(day); + } + }); + + $('.ccbox_timing_hour.checked').each(function () { + if (this.id.match(/_(\d+)$/)) { + var hour = parseInt(RegExp.$1); + if (!timing.hours) timing.hours = []; + timing.hours.push(hour); + } + }); + + $('.ccbox_timing_minute.checked').each(function () { + if (this.id.match(/_(\d+)$/)) { + var minute = parseInt(RegExp.$1); + if (!timing.minutes) timing.minutes = []; + timing.minutes.push(minute); + } + }); + + // update summary + $('#d_ee_timing_summary').html(summarize_event_timing(timing, event.timezone).replace(/(every\s+minute)/i, '$1')); + }, + + get_timing_checkbox_set: function (name, items, values, auto_add) { + // render html for set of color label checkboxes for timing category + var html = ''; + + // make sure all items are arrays + for (var idx = 0, len = items.length; idx < len; idx++) { + var item = items[idx]; + if (!isa_array(item)) items[idx] = [item, item]; + } + + // add unknown values to items array + if (auto_add) { + var is_callback = !!(typeof (auto_add) == 'function'); + var added = 0; + for (var idx = 0, len = values.length; idx < len; idx++) { + var value = values[idx]; + var found = false; + for (var idy = 0, ley = items.length; idy < ley; idy++) { + if (items[idy][0] == value) { found = true; idy = ley; } + } // foreach item + if (!found) { + items.push(is_callback ? auto_add(value) : [value, value]); + added++; + } + } // foreach value + + // resort items + if (added) { + items = items.sort(function (a, b) { + return a[0] - b[0]; + }); + } + } // auto_add + + for (var idx = 0, len = items.length; idx < len; idx++) { + var item = items[idx]; + var checked = !!(values.indexOf(item[0]) > -1); + var classes = []; + if (checked) classes.push("checked"); + classes.push("ccbox_timing"); + classes.push("ccbox_timing_" + name); + if (item[2]) classes.push(item[2]); + + if (html) html += ' '; + html += app.get_color_checkbox_html("ccbox_timing_" + name + '_' + item[0], item[1], classes.join(' ')); + // NOTE: the checkbox id isn't currently even used + + // if (break_every && (((idx + 1) % break_every) == 0)) html += '
'; + } // foreach item + + return html; + }, + + change_edit_plugin: function () { + // switch plugins, set default params, refresh param editor + var event = this.event; + var plugin_id = $('#fe_ee_plugin').val(); + event.plugin = plugin_id; + event.params = {}; + + if (plugin_id) { + var plugin = find_object(app.plugins, { id: plugin_id }); + if (plugin && plugin.params && plugin.params.length) { + for (var idx = 0, len = plugin.params.length; idx < len; idx++) { + var param = plugin.params[idx]; + event.params[param.id] = param.value; + } + } + } + + this.refresh_plugin_params(); + }, + + get_plugin_params_html: function () { + // get plugin param editor html + var html = ''; + var event = this.event; + var params = event.params; + + if (event.plugin) { + var plugin = find_object(app.plugins, { id: event.plugin }); + if (plugin && plugin.params && plugin.params.length) { + + html += '
 Plugin Parameters
'; + html += '
 Plugin Parameters'; + + // html += '
Plugin Parameters'; + + for (var idx = 0, len = plugin.params.length; idx < len; idx++) { + var param = plugin.params[idx]; + var value = (param.id in params) ? params[param.id] : param.value; + switch (param.type) { + + case 'text': + if (param.id == 'wf_args') { + html += ` + + ` + break; + } + + html += '
' + param.title + '
'; + html += '
'; + break; + + case 'textarea': + let ta_height = parseInt(param.rows) * 15; + html += '
' + param.title + '
'; + html += '
'; + let privs = app.user.privileges; + let canEdit = privs.admin || privs.edit_events || privs.create_events; + + // adding code eitor for script area + if (param.id == "script") { + let lang = params.lang || params.default_lang || 'shell'; + if (lang == 'java') { lang = 'text/x-java' } + if (lang == 'scala') { lang = 'text/x-scala' } + if (lang == 'csharp') { lang = 'text/x-csharp' } + let theme = params.theme || 'default'; + html += ` + + `} + + break; + + case 'checkbox': + html += '
'; + if (param.id == 'tty') { + //console.log(event) + html += ` + ` + } + break; + + case 'select': + if (param.id == 'wf_event') { + let wf_events = render_menu_options(app.schedule.filter(e => e.plugin != 'workflow'), value, true).trim() + html += ` +
+ ` + break; + } + if (param.id == 'wf_type') { + html += ` + + ` + html += '
' + param.title + '
'; + html += '
'; + break; + } + + html += '
' + param.title + '
'; + html += '
'; + + if (param.id == 'lang') { + html += ` + + ` + } + if (param.id == 'theme') { + html += ` + + ` + } + + break; + + case 'hidden': + // no visible UI + break; + + } // switch type + } // foreach param + + html += '
'; + html += '
Select the plugin parameters for the event.
'; + html += ` + + ` + + } // plugin params + else { + html += '
The selected plugin has no editable parameters.
'; + } + } + else { + html += '
Select a plugin to edit its parameters.
'; + } + + return html; + }, + + refresh_plugin_params: function () { + // redraw plugin param area after change + $('#d_ee_plugin_params').html(this.get_plugin_params_html()); + }, + + get_random_event: function () { + let tools = { randArray: (array) => array[Math.floor(Math.random() * array.length)] } + let left = "admiring;adoring;affectionate;agitated;amazing;angry;awesome;beautiful;blissful;bold;boring;brave;busy;charming;clever;cool;compassionate;competent;condescending;confident;cranky;crazy;dazzling;determined;distracted;dreamy;eager;ecstatic;elastic;elated;elegant;eloquent;epic;exciting;fervent;festive;flamboyant;focused;friendly;frosty;funny;gallant;gifted;goofy;gracious;great;happy;hardcore;heuristic;hopeful;hungry;infallible;inspiring;interesting;intelligent;jolly;jovial;keen;kind;laughing;loving;lucid;magical;mystifying;modest;musing;naughty;nervous;nice;nifty;nostalgic;objective;optimistic;peaceful;pedantic;pensive;practical;priceless;quirky;quizzical;recursing;relaxed;reverent;romantic;sad;serene;sharp;silly;sleepy;stoic;strange;stupefied;suspicious;sweet;tender;thirsty;trusting;unruffled;upbeat;vibrant;vigilant;vigorous;wizardly;wonderful;xenodochial;youthful;zealous;zen".split(";"); + let right = "albattani;allen;almeida;antonelli;agnesi;archimedes;ardinghelli;aryabhata;austin;babbage;banach;banzai;bardeen;bartik;bassi;beaver;bell;benz;bhabha;bhaskara;black;blackburn;blackwell;bohr;booth;borg;bose;bouman;boyd;brahmagupta;brattain;brown;buck;burnell;cannon;carson;cartwright;carver;cerf;chandrasekhar;chaplygin;chatelet;chatterjee;chebyshev;cohen;chaum;clarke;colden;cori;cray;curran;curie;darwin;davinci;dewdney;dhawan;diffie;dijkstra;dirac;driscoll;dubinsky;easley;edison;einstein;elbakyan;elgamal;elion;ellis;engelbart;euclid;euler;faraday;feistel;fermat;fermi;feynman;franklin;gagarin;galileo;galois;ganguly;gates;gauss;germain;goldberg;goldstine;goldwasser;golick;goodall;gould;greider;grothendieck;haibt;hamilton;haslett;hawking;hellman;heisenberg;hermann;herschel;hertz;heyrovsky;hodgkin;hofstadter;hoover;hopper;hugle;hypatia;ishizaka;jackson;jang;jemison;jennings;jepsen;johnson;joliot;jones;kalam;kapitsa;kare;keldysh;keller;kepler;khayyam;khorana".split(";") + let event_title = tools.randArray(left) + '_' + tools.randArray(right); + return { + "enabled": 1, + params: { + "duration": Math.floor(Math.random() * 20), + "progress": 1, + "burn": tools.randArray([0, 1]), + "action": tools.randArray(["Success", "Success", "Success", "Success", "Failure", "Crash"]), + "secret": "Will not be shown in Event UI", + }, + "timing": { "minutes": [Math.floor(Math.random() * 60)], "hours": [Math.floor(Math.random() * 24)] }, + "max_children": 1, "timeout": 3600, "catch_up": 0, "queue_max": 1000, "timezone": "America/New_York", + "plugin": "testplug", + "title": event_title, + "category": "general", + "target": "allgrp", "algo": "random", "multiplex": 0, "stagger": 0, "retries": 0, + "retry_delay": 0, "detached": 0, "queue": 0, "chain": "", "chain_error": "", "notify_success": "", "notify_fail": "", "web_hook": "", "cpu_limit": 0, "cpu_sustain": 0, + "memory_limit": 0, "memory_sustain": 0, "log_max_size": 0, "notes": "Randomly Generated Job", + "session_id": localStorage.session_id, + } + + }, + + get_event_form_json: function (quiet) { + // get event elements from form, used for new or edit + var event = this.event; + + // event title + event.title = trim($('#fe_ee_title').val()); + if (!event.title) return quiet ? false : app.badField('fe_ee_title', "Please enter a title for the event."); + + // event enabled + event.enabled = $('#fe_ee_enabled').is(':checked') ? 1 : 0; + + // event silent + event.silent = $('#fe_ee_silent').is(':checked') ? 1 : 0; + //graph icon + event.graph_icon = $('#fe_ee_graph_icon').val() || 'f111'; + //args + event.args = $('#fe_ee_args').val() + event.ticks = $('#fe_ee_ticks').val() + + // category + event.category = $('#fe_ee_cat').val(); + if (!event.category) return quiet ? false : app.badField('fe_ee_cat', "Please select a Category for the event."); + + // target (server group or individual server) + event.target = $('#fe_ee_target').val(); + + // algo / multiplex / stagger + event.algo = $('#fe_ee_algo').val(); + event.multiplex = (event.algo == 'multiplex') ? 1 : 0; + if (event.multiplex) { + event.stagger = parseInt($('#fe_ee_stagger').val()) * parseInt($('#fe_ee_stagger_units').val()); + if (isNaN(event.stagger)) return quiet ? false : app.badField('fe_ee_stagger', "Please enter a number of seconds to stagger by."); + } + else { + event.stagger = 0; + } + + // plugin + event.plugin = $('#fe_ee_plugin').val(); + if (!event.plugin) return quiet ? false : app.badField('fe_ee_plugin', "Please select a Plugin for the event."); + + // plugin params + event.params = {}; + var plugin = find_object(app.plugins, { id: event.plugin }); + if (plugin && plugin.params && plugin.params.length) { + for (var idx = 0, len = plugin.params.length; idx < len; idx++) { + var param = plugin.params[idx]; + switch (param.type) { + case 'text': + case 'textarea': + case 'select': + event.params[param.id] = $('#fe_ee_pp_' + param.id).val(); + break; + + case 'hidden': + // Special case: Always set this to the plugin default value + event.params[param.id] = param.value; + break; + + case 'checkbox': + event.params[param.id] = $('#fe_ee_pp_' + param.id).is(':checked') ? 1 : 0; + break; + } // switch type + } // foreach param + } // plugin params + + // timezone + event.timezone = $('#fe_ee_timezone').val(); + + // max children + event.max_children = parseInt($('#fe_ee_max_children').val()); + + // timeout + event.timeout = parseInt($('#fe_ee_timeout').val()) * parseInt($('#fe_ee_timeout_units').val()); + if (isNaN(event.timeout)) return quiet ? false : app.badField('fe_ee_timeout', "Please enter an integer value for the event timeout."); + if (event.timeout < 0) return quiet ? false : app.badField('fe_ee_timeout', "Please enter a positive integer for the event timeout."); + + // retries + event.retries = parseInt($('#fe_ee_retries').val()); + event.retry_delay = parseInt($('#fe_ee_retry_delay').val()) * parseInt($('#fe_ee_retry_delay_units').val()); + if (isNaN(event.retry_delay)) return quiet ? false : app.badField('fe_ee_retry_delay', "Please enter an integer value for the event retry delay."); + if (event.retry_delay < 0) return quiet ? false : app.badField('fe_ee_retry_delay', "Please enter a positive integer for the event retry delay."); + + // catch-up mode (run all) + event.catch_up = $('#fe_ee_catch_up').is(':checked') ? 1 : 0; + + // method (interruptable, non-interruptable) + event.detached = $('#fe_ee_detached').is(':checked') ? 1 : 0; + + // event queue + event.queue = $('#fe_ee_queue').is(':checked') ? 1 : 0; + event.queue_max = parseInt($('#fe_ee_queue_max').val() || "0"); + if (isNaN(event.queue_max)) return quiet ? false : app.badField('fe_ee_queue_max', "Please enter an integer value for the event queue max."); + if (event.queue_max < 0) return quiet ? false : app.badField('fe_ee_queue_max', "Please enter a positive integer for the event queue max."); + + // chain reaction + event.chain = $('#fe_ee_chain').val(); + event.chain_error = $('#fe_ee_chain_error').val(); + + // cursor reset + if (event.id && event.catch_up && $('#fe_ee_rc_enabled').is(':checked')) { + var new_cursor = parseInt($('#fe_ee_rc_time').data('epoch')); + if (!new_cursor || isNaN(new_cursor)) return quiet ? false : app.badField('fe_ee_rc_time', "Please enter a valid date/time for the new event time."); + event['reset_cursor'] = normalize_time(new_cursor, { sec: 0 }); + } + else delete event['reset_cursor']; + + // notification + event.notify_success = $('#fe_ee_notify_success').val(); + event.notify_fail = $('#fe_ee_notify_fail').val(); + event.web_hook = $('#fe_ee_web_hook').val(); + event.web_hook_start = $('#fe_ee_web_hook_start').val(); + event.web_hook_error = $('#fe_ee_web_hook_error').is(':checked') ? 1 : 0; + + // cpu limit + if ($('#fe_ee_cpu_enabled').is(':checked')) { + event.cpu_limit = parseInt($('#fe_ee_cpu_limit').val()); + if (isNaN(event.cpu_limit)) return quiet ? false : app.badField('fe_ee_cpu_limit', "Please enter an integer value for the CPU limit."); + if (event.cpu_limit < 0) return quiet ? false : app.badField('fe_ee_cpu_limit', "Please enter a positive integer for the CPU limit."); + + event.cpu_sustain = parseInt($('#fe_ee_cpu_sustain').val()) * parseInt($('#fe_ee_cpu_sustain_units').val()); + if (isNaN(event.cpu_sustain)) return quiet ? false : app.badField('fe_ee_cpu_sustain', "Please enter an integer value for the CPU sustain period."); + if (event.cpu_sustain < 0) return quiet ? false : app.badField('fe_ee_cpu_sustain', "Please enter a positive integer for the CPU sustain period."); + } + else { + event.cpu_limit = 0; + event.cpu_sustain = 0; + } + + // mem limit + if ($('#fe_ee_memory_enabled').is(':checked')) { + event.memory_limit = parseInt($('#fe_ee_memory_limit').val()) * parseInt($('#fe_ee_memory_limit_units').val()); + if (isNaN(event.memory_limit)) return quiet ? false : app.badField('fe_ee_memory_limit', "Please enter an integer value for the memory limit."); + if (event.memory_limit < 0) return quiet ? false : app.badField('fe_ee_memory_limit', "Please enter a positive integer for the memory limit."); + + event.memory_sustain = parseInt($('#fe_ee_memory_sustain').val()) * parseInt($('#fe_ee_memory_sustain_units').val()); + if (isNaN(event.memory_sustain)) return quiet ? false : app.badField('fe_ee_memory_sustain', "Please enter an integer value for the memory sustain period."); + if (event.memory_sustain < 0) return quiet ? false : app.badField('fe_ee_memory_sustain', "Please enter a positive integer for the memory sustain period."); + } + else { + event.memory_limit = 0; + event.memory_sustain = 0; + } + + // log file size limit + if ($('#fe_ee_log_enabled').is(':checked')) { + event.log_max_size = parseInt($('#fe_ee_log_limit').val()) * parseInt($('#fe_ee_log_limit_units').val()); + if (isNaN(event.log_max_size)) return quiet ? false : app.badField('fe_ee_log_limit', "Please enter an integer value for the log size limit."); + if (event.log_max_size < 0) return quiet ? false : app.badField('fe_ee_log_limit', "Please enter a positive integer for the log size limit."); + } + else { + event.log_max_size = 0; + } + + // notes + event.notes = trim($('#fe_ee_notes').val()); + + return event; + }, + + onDataUpdate: function (key, value) { + // recieved data update (websocket), see if sub-page cares about it + switch (key) { + case 'schedule': + if (this.args.sub == 'events') this.gosub_events(this.args); + break; + + case 'state': + if (this.args.sub == 'edit_event') this.update_rc_value(); + break; + } + }, + + onStatusUpdate: function (data) { + // received status update (websocket), update sub-page if needed + if (data.jobs_changed && (this.args.sub == 'events')) this.gosub_events(this.args); + }, + + onResizeDelay: function (size) { + // called 250ms after latest window resize + // so we can run more expensive redraw operations + // if (this.args.sub == 'events') this.gosub_events(this.args); + }, + + leavesub_edit_event: function (args) { + // special hook fired when leaving edit_event sub-page + // try to save edited state of event in mem cache + if (this.event_copy) return; // in middle of edit --> copy operation + + var event = this.get_event_form_json(true); // quiet mode + if (event) { + app.autosave_event = event; + } + }, + + onDeactivate: function () { + // called when page is deactivated + // this.div.html( '' ); + if(app.network) app.network.unselectAll(); + + // allow sub-page to hook deactivate + if (this.args && this.args.sub && this['leavesub_' + this.args.sub]) { + this['leavesub_' + this.args.sub](this.args); + } + + return true; + } + +}); diff --git a/htdocs/js/pages/admin/APIKeys.js b/htdocs/js/pages/admin/APIKeys.js new file mode 100644 index 0000000..005996e --- /dev/null +++ b/htdocs/js/pages/admin/APIKeys.js @@ -0,0 +1,343 @@ +// Cronicle Admin Page -- API Keys + +Class.add( Page.Admin, { + + gosub_api_keys: function(args) { + // show API Key list + app.setWindowTitle( "API Keys" ); + this.div.addClass('loading'); + app.api.post( 'app/get_api_keys', copy_object(args), this.receive_keys.bind(this) ); + }, + + receive_keys: function(resp) { + // receive all API Keys from server, render them sorted + this.lastAPIKeysResp = resp; + + var html = ''; + this.div.removeClass('loading'); + + var size = get_inner_window_size(); + var col_width = Math.floor( ((size.width * 0.9) + 200) / 7 ); + + if (!resp.rows) resp.rows = []; + + // sort by title ascending + this.api_keys = resp.rows.sort( function(a, b) { + return a.title.toLowerCase().localeCompare( b.title.toLowerCase() ); + } ); + + html += this.getSidebarTabs( 'api_keys', + [ + ['activity', "Activity Log"], + ['conf_keys', "Config Keys"], + ['api_keys', "API Keys"], + ['categories', "Categories"], + ['plugins', "Plugins"], + ['servers', "Servers"], + ['users', "Users"] + ] + ); + + var cols = ['App Title', 'API Key', 'Status', 'Author', 'Created', 'Actions']; + + html += '
'; + + html += '
'; + html += 'API Keys'; + html += '
'; + html += '
'; + + var self = this; + html += this.getBasicTable( this.api_keys, cols, 'key', function(item, idx) { + var actions = [ + 'Edit', + 'Delete' + ]; + return [ + '
' + self.getNiceAPIKey(item, true, col_width) + '
', + '
' + item.key + '
', + item.active ? ' Active' : ' Suspended', + self.getNiceUsername(item.username, true, col_width), + ''+get_nice_date(item.created, true)+'', + actions.join(' | ') + ]; + } ); + + html += '
'; + html += '
'; + html += ''; + html += '
  Add API Key...
'; + + html += '
'; // padding + html += '
'; // sidebar tabs + + this.div.html( html ); + }, + + edit_api_key: function(idx) { + // jump to edit sub + if (idx > -1) Nav.go( '#Admin?sub=edit_api_key&id=' + this.api_keys[idx].id ); + else Nav.go( '#Admin?sub=new_api_key' ); + }, + + delete_api_key: function(idx) { + // delete key from search results + this.api_key = this.api_keys[idx]; + this.show_delete_api_key_dialog(); + }, + + gosub_new_api_key: function(args) { + // create new API Key + var html = ''; + app.setWindowTitle( "New API Key" ); + this.div.removeClass('loading'); + + html += this.getSidebarTabs( 'new_api_key', + [ + ['activity', "Activity Log"], + ['conf_keys', "Config Keys"], + ['api_keys', "API Keys"], + ['new_api_key', "New API Key"], + ['categories', "Categories"], + ['plugins', "Plugins"], + ['servers', "Servers"], + ['users', "Users"] + ] + ); + + html += '
New API Key
'; + + html += '
'; + html += '
'; + + this.api_key = { privileges: {}, key: get_unique_id() }; + + html += this.get_api_key_edit_html(); + + // buttons at bottom + html += ''; + + html += '
'; + html += '
'; + + html += ''; + html += ''; + html += ''; + + html += ''; + html += '
Cancel
 
  Create Key
'; + + html += '
'; + html += '
'; // table wrapper div + + html += ''; // sidebar tabs + + this.div.html( html ); + + setTimeout( function() { + $('#fe_ak_title').focus(); + }, 1 ); + }, + + cancel_api_key_edit: function() { + // cancel editing API Key and return to list + Nav.go( 'Admin?sub=api_keys' ); + }, + + do_new_api_key: function(force) { + // create new API Key + app.clearError(); + var api_key = this.get_api_key_form_json(); + if (!api_key) return; // error + + if (!api_key.title.length) { + return app.badField('#fe_ak_title', "Please enter an app title for the new API Key."); + } + + this.api_key = api_key; + + app.showProgress( 1.0, "Creating API Key..." ); + app.api.post( 'app/create_api_key', api_key, this.new_api_key_finish.bind(this) ); + }, + + new_api_key_finish: function(resp) { + // new API Key created successfully + app.hideProgress(); + + Nav.go('Admin?sub=edit_api_key&id=' + resp.id); + + setTimeout( function() { + app.showMessage('success', "The new API Key was created successfully."); + }, 150 ); + }, + + gosub_edit_api_key: function(args) { + // edit API Key subpage + this.div.addClass('loading'); + app.api.post( 'app/get_api_key', { id: args.id }, this.receive_key.bind(this) ); + }, + + receive_key: function(resp) { + // edit existing API Key + var html = ''; + this.api_key = resp.api_key; + + app.setWindowTitle( "Editing API Key \"" + (this.api_key.title) + "\"" ); + this.div.removeClass('loading'); + + html += this.getSidebarTabs( 'edit_api_key', + [ + ['activity', "Activity Log"], + ['conf_keys', "Config Keys"], + ['api_keys', "API Keys"], + ['edit_api_key', "Edit API Key"], + ['categories', "Categories"], + ['plugins', "Plugins"], + ['servers', "Servers"], + ['users', "Users"] + ] + ); + + html += '
Editing API Key “' + (this.api_key.title) + '”
'; + + html += '
'; + html += '
'; + html += ''; + + html += this.get_api_key_edit_html(); + + html += ''; + + html += '
'; + html += '
'; + + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += '
Cancel
 
Delete Key...
 
  Save Changes
'; + + html += '
'; + html += '
'; + html += '
'; // table wrapper div + + html += ''; // sidebar tabs + + this.div.html( html ); + }, + + do_save_api_key: function() { + // save changes to api key + app.clearError(); + var api_key = this.get_api_key_form_json(); + if (!api_key) return; // error + + this.api_key = api_key; + + app.showProgress( 1.0, "Saving API Key..." ); + app.api.post( 'app/update_api_key', api_key, this.save_api_key_finish.bind(this) ); + }, + + save_api_key_finish: function(resp, tx) { + // new API Key saved successfully + app.hideProgress(); + app.showMessage('success', "The API Key was saved successfully."); + window.scrollTo( 0, 0 ); + }, + + show_delete_api_key_dialog: function() { + // show dialog confirming api key delete action + var self = this; + app.confirm( 'Delete API Key', "Are you sure you want to permanently delete the API Key \""+this.api_key.title+"\"? There is no way to undo this action.", 'Delete', function(result) { + if (result) { + app.showProgress( 1.0, "Deleting API Key..." ); + app.api.post( 'app/delete_api_key', self.api_key, self.delete_api_key_finish.bind(self) ); + } + } ); + }, + + delete_api_key_finish: function(resp, tx) { + // finished deleting API Key + var self = this; + app.hideProgress(); + + Nav.go('Admin?sub=api_keys', 'force'); + + setTimeout( function() { + app.showMessage('success', "The API Key '"+self.api_key.title+"' was deleted successfully."); + }, 150 ); + }, + + get_api_key_edit_html: function() { + // get html for editing an API Key (or creating a new one) + var html = ''; + var api_key = this.api_key; + + // API Key + html += get_form_table_row( 'API Key', ' « Generate Random' ); + html += get_form_table_caption( "The API Key string is used to authenticate API calls." ); + html += get_form_table_spacer(); + + // status + html += get_form_table_row( 'Status', '' ); + html += get_form_table_caption( "'Disabled' means that the API Key remains in the system, but it cannot be used for any API calls." ); + html += get_form_table_spacer(); + + // title + html += get_form_table_row( 'App Title', '' ); + html += get_form_table_caption( "Enter the title of the application that will be using the API Key."); + html += get_form_table_spacer(); + + // description + html += get_form_table_row('App Description', ''); + html += get_form_table_caption( "Optionally enter a more detailed description of the application." ); + html += get_form_table_spacer(); + + // privilege list + var priv_html = ''; + for (var idx = 0, len = config.privilege_list.length; idx < len; idx++) { + var priv = config.privilege_list[idx]; + if (priv.id != 'admin') { + var has_priv = !!api_key.privileges[ priv.id ]; + priv_html += '
'; + priv_html += ''; + priv_html += ''; + priv_html += '
'; + } + } + html += get_form_table_row( 'Privileges', priv_html ); + html += get_form_table_caption( "Select which privileges the API Key should have." ); + html += get_form_table_spacer(); + + return html; + }, + + get_api_key_form_json: function() { + // get api key elements from form, used for new or edit + var api_key = this.api_key; + + api_key.key = $('#fe_ak_key').val(); + api_key.active = $('#fe_ak_status').val(); + api_key.title = $('#fe_ak_title').val(); + api_key.description = $('#fe_ak_desc').val(); + + if (!api_key.key.length) { + return app.badField('#fe_ak_key', "Please enter an API Key string, or generate a random one."); + } + + for (var idx = 0, len = config.privilege_list.length; idx < len; idx++) { + var priv = config.privilege_list[idx]; + api_key.privileges[ priv.id ] = $('#fe_ak_priv_'+priv.id).is(':checked') ? 1 : 0; + } + + return api_key; + }, + + generate_key: function() { + // generate random api key + $('#fe_ak_key').val( get_unique_id() ); + } + +}); diff --git a/htdocs/js/pages/admin/Activity.js b/htdocs/js/pages/admin/Activity.js new file mode 100644 index 0000000..22c01df --- /dev/null +++ b/htdocs/js/pages/admin/Activity.js @@ -0,0 +1,286 @@ +// Cronicle Admin Page -- Activity Log + +Class.add( Page.Admin, { + + activity_types: { + '^cat': ' Category', + '^group': ' Group', + '^plugin': ' Plugin', + // '^apikey': ' API Key', + '^apikey': ' API Key', + '^confkey': ' Config', + '^event': ' Event', + '^user': '  User', + 'server': ' Server', + '^job': ' Job', + '^state': ' Scheduler', // mdi-lg + '^error': ' Error', + '^warning': ' Warning', + '^restore' : ' Restore', + '^backup' : ' Backup', + }, + + gosub_activity: function(args) { + // show activity log + app.setWindowTitle( "Activity Log" ); + + if (!args.offset) args.offset = 0; + if (!args.limit) args.limit = 25; + app.api.post( 'app/get_activity', copy_object(args), this.receive_activity.bind(this) ); + }, + + receive_activity: function(resp) { + // receive page of activity from server, render it + this.lastActivityResp = resp; + // hide warnings and debug runs + if(resp.rows) {resp.rows = resp.rows.filter(item => item.action != 'job_complete_debug' && item.code != 255) } + + var html = ''; + this.div.removeClass('loading'); + + html += this.getSidebarTabs( 'activity', + [ + ['activity', "Activity Log"], + ['conf_keys', "Config Keys"], + ['api_keys', "API Keys"], + ['categories', "Categories"], + ['plugins', "Plugins"], + ['servers', "Servers"], + ['users', "Users"] + ] + ); + + this.events = []; + if (resp.rows) this.events = resp.rows; + + var cols = ['Date/Time', 'Type', 'Description', 'Username', 'IP Address', 'Actions']; + + html += '
'; + + html += '
'; + html += 'Activity Log'; + // html += '
'; + html += '
'; + + var self = this; + html += this.getPaginatedTable( resp, cols, 'item', function(item, idx) { + // figure out icon first + if (!item.action) item.action = 'unknown'; + + var item_type = ''; + for (var key in self.activity_types) { + var regexp = new RegExp(key); + if (item.action.match(regexp)) { + item_type = self.activity_types[key]; + break; + } + } + + // compose nice description + var desc = ''; + var actions = []; + var color = ''; + + switch (item.action) { + + // categories + case 'cat_create': + desc = 'New category created: ' + item.cat.title + ''; + break; + case 'cat_update': + desc = 'Category updated: ' + item.cat.title + ''; + break; + case 'cat_delete': + desc = 'Category deleted: ' + item.cat.title + ''; + break; + + // groups + case 'group_create': + desc = 'New server group created: ' + item.group.title + ''; + break; + case 'group_update': + desc = 'Server group updated: ' + item.group.title + ''; + break; + case 'group_delete': + desc = 'Server group deleted: ' + item.group.title + ''; + break; + + // plugins + case 'plugin_create': + desc = 'New Plugin created: ' + item.plugin.title + ''; + break; + case 'plugin_update': + desc = 'Plugin updated: ' + item.plugin.title + ''; + break; + case 'plugin_delete': + desc = 'Plugin deleted: ' + item.plugin.title + ''; + break; + + // api keys + case 'apikey_create': + desc = 'New API Key created: ' + item.api_key.title + ' (Key: ' + item.api_key.key + ')'; + actions.push( 'Edit Key' ); + break; + case 'apikey_update': + desc = 'API Key updated: ' + item.api_key.title + ' (Key: ' + item.api_key.key + ')'; + actions.push( 'Edit Key' ); + break; + case 'apikey_delete': + desc = 'API Key deleted: ' + item.api_key.title + ' (Key: ' + item.api_key.key + ')'; + break; + + // config keys + case 'confkey_create': + desc = 'Config created: ' + item.conf_key.title + ' : ' + item.conf_key.key; + actions.push( 'Edit Config' ); + break; + case 'confkey_update': + desc = 'Config updated: ' + item.conf_key.title + ' : ' + item.conf_key.key; + actions.push( 'Edit Config' ); + break; + case 'confkey_delete': + desc = 'Config deleted: ' + item.conf_key.title + ' : ' + item.conf_key.key; + break; + + // events + case 'event_create': + desc = 'New event added: ' + item.event.title + ''; + desc += " (" + summarize_event_timing(item.event.timing, item.event.timezone) + ")"; + actions.push( 'Edit Event' ); + break; + case 'event_update': + desc = 'Event updated: ' + item.event.title + ''; + actions.push( 'Edit Event' ); + break; + case 'event_delete': + desc = 'Event deleted: ' + item.event.title + ''; + break; + + // users + case 'user_create': + desc = 'New user account created: ' + item.user.username + " (" + item.user.full_name + ")"; + actions.push( 'Edit User' ); + break; + case 'user_update': + desc = 'User account updated: ' + item.user.username + " (" + item.user.full_name + ")"; + actions.push( 'Edit User' ); + break; + case 'user_delete': + desc = 'User account deleted: ' + item.user.username + " (" + item.user.full_name + ")"; + break; + case 'user_login': + desc = "User logged in: " + item.user.username + " (" + item.user.full_name + ")"; + break; + + // servers + case 'add_server': // legacy + case 'server_add': // current + desc = 'Server '+(item.manual ? 'manually ' : '')+'added to cluster: ' + item.hostname + ''; + break; + case 'remove_server': // legacy + case 'server_remove': // current + desc = 'Server '+(item.manual ? 'manually ' : '')+'removed from cluster: ' + item.hostname + ''; + break; + case 'manager_server': // legacy + case 'server_manager': // current + desc = 'Server has become manager: ' + item.hostname + ''; + break; + + case 'server_restart': + desc = 'Server restarted: ' + item.hostname + ''; + break; + case 'server_shutdown': + desc = 'Server shut down: ' + item.hostname + ''; + break; + + case 'server_sigterm': + desc = 'Server shut down (sigterm): ' + item.hostname + ''; + break; + + case 'server_disable': + desc = 'Lost connectivity to server: ' + item.hostname + ''; + color = 'yellow'; + break; + case 'server_enable': + desc = 'Reconnected to server: ' + item.hostname + ''; + break; + + // jobs + case 'job_run': + var event = find_object( app.schedule, { id: item.event } ) || { title: 'Unknown Event' }; + desc = 'Job #'+item.id+' ('+event.title+') manually started'; + actions.push( 'Job Details' ); + break; + case 'job_complete': + var event = find_object( app.schedule, { id: item.event } ) || { title: 'Unknown Event' }; + if (!item.code) { + desc = 'Job #'+item.id+' ('+event.title+') on server '+item.hostname.replace(/\.[\w\-]+\.\w+$/, '')+' completed successfully'; + } + else { + desc = 'Job #'+item.id+' ('+event.title+') on server '+item.hostname.replace(/\.[\w\-]+\.\w+$/, '')+' failed with error: ' + encode_entities(item.description || 'Unknown Error').replace(/\x1B\[[0-?]*[ -/]*[@-~]/g, ""); + if (desc.match(/\n/)) desc = desc.split(/\n/).shift() + "..."; + color = 'red'; + } + actions.push( 'Job Details' ); + break; + case 'job_failure': + desc = 'Job #'+item.job.id+' ('+item.job.event_title+') on server '+item.job.hostname.replace(/\.[\w\-]+\.\w+$/, '')+' failed with error: ' + encode_entities(item.job.description || 'Unknown Error').replace(/\x1B\[[0-?]*[ -/]*[@-~]/g, ""); + if (desc.match(/\n/)) desc = desc.split(/\n/).shift() + "..."; + color = 'red'; + + actions.push( 'Job Details' ); + break; + case 'job_delete': + var event = find_object( app.schedule, { id: item.event } ) || { title: 'Unknown Event' }; + desc = 'Job #'+item.id+' ('+event.title+') manually deleted'; + break; + + // scheduler + case 'state_update': + desc = 'Scheduler manager switch was ' + (item.enabled ? 'enabled' : 'disabled') + ''; + break; + + // errors + case 'error': + desc = encode_entities( item.description ); + color = 'red'; + break; + + // warnings + case 'warning': + desc = encode_entities( item.description ); + color = 'yellow'; + break; + + // restore (Import) + case 'restore': + desc = JSON.stringify(item.info, null, 2).replaceAll('"', ""); + break; + + // backup (Export) + case 'backup': + desc = '' + break; + + } // action + + var tds = [ + '
' + get_nice_date_time( item.epoch || 0, false, true ) + '
', + '
' + item_type + '
', + '
' + desc + '
', + '
' + self.getNiceUsername(item, true) + '
', + (item.ip || 'n/a').replace(/^\:\:ffff\:(\d+\.\d+\.\d+\.\d+)$/, '$1'), + '
' + actions.join(' | ') + '
' + ]; + if (color) tds.className = color; + + return tds; + } ); + + html += '
'; // padding + html += ''; // sidebar tabs + + this.div.html( html ); + } + +}); \ No newline at end of file diff --git a/htdocs/js/pages/admin/Categories.js b/htdocs/js/pages/admin/Categories.js new file mode 100644 index 0000000..cdb6e35 --- /dev/null +++ b/htdocs/js/pages/admin/Categories.js @@ -0,0 +1,506 @@ +// Cronicle Admin Page -- Categories + +Class.add( Page.Admin, { + + gosub_categories: function(args) { + // show category list + this.div.removeClass('loading'); + app.setWindowTitle( "Categories" ); + + var size = get_inner_window_size(); + var col_width = Math.floor( ((size.width * 0.9) + 200) / 5 ); + + var html = ''; + + html += this.getSidebarTabs( 'categories', + [ + ['activity', "Activity Log"], + ['conf_keys', "Config Keys"], + ['api_keys', "API Keys"], + ['categories', "Categories"], + ['plugins', "Plugins"], + ['servers', "Servers"], + ['users', "Users"] + ] + ); + + var cols = ['Title', 'Description', 'Assigned Events', 'Max Concurrent', 'Actions']; + + html += '
'; + + html += '
'; + html += 'Event Categories'; + // html += '
'; + html += '
'; + + // sort by title ascending + this.categories = app.categories.sort( function(a, b) { + // return (b.title < a.title) ? 1 : -1; + return a.title.toLowerCase().localeCompare( b.title.toLowerCase() ); + } ); + + // render table + var self = this; + html += this.getBasicTable( this.categories, cols, 'category', function(cat, idx) { + var actions = [ + 'Edit', + 'Delete' + ]; + + var cat_events = find_objects( app.schedule, { category: cat.id } ); + var num_events = cat_events.length; + + var tds = [ + '
' + self.getNiceCategory(cat, col_width) + '
', + '
' + (cat.description || '(No description)') + '
', + num_events ? commify( num_events ) : '(None)', + cat.max_children ? commify(cat.max_children) : '(No limit)', + actions.join(' | ') + ]; + + if (cat && cat.color) { + if (tds.className) tds.className += ' '; else tds.className = ''; + tds.className += cat.color; + } + + if (!cat.enabled) { + if (tds.className) tds.className += ' '; else tds.className = ''; + tds.className += 'disabled'; + } + + return tds; + } ); + + html += '
'; + html += '
'; + html += ''; + html += '
  Add Category...
'; + + html += '
'; // padding + html += ''; // sidebar tabs + + this.div.html( html ); + }, + + edit_category: function(idx) { + // jump to edit sub + if (idx > -1) Nav.go( '#Admin?sub=edit_category&id=' + this.categories[idx].id ); + else Nav.go( '#Admin?sub=new_category' ); + }, + + delete_category: function(idx) { + // delete key from search results + this.category = this.categories[idx]; + this.show_delete_category_dialog(); + }, + + gosub_new_category: function(args) { + // create new Category + var html = ''; + app.setWindowTitle( "New Category" ); + this.div.removeClass('loading'); + + html += this.getSidebarTabs( 'new_category', + [ + ['activity', "Activity Log"], + ['conf_keys', "Config Keys"], + ['api_keys', "API Keys"], + ['categories', "Categories"], + ['new_category', "New Category"], + ['plugins', "Plugins"], + ['servers', "Servers"], + ['users', "Users"] + ] + ); + + html += '
Add New Category
'; + + html += '
'; + html += '
'; + + this.category = { + title: "", + description: "", + max_children: 0, + enabled: 1 + }; + + html += this.get_category_edit_html(); + + // buttons at bottom + html += ''; + + html += '
'; + html += '
'; + + html += ''; + html += ''; + html += ''; + + html += ''; + html += '
Cancel
 
  Add Category
'; + + html += '
'; + html += '
'; // table wrapper div + + html += ''; // sidebar tabs + + this.div.html( html ); + + setTimeout( function() { + $('#fe_ec_title').focus(); + }, 1 ); + }, + + cancel_category_edit: function() { + // cancel editing category and return to list + Nav.go( 'Admin?sub=categories' ); + }, + + do_new_category: function(force) { + // create new category + app.clearError(); + var category = this.get_category_form_json(); + if (!category) return; // error + + // pro-tip: embed id in title as bracketed prefix + if (category.title.match(/^\[(\w+)\]\s*(.+)$/)) { + category.id = RegExp.$1; + category.title = RegExp.$2; + } + + this.category = category; + + app.showProgress( 1.0, "Creating category..." ); + app.api.post( 'app/create_category', category, this.new_category_finish.bind(this) ); + }, + + new_category_finish: function(resp) { + // new Category created successfully + app.hideProgress(); + + // Can't nav to edit_category yet, websocket may not have received update yet + // Nav.go('Admin?sub=edit_category&id=' + resp.id); + Nav.go('Admin?sub=categories'); + + setTimeout( function() { + app.showMessage('success', "The new category was created successfully."); + }, 150 ); + }, + + gosub_edit_category: function(args) { + // edit existing Category + var html = ''; + this.category = find_object( app.categories, { id: args.id } ); + + app.setWindowTitle( "Editing Category \"" + (this.category.title) + "\"" ); + this.div.removeClass('loading'); + + html += this.getSidebarTabs( 'edit_category', + [ + ['activity', "Activity Log"], + ['conf_keys', "Config Keys"], + ['api_keys', "API Keys"], + ['categories', "Categories"], + ['edit_category', "Edit Category"], + ['plugins', "Plugins"], + ['servers', "Servers"], + ['users', "Users"] + ] + ); + + html += '
Editing Category “' + (this.category.title) + '”
'; + + html += '
'; + html += '
'; + html += ''; + + html += this.get_category_edit_html(); + + html += ''; + + html += '
'; + html += '
'; + + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += '
Cancel
 
Delete Category...
 
  Save Changes
'; + + html += '
'; + html += '
'; + html += '
'; // table wrapper div + + html += ''; // sidebar tabs + + this.div.html( html ); + }, + + do_save_category: function() { + // save changes to category + app.clearError(); + var category = this.get_category_form_json(); + if (!category) return; // error + + this.category = category; + + app.showProgress( 1.0, "Saving category..." ); + app.api.post( 'app/update_category', category, this.save_category_finish.bind(this) ); + }, + + save_category_finish: function(resp, tx) { + // new category saved successfully + var self = this; + var category = this.category; + + app.hideProgress(); + app.showMessage('success', "The category was saved successfully."); + window.scrollTo( 0, 0 ); + + // copy active jobs to array + var jobs = []; + for (var id in app.activeJobs) { + var job = app.activeJobs[id]; + if ((job.category == category.id) && !job.detached) jobs.push( job ); + } + + // if the cat was disabled and there are running jobs, ask user to abort them + if (!category.enabled && jobs.length) { + app.confirm( 'Abort Jobs', "There " + ((jobs.length != 1) ? 'are' : 'is') + " currently still " + jobs.length + " active " + pluralize('job', jobs.length) + " using the disabled category "+category.title+". Do you want to abort " + ((jobs.length != 1) ? 'these' : 'it') + " now?", "Abort", function(result) { + if (result) { + app.showProgress( 1.0, "Aborting " + pluralize('Job', jobs.length) + "..." ); + app.api.post( 'app/abort_jobs', { category: category.id }, function(resp) { + app.hideProgress(); + if (resp.count > 0) { + app.showMessage('success', "The " + pluralize('job', resp.count) + " " + ((resp.count != 1) ? 'were' : 'was') + " aborted successfully."); + } + else { + app.showMessage('warning', "No jobs were aborted. It is likely they completed while the dialog was up."); + } + } ); + } // clicked Abort + } ); // app.confirm + } // disabled + jobs + }, + + show_delete_category_dialog: function() { + // show dialog confirming category delete action + var self = this; + var category = this.category; + var cat = this.category; + + // check for events first + var cat_events = find_objects( app.schedule, { category: cat.id } ); + var num_events = cat_events.length; + if (num_events) return app.doError("Sorry, you cannot delete a category that has events assigned to it."); + + // proceed with delete + var self = this; + app.confirm( 'Delete Category', "Are you sure you want to delete the category "+cat.title+"? There is no way to undo this action.", "Delete", function(result) { + if (result) { + app.showProgress( 1.0, "Deleting Category..." ); + app.api.post( 'app/delete_category', cat, self.delete_category_finish.bind(self) ); + } + } ); + }, + + delete_category_finish: function(resp, tx) { + // finished deleting category + var self = this; + app.hideProgress(); + + Nav.go('Admin?sub=categories', 'force'); + + setTimeout( function() { + app.showMessage('success', "The category '"+self.category.title+"' was deleted successfully."); + }, 150 ); + }, + + get_category_edit_html: function() { + // get html for editing a category (or creating a new one) + var html = ''; + var category = this.category; + var cat = this.category; + + // Internal ID + if (cat.id && this.isAdmin()) { + html += get_form_table_row( 'Category ID', '
' + cat.id + '
' ); + html += get_form_table_caption( "The internal Category ID used for API calls. This cannot be changed." ); + html += get_form_table_spacer(); + } + + // title + html += get_form_table_row('Category Title:', '') + + get_form_table_caption("Enter a title for the category, short and sweet.") + + get_form_table_spacer(); + + // cat enabled + html += get_form_table_row( 'Active', '' ); + html += get_form_table_caption( "Select whether events in this category should be enabled or disabled in the schedule." ); + html += get_form_table_spacer(); + + // description + html += get_form_table_row('Description:', '') + + get_form_table_caption("Optionally enter a description for the category.") + + get_form_table_spacer(); + + // max concurrent + html += get_form_table_row('Max Concurrent:', '') + + get_form_table_caption("Select the maximum number of jobs allowed to run concurrently in this category."); + html += get_form_table_spacer(); + + // color + var current_color = cat.color || 'plain'; + var swatch_html = ''; + var colors = ['plain', 'red', 'green', 'blue', 'skyblue', 'yellow', 'purple', 'orange']; + for (var idx = 0, len = colors.length; idx < len; idx++) { + var color = colors[idx]; + swatch_html += '
'; + } + swatch_html += '
'; + + html += get_form_table_row( 'Highlight Color', swatch_html ); + html += get_form_table_caption( "Optionally select a highlight color for the category, which will show on the schedule." ); + html += get_form_table_spacer(); + + // default notification options + var notif_expanded = !!(cat.notify_success || cat.notify_fail || cat.web_hook); + html += get_form_table_row( 'Notification', + '
 Default Notification Options
' + + '
 Default Notification Options' + + '
Default Email on Success:
' + + '
' + + + '
Default Email on Failure:
' + + '
' + + + '
Default Web Hook URL:
' + + '
' + + '
' + ); + html += get_form_table_caption( "Optionally enter default e-mail addresses for notification, and/or a web hook URL.
Note that events can override any of these notification settings." ); + html += get_form_table_spacer(); + + // default resource limits + var res_expanded = !!(cat.memory_limit || cat.memory_sustain || cat.cpu_limit || cat.cpu_sustain || cat.log_max_size); + html += get_form_table_row( 'Limits', + '
 Default Resource Limits
' + + '
 Default Resource Limits' + + + '
Default CPU Limit:
' + + '
' + + '' + + '' + + '' + + '' + + '' + + '
%for' + this.get_relative_time_combo_box( 'fe_ec_cpu_sustain', cat.cpu_sustain, 'fieldset_params_table' ) + '
' + + + '
Default Memory Limit:
' + + '
' + + '' + + '' + + '' + + '' + + '' + + '
' + this.get_relative_size_combo_box( 'fe_ec_memory_limit', cat.memory_limit, 'fieldset_params_table' ) + 'for' + this.get_relative_time_combo_box( 'fe_ec_memory_sustain', cat.memory_sustain, 'fieldset_params_table' ) + '
' + + + '
Default Log Size Limit:
' + + '
' + + '' + + '' + + '' + + '
' + this.get_relative_size_combo_box( 'fe_ec_log_limit', cat.log_max_size, 'fieldset_params_table' ) + '
' + + + '
' + ); + html += get_form_table_caption( + "Optionally set default CPU load, memory usage and log size limits for the category.
Note that events can override any of these limits." + ); + html += get_form_table_spacer(); + + html += get_form_table_row('Graph',`
+ + +
` + ); + + setTimeout( function() { + $P().update_add_remove_me( $('#fe_ec_notify_success, #fe_ec_notify_fail') ); + }, 1 ); + + return html; + }, + + select_color: function(color) { + // click on a color swatch + this.category.color = (color == 'plain') ? '' : color; + $('.swatch').removeClass('active'); + $('.swatch.'+color).addClass('active'); + }, + + get_category_form_json: function() { + // get category elements from form, used for new or edit + var category = this.category; + + category.title = $('#fe_ec_title').val(); + if (!category.title.length) { + return app.badField('#fe_ec_title', "Please enter a title for the category."); + } + + category.gcolor = $("#fe_ec_gcolor").val(); + category.enabled = $('#fe_ec_enabled').is(':checked') ? 1 : 0; + category.description = $('#fe_ec_desc').val(); + category.max_children = parseInt( $('#fe_ec_max_children').val() ); + category.notify_success = $('#fe_ec_notify_success').val(); + category.notify_fail = $('#fe_ec_notify_fail').val(); + category.web_hook = $('#fe_ec_web_hook').val(); + + // cpu limit + if ($('#fe_ec_cpu_enabled').is(':checked')) { + category.cpu_limit = parseInt( $('#fe_ec_cpu_limit').val() ); + if (isNaN(category.cpu_limit)) return app.badField('fe_ec_cpu_limit', "Please enter an integer value for the CPU limit."); + if (category.cpu_limit < 0) return app.badField('fe_ec_cpu_limit', "Please enter a positive integer for the CPU limit."); + + category.cpu_sustain = parseInt( $('#fe_ec_cpu_sustain').val() ) * parseInt( $('#fe_ec_cpu_sustain_units').val() ); + if (isNaN(category.cpu_sustain)) return app.badField('fe_ec_cpu_sustain', "Please enter an integer value for the CPU sustain period."); + if (category.cpu_sustain < 0) return app.badField('fe_ec_cpu_sustain', "Please enter a positive integer for the CPU sustain period."); + } + else { + category.cpu_limit = 0; + category.cpu_sustain = 0; + } + + // mem limit + if ($('#fe_ec_memory_enabled').is(':checked')) { + category.memory_limit = parseInt( $('#fe_ec_memory_limit').val() ) * parseInt( $('#fe_ec_memory_limit_units').val() ); + if (isNaN(category.memory_limit)) return app.badField('fe_ec_memory_limit', "Please enter an integer value for the memory limit."); + if (category.memory_limit < 0) return app.badField('fe_ec_memory_limit', "Please enter a positive integer for the memory limit."); + + category.memory_sustain = parseInt( $('#fe_ec_memory_sustain').val() ) * parseInt( $('#fe_ec_memory_sustain_units').val() ); + if (isNaN(category.memory_sustain)) return app.badField('fe_ec_memory_sustain', "Please enter an integer value for the memory sustain period."); + if (category.memory_sustain < 0) return app.badField('fe_ec_memory_sustain', "Please enter a positive integer for the memory sustain period."); + } + else { + category.memory_limit = 0; + category.memory_sustain = 0; + } + + // job log file size limit + if ($('#fe_ec_log_enabled').is(':checked')) { + category.log_max_size = parseInt( $('#fe_ec_log_limit').val() ) * parseInt( $('#fe_ec_log_limit_units').val() ); + if (isNaN(category.log_max_size)) return app.badField('fe_ec_log_limit', "Please enter an integer value for the log size limit."); + if (category.log_max_size < 0) return app.badField('fe_ec_log_limit', "Please enter a positive integer for the log size limit."); + } + else { + category.log_max_size = 0; + } + + return category; + } + +}); \ No newline at end of file diff --git a/htdocs/js/pages/admin/ConfigKeys.js b/htdocs/js/pages/admin/ConfigKeys.js new file mode 100644 index 0000000..59659aa --- /dev/null +++ b/htdocs/js/pages/admin/ConfigKeys.js @@ -0,0 +1,335 @@ +// Cronicle Admin Page -- Config Keys + +Class.add( Page.Admin, { + + gosub_conf_keys: function(args) { + // show Config Key list + app.setWindowTitle( "Config Keys" ); + this.div.addClass('loading'); + app.api.post( 'app/get_conf_keys', copy_object(args), this.receive_confkeys.bind(this) ); + }, + + receive_confkeys: function(resp) { + // receive all Config Keys from server, render them sorted + this.lastConfigKeysResp = resp; + + var html = ''; + this.div.removeClass('loading'); + + var size = get_inner_window_size(); + var col_width = Math.floor( ((size.width * 0.9) + 200) / 7 ); + + if (!resp.rows) resp.rows = []; + + // sort by title ascending + this.conf_keys = resp.rows.sort( function(a, b) { + return a.title.toLowerCase().localeCompare( b.title.toLowerCase() ); + } ); + + html += this.getSidebarTabs( 'conf_keys', + [ + ['activity', "Activity Log"], + ['conf_keys', "Config Keys"], + ['api_keys', "API Keys"], + ['categories', "Categories"], + ['plugins', "Plugins"], + ['servers', "Servers"], + ['users', "Users"] + ] + ); + + var cols = ['Config Key', 'Value', 'Action']; + + html += '
'; + + html += '
'; + html += 'Config Keys'; + html += `` + html += '
'; + html += '
'; + + var self = this; + html += this.getBasicTable( this.conf_keys, cols, 'key', function(item, idx) { + var actions = [ + 'Edit', + 'Delete' + ]; + + return [ + `
  ${item.title}
` + , `
${item.key}
` + , '
' + actions.join(' | ') + '
' + ]; + } ); + + html += '
'; + html += '
'; + html += ''; + html += ''; + html += ''; + html += '
  Add Config Key...
 
  Reload
'; + + html += '
'; // padding + html += ''; // sidebar tabs + + this.div.html( html ); + }, + + edit_conf_key: function(idx) { + // jump to edit sub + if (idx > -1) Nav.go( '#Admin?sub=edit_conf_key&id=' + this.conf_keys[idx].id ); + else Nav.go( '#Admin?sub=new_conf_key' ); + }, + + delete_conf_key: function(idx) { + // delete key from search results + this.conf_key = this.conf_keys[idx]; + this.show_delete_conf_key_dialog(); + }, + + gosub_new_conf_key: function(args) { + // create new Config Key + var html = ''; + app.setWindowTitle( "New Config Key" ); + this.div.removeClass('loading'); + + html += this.getSidebarTabs( 'new_conf_key', + [ + ['activity', "Activity Log"], + ['conf_keys', "Config Keys"], + ['new_conf_key', "New Config Key"], + ['api_keys', "API Keys"], + ['categories', "Categories"], + ['plugins', "Plugins"], + ['servers', "Servers"], + ['users', "Users"] + ] + ); + + html += '
New Config Key
'; + + html += '
'; + html += '
'; + + this.conf_key = { key: 'true' }; + + html += this.get_conf_key_edit_html(); + + // buttons at bottom + html += ''; + + html += '
'; + html += '
'; + + html += ''; + html += ''; + html += ''; + + html += ''; + html += '
Cancel
 
  Create Key
'; + + html += '
'; + html += '
'; // table wrapper div + + html += ''; // sidebar tabs + + this.div.html( html ); + + setTimeout( function() { + $('#fe_ck_title').focus(); + }, 1 ); + }, + + cancel_conf_key_edit: function() { + // cancel editing Config Key and return to list + Nav.go( 'Admin?sub=conf_keys' ); + }, + + do_new_conf_key: function(force) { + // create new Config Key + app.clearError(); + var conf_key = this.get_conf_key_form_json(); + if (!conf_key) return; // error + + if (!conf_key.title.length) { + return app.badField('#fe_ck_title', "Please enter Config Name"); + } + + this.conf_key = conf_key; + + app.showProgress( 1.0, "Creating Config Key..." ); + app.api.post( 'app/create_conf_key', conf_key, this.new_conf_key_finish.bind(this) ); + }, + + new_conf_key_finish: function(resp) { + // new Config Key created successfully + app.hideProgress(); + + Nav.go('Admin?sub=edit_conf_key&id=' + resp.id); + + setTimeout( function() { + app.showMessage('success', "The new Config Key was created successfully."); + }, 150 ); + }, + + gosub_edit_conf_key: function(args) { + // edit Config Key subpage + this.div.addClass('loading'); + app.api.post( 'app/get_conf_key', { id: args.id }, this.receive_confkey.bind(this) ); + }, + + receive_confkey: function(resp) { + // edit existing Config Key + var html = ''; + this.conf_key = resp.conf_key; + + app.setWindowTitle( "Editing Config Key \"" + (this.conf_key.title) + "\"" ); + this.div.removeClass('loading'); + + html += this.getSidebarTabs( 'edit_conf_key', + [ + ['activity', "Activity Log"], + ['conf_keys', "Config Keys"], + ['edit_conf_key', "Edit Config Key"], + ['api_keys', "API Keys"], + ['categories', "Categories"], + ['plugins', "Plugins"], + ['servers', "Servers"], + ['users', "Users"] + ] + ); + + html += '
Editing Config Key “' + (this.conf_key.title) + '”
'; + + html += '
'; + html += '
'; + html += ''; + + html += this.get_conf_key_edit_html(); + + html += ''; + + html += '
'; + html += '
'; + + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += '
Cancel
 
Delete Key...
 
  Save Changes
 
   New
'; + + html += '
'; + html += '
'; + html += '
'; // table wrapper div + + html += ''; // sidebar tabs + + this.div.html( html ); + }, + + do_save_conf_key: function() { + // save changes to Config Key + app.clearError(); + var conf_key = this.get_conf_key_form_json(); + if (!conf_key) return; // error + + this.conf_key = conf_key; + + app.showProgress( 1.0, "Saving Config Key..." ); + app.api.post( 'app/update_conf_key', conf_key, this.save_conf_key_finish.bind(this) ); + }, + + save_conf_key_finish: function(resp, tx) { + // new Config Key saved successfully + app.hideProgress(); + app.showMessage('success', "The Config Key was saved successfully."); + window.scrollTo( 0, 0 ); + }, + + do_reload_conf_key: function(args) { + // save changes to Config Key + app.clearError(); + app.showProgress( 1.0, "Reloading Config Key..." ); + app.api.post( 'app/reload_conf_key', args, this.reload_conf_key_finish.bind(this) ); + }, + + reload_conf_key_finish: function(resp, tx) { + // new Config Key saved successfully + app.hideProgress(); + app.showMessage('success', "Config Keys were reloaded successfully."); + window.scrollTo( 0, 0 ); + }, + + + show_delete_conf_key_dialog: function() { + // show dialog confirming Config Key delete action + var self = this; + app.confirm( 'Delete Config Key', "Are you sure you want to permanently delete the Config Key \""+this.conf_key.title+"\"? There is no way to undo this action.", 'Delete', function(result) { + if (result) { + app.showProgress( 1.0, "Deleting Config Key..." ); + app.api.post( 'app/delete_conf_key', self.conf_key, self.delete_conf_key_finish.bind(self) ); + } + } ); + }, + + delete_conf_key_finish: function(resp, tx) { + // finished deleting Config Key + var self = this; + app.hideProgress(); + + Nav.go('Admin?sub=conf_keys', 'force'); + + setTimeout( function() { + app.showMessage('success', "The Config Key '"+self.conf_key.title+"' was deleted successfully."); + }, 150 ); + }, + + get_conf_key_edit_html: function() { + // get html for editing an Config Key (or creating a new one) + var html = ''; + var conf_key = this.conf_key; + + + // title + var disableConfTitle = '' + if(conf_key.title) disableConfTitle = 'disabled' // let edit only if new + html += get_form_table_row( 'Config Title', `` ); + html += get_form_table_caption( "For nested properties use . (e.g. servers.worker1)"); + html += get_form_table_spacer(); + + // Config Key + html += get_form_table_row( 'Value', '' ); + html += get_form_table_caption( "For boolean use 0/1 or true/false" ); + html += get_form_table_spacer(); + + + // description + html += get_form_table_row('Description', ''); + html += get_form_table_caption( "Config purpose (optional)" ); + html += get_form_table_spacer(); + + return html; + }, + + get_conf_key_form_json: function() { + // get Config Key elements from form, used for new or edit + var conf_key = this.conf_key; + + conf_key.key = $('#fe_ck_key').val(); + conf_key.active = $('#fe_ck_status').val(); + conf_key.title = $('#fe_ck_title').val(); + conf_key.description = $('#fe_ck_desc').val(); + + if (!conf_key.key.length) { + return app.badField('#fe_ck_key', "Please enter an Config Key string"); + } + + return conf_key; + } + + +}); diff --git a/htdocs/js/pages/admin/Plugins.js b/htdocs/js/pages/admin/Plugins.js new file mode 100644 index 0000000..cd0854d --- /dev/null +++ b/htdocs/js/pages/admin/Plugins.js @@ -0,0 +1,694 @@ +// Cronicle Admin Page -- Plugins + +Class.add( Page.Admin, { + + ctype_labels: { + text: "Text Field", + textarea: "Text Box", + checkbox: "Checkbox", + hidden: "Hidden", + select: "Menu" + }, + + gosub_plugins: function(args) { + // show plugin list + this.div.removeClass('loading'); + app.setWindowTitle( "Plugins" ); + + var size = get_inner_window_size(); + var col_width = Math.floor( ((size.width * 0.9) + 500) / 6 ); + + var html = ''; + + this.plugins = app.plugins; + + html += this.getSidebarTabs( 'plugins', + [ + ['activity', "Activity Log"], + ['conf_keys', "Config Keys"], + ['api_keys', "API Keys"], + ['categories', "Categories"], + ['plugins', "Plugins"], + ['servers', "Servers"], + ['users', "Users"] + ] + ); + + var cols = ['Plugin Name', 'Author', '# of Events', 'Created', 'Modified', 'Actions']; + + // html += '
'; + html += '
'; + + html += '
'; + html += 'Plugins'; + // html += '
'; + html += '
'; + + // sort by title ascending + this.plugins = app.plugins.sort( function(a, b) { + // return (b.title < a.title) ? 1 : -1; + return a.title.toLowerCase().localeCompare( b.title.toLowerCase() ); + } ); + + var self = this; + html += this.getBasicTable( this.plugins, cols, 'plugin', function(plugin, idx) { + var actions = [ + 'Edit', + 'Delete' + ]; + + var plugin_events = find_objects( app.schedule, { plugin: plugin.id } ); + var num_events = plugin_events.length; + + var tds = [ + '', + self.getNiceUsername(plugin, true, col_width), + num_events ? commify( num_events ) : '(None)', + ''+get_nice_date(plugin.created, true)+'', + ''+get_nice_date(plugin.modified, true)+'', + actions.join(' | ') + ]; + + if (!plugin.enabled) { + if (tds.className) tds.className += ' '; else tds.className = ''; + tds.className += 'disabled'; + } + + return tds; + } ); + + html += '
'; + html += '
'; + html += ''; + html += '
  Add New Plugin...
'; + + html += '
'; // padding + html += '
'; // sidebar tabs + + this.div.html( html ); + }, + + edit_plugin: function(idx) { + // jump to edit sub + if (idx > -1) Nav.go( '#Admin?sub=edit_plugin&id=' + this.plugins[idx].id ); + else Nav.go( '#Admin?sub=new_plugin' ); + }, + + delete_plugin: function(idx) { + // delete key from search results + this.plugin = this.plugins[idx]; + this.show_delete_plugin_dialog(); + }, + + show_delete_plugin_dialog: function() { + // delete selected plugin + var plugin = this.plugin; + + // check for events first + var plugin_events = find_objects( app.schedule, { plugin: plugin.id } ); + var num_events = plugin_events.length; + if (num_events) return app.doError("Sorry, you cannot delete a plugin that has events assigned to it."); + + // proceed with delete + var self = this; + app.confirm( 'Delete Plugin', "Are you sure you want to delete the plugin "+plugin.title+"? There is no way to undo this action.", "Delete", function(result) { + if (result) { + app.showProgress( 1.0, "Deleting Plugin..." ); + app.api.post( 'app/delete_plugin', plugin, function(resp) { + app.hideProgress(); + app.showMessage('success', "The Plugin '"+self.plugin.title+"' was deleted successfully."); + // self.gosub_plugins(self.args); + + Nav.go('Admin?sub=plugins', 'force'); + } ); + } + } ); + }, + + gosub_new_plugin: function(args) { + // create new plugin + var html = ''; + app.setWindowTitle( "Add New Plugin" ); + this.div.removeClass('loading'); + + html += this.getSidebarTabs( 'new_plugin', + [ + ['activity', "Activity Log"], + ['conf_keys', "Config Keys"], + ['api_keys', "API Keys"], + ['categories', "Categories"], + ['plugins', "Plugins"], + ['new_plugin', "Add New Plugin"], + ['servers', "Servers"], + ['users', "Users"] + ] + ); + + html += '
Add New Plugin
'; + + html += '
'; + html += '
'; + + if (this.plugin_copy) { + this.plugin = this.plugin_copy; + delete this.plugin_copy; + } + else { + this.plugin = { params: [], enabled: 1 }; + } + + html += this.get_plugin_edit_html(); + + // buttons at bottom + html += ''; + html += '
'; + html += '
'; + + html += ''; + html += ''; + html += ''; + html += ''; + html += '
Cancel
 
  Create Plugin
'; + + html += '
'; + + html += '
'; // table wrapper div + html += ''; // sidebar tabs + + this.div.html( html ); + + setTimeout( function() { + $('#fe_ep_title').focus(); + }, 1 ); + }, + + cancel_plugin_edit: function() { + // cancel edit, nav back to plugin list + Nav.go('Admin?sub=plugins'); + }, + + do_new_plugin: function(force) { + // create new plugin + app.clearError(); + var plugin = this.get_plugin_form_json(); + if (!plugin) return; // error + + // pro-tip: embed id in title as bracketed prefix + if (plugin.title.match(/^\[(\w+)\]\s*(.+)$/)) { + plugin.id = RegExp.$1; + plugin.title = RegExp.$2; + } + + this.plugin = plugin; + + app.showProgress( 1.0, "Creating plugin..." ); + app.api.post( 'app/create_plugin', plugin, this.new_plugin_finish.bind(this) ); + }, + + new_plugin_finish: function(resp) { + // new plugin created successfully + app.hideProgress(); + + Nav.go('Admin?sub=plugins'); + + setTimeout( function() { + app.showMessage('success', "The new plugin was created successfully."); + }, 150 ); + }, + + gosub_edit_plugin: function(args) { + // edit plugin subpage + var plugin = find_object( app.plugins, { id: args.id } ); + if (!plugin) return app.doError("Could not locate Plugin with ID: " + args.id); + + // make local copy so edits don't affect main app list until save + this.plugin = deep_copy_object( plugin ); + + var html = ''; + app.setWindowTitle( "Editing Plugin \"" + plugin.title + "\"" ); + this.div.removeClass('loading'); + + html += this.getSidebarTabs( 'edit_plugin', + [ + ['activity', "Activity Log"], + ['conf_keys', "Config Keys"], + ['api_keys', "API Keys"], + ['categories', "Categories"], + ['plugins', "Plugins"], + ['edit_plugin', "Edit Plugin"], + ['servers', "Servers"], + ['users', "Users"] + ] + ); + + html += '
Editing Plugin “' + plugin.title + '”
'; + + html += '
'; + html += '
'; + html += ''; + + html += this.get_plugin_edit_html(); + + html += ''; + + html += '
'; + html += '
'; + + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += '
Cancel
 
Delete Plugin...
 
Copy Plugin...
 
  Save Changes
'; + + html += '
'; + html += '
'; + html += '
'; // table wrapper div + + html += ''; // sidebar tabs + + this.div.html( html ); + }, + + do_copy_plugin: function() { + // copy plugin to new + app.clearError(); + var plugin = this.get_plugin_form_json(); + if (!plugin) return; // error + + delete plugin.id; + delete plugin.created; + delete plugin.modified; + delete plugin.username; + + plugin.title = "Copy of " + plugin.title; + + this.plugin_copy = plugin; + Nav.go('Admin?sub=new_plugin'); + }, + + do_save_plugin: function() { + // save changes to existing plugin + app.clearError(); + var plugin = this.get_plugin_form_json(); + if (!plugin) return; // error + + this.plugin = plugin; + + app.showProgress( 1.0, "Saving plugin..." ); + app.api.post( 'app/update_plugin', plugin, this.save_plugin_finish.bind(this) ); + }, + + save_plugin_finish: function(resp, tx) { + // existing plugin saved successfully + var self = this; + var plugin = this.plugin; + + app.hideProgress(); + app.showMessage('success', "The plugin was saved successfully."); + window.scrollTo( 0, 0 ); + + // copy active jobs to array + var jobs = []; + for (var id in app.activeJobs) { + var job = app.activeJobs[id]; + if ((job.plugin == plugin.id) && !job.detached) jobs.push( job ); + } + + // if the plugin was disabled and there are running jobs, ask user to abort them + if (!plugin.enabled && jobs.length) { + app.confirm( 'Abort Jobs', "There " + ((jobs.length != 1) ? 'are' : 'is') + " currently still " + jobs.length + " active " + pluralize('job', jobs.length) + " using the disabled plugin "+plugin.title+". Do you want to abort " + ((jobs.length != 1) ? 'these' : 'it') + " now?", "Abort", function(result) { + if (result) { + app.showProgress( 1.0, "Aborting " + pluralize('Job', jobs.length) + "..." ); + app.api.post( 'app/abort_jobs', { plugin: plugin.id }, function(resp) { + app.hideProgress(); + if (resp.count > 0) { + app.showMessage('success', "The " + pluralize('job', resp.count) + " " + ((resp.count != 1) ? 'were' : 'was') + " aborted successfully."); + } + else { + app.showMessage('warning', "No jobs were aborted. It is likely they completed while the dialog was up."); + } + } ); + } // clicked Abort + } ); // app.confirm + } // disabled + jobs + }, + + get_plugin_edit_html: function() { + // get html for editing a plugin (or creating a new one) + var html = ''; + var plugin = this.plugin; + + // Internal ID + if (plugin.id && this.isAdmin()) { + html += get_form_table_row( 'Plugin ID', '
' + plugin.id + '
' ); + html += get_form_table_caption( "The internal Plugin ID used for API calls. This cannot be changed." ); + html += get_form_table_spacer(); + } + + // plugin title + html += get_form_table_row( 'Plugin Name', '' ); + html += get_form_table_caption( "Enter a name for the Plugin. Ideally it should be somewhat short, and Title Case." ); + html += get_form_table_spacer(); + + // plugin enabled + html += get_form_table_row( 'Active', '' ); + html += get_form_table_caption( "Select whether events using this Plugin should be enabled or disabled in the schedule." ); + html += get_form_table_spacer(); + + // command + html += get_form_table_row('Executable:', ''); + html += get_form_table_caption( + 'Enter the filesystem path to your executable, including any command-line arguments.
' + + 'Do not include any pipes or redirects -- for those, please use the Shell Plugin.' + ); + html += get_form_table_spacer(); + + // params editor + html += get_form_table_row( 'Parameters:', '
' + this.get_plugin_params_html() + '
' ); + html += get_form_table_caption( + '
Parameters are passed to your Plugin via JSON, and as environment variables.
' + + 'For example, you can use this to customize the PATH variable, if your Plugin requires it.
' + ); + html += get_form_table_spacer(); + + // advanced options + var adv_expanded = !!(plugin.cwd || plugin.uid); + html += get_form_table_row( 'Advanced', + '
 Advanced Options
' + + '
 Advanced Options' + + '
Working Directory (CWD):
' + + '
' + + + '
Run as User (UID):
' + + '
' + + + '
Secret
' + + '
' + + + '
' + ); + html += get_form_table_caption( + "Optionally enter a working directory path, and/or a custom UID for the Plugin.
" + + "The UID may be either numerical or a string ('root', 'wheel', etc.).
" + + "Secret will appear as PLUGIN_SECRET env variable. It's not transmitted to UI" + ); + html += get_form_table_spacer(); + + return html; + }, + + stopEnter: function(item, e) { + // prevent user from hitting enter in textarea + var c = e.which ? e.which : e.keyCode; + if (c == 13) { + if (e.preventDefault) e.preventDefault(); + // setTimeout("document.getElementById('"+item.id+"').focus();",0); + return false; + } + }, + + get_plugin_params_html: function() { + // return HTML for editing plugin params + var params = this.plugin.params; + var html = ''; + var ctype_labels = this.ctype_labels; + + var cols = ['Param ID', 'Label', 'Control Type', 'Description', 'Actions']; + + html += ''; + html += ''; + for (var idx = 0, len = params.length; idx < len; idx++) { + var param = params[idx]; + var actions = [ + 'Edit', + 'Delete' + ]; + html += ''; + html += ''; + // html += ''; + if (param.title) html += ''; + else html += ''; + + html += ''; + + var pairs = []; + switch (param.type) { + case 'text': + pairs.push([ 'Size', param.size ]); + if ('value' in param) pairs.push([ 'Default', '“' + param.value + '”' ]); + break; + + case 'textarea': + pairs.push([ 'Rows', param.rows ]); + break; + + case 'checkbox': + pairs.push([ 'Default', param.value ? 'Checked' : 'Unchecked' ]); + break; + + case 'hidden': + pairs.push([ 'Value', '“' + param.value + '”' ]); + break; + + case 'select': + pairs.push([ 'Items', '(' + param.items.join(', ') + ')' ]); + if ('value' in param) pairs.push([ 'Default', '“' + param.value + '”' ]); + break; + } + for (var idy = 0, ley = pairs.length; idy < ley; idy++) { + pairs[idy] = '' + pairs[idy][0] + ': ' + pairs[idy][1]; + } + html += ''; + + html += ''; + html += ''; + } // foreach param + if (!params.length) { + html += ''; + } + html += '
' + cols.join('').replace(/\s+/g, ' ') + '
  ' + param.id + '' + param.title + '“' + param.title + '”(n/a)' + ctype_labels[param.type] + '' + pairs.join(', ') + '' + actions.join(' | ') + '
'; + html += 'No params found.'; + html += '
'; + + html += '
Add Parameter...
'; + + return html; + }, + + edit_plugin_param: function(idx) { + // show dialog to edit or add plugin param + var self = this; + var param = (idx > -1) ? this.plugin.params[idx] : { + id: "", + type: "text", + title: "", + size: 20, + value: "" + }; + this.plugin_param = param; + + var edit = (idx > -1) ? true : false; + var html = ''; + + var ctype_labels = this.ctype_labels; + var ctype_options = [ + ['text', ctype_labels.text], + ['textarea', ctype_labels.textarea], + ['checkbox', ctype_labels.checkbox], + ['select', ctype_labels.select], + ['hidden', ctype_labels.hidden] + ]; + + html += '' + + get_form_table_row('Parameter ID:', '') + + get_form_table_caption("Enter an ID for the parameter, which will be the JSON key.") + + get_form_table_spacer() + + get_form_table_row('Label:', '') + + get_form_table_caption("Enter a label, which will be displayed next to the control.") + + // get_form_table_spacer() + + // get_form_table_row('Control Type:', '') + + // get_form_table_caption("Select the type of control you want to display.") + + '
'; + + html += '
'; + html += '
Control Type: 
'; + html += '
' + this.get_plugin_param_editor_html() + '
'; + html += '
'; + + app.confirm( '  ' + (edit ? "Edit Parameter" : "Add Parameter"), html, edit ? "OK" : "Add", function(result) { + app.clearError(); + + if (result) { + param = self.get_plugin_param_values(); + if (!param) return; + + if (edit) { + // edit existing + self.plugin.params[idx] = param; + } + else { + // add new, check for unique id + if (find_object(self.plugin.params, { id: param.id })) { + return add.badField('fe_epp_id', "That parameter ID is already taken. Please enter a unique value."); + } + + self.plugin.params.push( param ); + } + + Dialog.hide(); + + // refresh param list + self.refresh_plugin_params(); + + } // user clicked add + } ); // app.confirm + + if (!edit) setTimeout( function() { + $('#fe_epp_id').focus(); + }, 1 ); + }, + + get_plugin_param_editor_html: function() { + // get html for editing one plugin param, new or edit + var param = this.plugin_param; + var html = ''; + + switch (param.type) { + case 'text': + html += get_form_table_row('Size:', ''); + html += get_form_table_caption("Enter the size of the text field, in characters."); + html += get_form_table_spacer('short transparent'); + html += get_form_table_row('Default Value:', ''); + html += get_form_table_caption("Enter the default value for the text field."); + break; + + case 'textarea': + html += get_form_table_row('Rows:', ''); + html += get_form_table_caption("Enter the number of visible rows to allocate for the text box."); + html += get_form_table_spacer('short transparent'); + html += get_form_table_row('Default Text:', ''); + html += get_form_table_caption("Optionally enter default text for the text box."); + break; + + case 'checkbox': + html += get_form_table_row('Default State:', ''); + html += get_form_table_caption("Select whether the checkbox should be initially checked or unchecked."); + break; + + case 'hidden': + html += get_form_table_row('Value:', ''); + html += get_form_table_caption("Enter the value for the hidden field."); + break; + + case 'select': + html += get_form_table_row('Menu Items:', ''); + html += get_form_table_caption("Enter a comma-separated list of items for the menu."); + html += get_form_table_spacer('short transparent'); + html += get_form_table_row('Selected Item:', ''); + html += get_form_table_caption("Optionally enter an item to be selected by default."); + break; + } // switch type + + html += '
'; + return html; + }, + + get_plugin_param_values: function() { + // build up new 'param' object based on edit form (gen'ed from get_plugin_edit_controls()) + var param = { type: this.plugin_param.type }; + + param.id = trim( $('#fe_epp_id').val() ); + if (!param.id) return app.badField('fe_epp_id', "Please enter an ID for the plugin parameter."); + if (!param.id.match(/^\w+$/)) return app.badField('fe_epp_id', "The parameter ID needs to be alphanumeric."); + + param.title = trim( $('#fe_epp_title').val() ); + if ((param.type != 'hidden') && !param.title) return app.badField('fe_epp_title', "Please enter a label for the plugin parameter."); + + switch (param.type) { + case 'text': + param.size = trim( $('#fe_epp_text_size').val() ); + if (!param.size.match(/^\d+$/)) return app.badField('fe_epp_text_size', "Please enter a size for the text field."); + param.size = parseInt( param.size ); + if (!param.size) return app.badField('fe_epp_text_size', "Please enter a size for the text field."); + if (param.size > 40) return app.badField('fe_epp_text_size', "The text field size needs to be between 1 and 40 characters."); + param.value = trim( $('#fe_epp_text_value').val() ); + break; + + case 'textarea': + param.rows = trim( $('#fe_epp_textarea_rows').val() ); + if (!param.rows.match(/^\d+$/)) return app.badField('fe_epp_textarea_rows', "Please enter a number of rows for the text box."); + param.rows = parseInt( param.rows ); + if (!param.rows) return app.badField('fe_epp_textarea_rows', "Please enter a number of rows for the text box."); + if (param.rows > 50) return app.badField('fe_epp_textarea_rows', "The text box rows needs to be between 1 and 50."); + param.value = trim( $('#fe_epp_textarea_value').val() ); + break; + + case 'checkbox': + param.value = parseInt( trim( $('#fe_epp_checkbox_value').val() ) ); + break; + + case 'hidden': + param.value = trim( $('#fe_epp_hidden_value').val() ); + break; + + case 'select': + if (!$('#fe_epp_select_items').val().match(/\S/)) return app.badField('fe_epp_select_items', "Please enter a comma-separated list of items for the menu."); + param.items = trim( $('#fe_epp_select_items').val() ).split(/\,\s*/); + param.value = trim( $('#fe_epp_select_value').val() ); + if (param.value && !find_in_array(param.items, param.value)) return app.badField('fe_epp_select_value', "The default value you entered was not found in the list of menu items."); + break; + } + + return param; + }, + + change_plugin_control_type: function() { + // change dialog to new control type + // render, resize and reposition dialog + var new_type = $('#fe_epp_ctype').val(); + this.plugin_param.type = new_type; + + $('#d_epp_editor').html( this.get_plugin_param_editor_html() ); + + // Dialog.autoResize(); + }, + + delete_plugin_param: function(idx) { + // delete selected plugin param, but do not save + // don't prompt either, giving a UX hint that save did not occur + this.plugin.params.splice( idx, 1 ); + this.refresh_plugin_params(); + }, + + refresh_plugin_params: function() { + // redraw plugin param area after change + $('#d_ep_params').html( this.get_plugin_params_html() ); + }, + + get_plugin_form_json: function() { + // get plugin elements from form, used for new or edit + var plugin = this.plugin; + + plugin.title = trim( $('#fe_ep_title').val() ); + if (!plugin.title) return app.badField('fe_ep_title', "Please enter a title for the Plugin."); + + plugin.enabled = $('#fe_ep_enabled').is(':checked') ? 1 : 0; + + plugin.command = trim( $('#fe_ep_command').val() ); + if (!plugin.command) return app.badField('fe_ep_command', "Please enter a filesystem path to the executable command for the Plugin."); + if (plugin.command.match(/[\n\r]/)) return app.badField('fe_ep_command', "You must not include any newlines (EOLs) in your command. Please consider using the built-in Shell Plugin."); + + plugin.cwd = trim( $('#fe_ep_cwd').val() ); + plugin.uid = trim( $('#fe_ep_uid').val() ); + plugin.secret = trim( $('#fe_ep_secret').val() ); + + if (plugin.uid.match(/^\d+$/)) plugin.uid = parseInt( plugin.uid ); + + return plugin; + } + +}); diff --git a/htdocs/js/pages/admin/Servers.js b/htdocs/js/pages/admin/Servers.js new file mode 100644 index 0000000..aea2260 --- /dev/null +++ b/htdocs/js/pages/admin/Servers.js @@ -0,0 +1,392 @@ +// Cronicle Admin Page -- Servers + +Class.add( Page.Admin, { + + gosub_servers: function(args) { + // show server list, server groups + this.div.removeClass('loading'); + app.setWindowTitle( "Servers" ); + + var size = get_inner_window_size(); + var col_width = Math.floor( ((size.width * 0.9) + 400) / 9 ); + + var html = ''; + + html += this.getSidebarTabs( 'servers', + [ + ['activity', "Activity Log"], + ['conf_keys', "Config Keys"], + ['api_keys', "API Keys"], + ['categories', "Categories"], + ['plugins', "Plugins"], + ['servers', "Servers"], + ['users', "Users"] + ] + ); + + html += '
'; + + // Active Server Cluster + + var cols = ['Hostname', 'IP Address', 'Groups', 'Status', 'Active Jobs', 'Uptime', 'CPU', 'Mem', 'Actions']; + + html += '
'; + html += 'Server Cluster'; + // html += '
'; + html += '
'; + + this.servers = []; + var hostnames = hash_keys_to_array( app.servers ).sort(); + for (var idx = 0, len = hostnames.length; idx < len; idx++) { + this.servers.push( app.servers[ hostnames[idx] ] ); + } + + // include nearby servers under main server list + if (app.nearby) { + var hostnames = hash_keys_to_array( app.nearby ).sort(); + for (var idx = 0, len = hostnames.length; idx < len; idx++) { + var server = app.nearby[ hostnames[idx] ]; + if (!app.servers[server.hostname]) { + server.nearby = 1; + this.servers.push( server ); + } + } + } + + // render table + var self = this; + html += this.getBasicTable( this.servers, cols, 'server', function(server, idx) { + + // render nearby servers differently + if (server.nearby) { + var tds = [ + '
 ' + server.hostname.replace(/\.[\w\-]+\.\w+$/, '') + '
', + (server.ip || 'n/a').replace(/^\:\:ffff\:(\d+\.\d+\.\d+\.\d+)$/, '$1'), + '-', '(Nearby)', '-', '-', '-', '-', + 'Add Server' + ]; + tds.className = 'blue'; + return tds; + } // nearby + + var actions = [ + 'Restart', + 'Shutdown' + ]; + if (server.disabled) actions = []; + if (!server.manager) { + actions.push( 'Remove' ); + } + + var group_names = []; + var eligible = false; + for (var idx = 0, len = app.server_groups.length; idx < len; idx++) { + var group = app.server_groups[idx]; + var regexp = new RegExp( group.regexp, "i" ); + if (server.hostname.match(regexp)) { + group_names.push( group.title ); + if (group.manager) eligible = true; + } + } + + var jobs = find_objects( app.activeJobs, { hostname: server.hostname } ); + var num_jobs = jobs.length; + + var cpu = 0; + var mem = 0; + if (server.data && server.data.cpu) cpu += server.data.cpu; + if (server.data && server.data.mem) mem += server.data.mem; + for (idx = 0, len = jobs.length; idx < len; idx++) { + var job = jobs[idx]; + if (job.cpu && job.cpu.current) cpu += job.cpu.current; + if (job.mem && job.mem.current) mem += job.mem.current; + } + + var tds = [ + '
' + self.getNiceGroup(null, server.hostname, col_width) + '
', + (server.ip || 'n/a').replace(/^\:\:ffff\:(\d+\.\d+\.\d+\.\d+)$/, '$1'), + group_names.length ? group_names.join(', ') : '(None)', + server.manager ? ' Manager' : (eligible ? 'Backup' : 'Worker'), + num_jobs ? commify( num_jobs ) : '(None)', + get_text_from_seconds( server.uptime, true, true ).replace(/\bday\b/, 'days'), + short_float(cpu) + '%', + get_text_from_bytes(mem), + actions.join(' | ') + ]; + + if (server.disabled) tds.className = 'disabled'; + + return tds; + } ); + + html += '
'; + html += '
'; + html += ''; + html += '
  Add Server...
'; + + html += '
'; + + // Server Groups + + var col_width = Math.floor( ((size.width * 0.9) + 300) / 6 ); + + var cols = ['Title', 'Hostname Match', '# of Servers', '# of Events', 'Class', 'Actions']; + + html += '
'; + html += 'Server Groups'; + // html += '
'; + html += '
'; + + // sort by title ascending + this.server_groups = app.server_groups.sort( function(a, b) { + // return (b.title < a.title) ? 1 : -1; + return a.title.toLowerCase().localeCompare( b.title.toLowerCase() ); + } ); + + // render table + var self = this; + html += this.getBasicTable( this.server_groups, cols, 'group', function(group, idx) { + var actions = [ + 'Edit', + 'Delete' + ]; + + var regexp = new RegExp( group.regexp, "i" ); + var num_servers = 0; + for (var hostname in app.servers) { + if (hostname.match(regexp)) num_servers++; + } + + var group_events = find_objects( app.schedule, { target: group.id } ); + var num_events = group_events.length; + + return [ + '
' + self.getNiceGroup(group, null, col_width) + '
', + '
/' + group.regexp + '/
', + // group.description || '(No description)', + num_servers ? commify( num_servers) : '(None)', + num_events ? commify( num_events ) : '(None)', + group.manager ? 'Manager Eligible' : 'Worker Only', + actions.join(' | ') + ]; + } ); + + html += '
'; + html += '
'; + html += ''; + html += '
  Add Group...
'; + + html += '
'; // padding + html += ''; // sidebar tabs + + this.div.html( html ); + }, + + add_server_from_list: function(idx) { + // add a server right away, from the nearby list + var server = this.servers[idx]; + + app.showProgress( 1.0, "Adding server..." ); + app.api.post( 'app/add_server', { hostname: server.ip || server.hostname }, function(resp) { + app.hideProgress(); + app.showMessage('success', "Server was added successfully."); + // self['gosub_servers'](self.args); + } ); // api.post + }, + + add_server: function() { + // show dialog allowing user to enter an arbitrary hostname to add + var html = ''; + + // html += '
Typically, servers should automatically add themselves to the cluster, if they are within UDP broadcast range (i.e. on the same LAN). You should only need to manually add a server in special circumstances, e.g. if it is remotely hosted in another datacenter or network.
'; + + // html += '
Note that the new server cannot already be a manager server, nor part of another '+app.name+' server cluster, and the current manager server must be able to reach it.
'; + + html += '
' + + // get_form_table_spacer() + + get_form_table_row('Hostname or IP:', '') + + get_form_table_caption("Enter the hostname or IP of the server you want to add.") + + '
'; + + app.confirm( '  Add Server', html, "Add Server", function(result) { + app.clearError(); + + if (result) { + var hostname = $('#fe_as_hostname').val().toLowerCase(); + if (!hostname) return app.badField('fe_as_hostname', "Please enter a server hostname or IP address."); + if (!hostname.match(/^[\w\-\.]+$/)) return app.badField('fe_as_hostname', "Please enter a valid server hostname or IP address."); + if (app.servers[hostname]) return app.badField('fe_as_hostname', "That server is already in the cluster."); + Dialog.hide(); + + app.showProgress( 1.0, "Adding server..." ); + app.api.post( 'app/add_server', { hostname: hostname }, function(resp) { + app.hideProgress(); + app.showMessage('success', "Server was added successfully."); + // self['gosub_servers'](self.args); + } ); // api.post + } // user clicked add + } ); // app.confirm + + setTimeout( function() { + $('#fe_as_hostname').focus(); + }, 1 ); + }, + + remove_server: function(idx) { + // remove manual server after user confirmation + var server = this.servers[idx]; + + var jobs = find_objects( app.activeJobs, { hostname: server.hostname } ); + if (jobs.length) return app.doError("Sorry, you cannot remove a server that has active jobs running on it."); + + // proceed with remove + var self = this; + app.confirm( 'Remove Server', "Are you sure you want to remove the server "+server.hostname+"?", "Remove", function(result) { + if (result) { + app.showProgress( 1.0, "Removing server..." ); + app.api.post( 'app/remove_server', server, function(resp) { + app.hideProgress(); + app.showMessage('success', "Server was removed successfully."); + // self.gosub_servers(self.args); + } ); + } + } ); + }, + + edit_group: function(idx) { + // edit group (-1 == new group) + var self = this; + var group = (idx > -1) ? this.server_groups[idx] : { + title: "", + regexp: "", + manager: 0 + }; + var edit = (idx > -1) ? true : false; + var html = ''; + + html += ''; + + // Internal ID + if (edit && this.isAdmin()) { + html += get_form_table_row( 'Group ID', '
' + group.id + '
' ); + html += get_form_table_caption( "The internal Group ID used for API calls. This cannot be changed." ); + html += get_form_table_spacer(); + } + + html += + get_form_table_row('Group Title:', '') + + get_form_table_caption("Enter a title for the server group, short and sweet.") + + get_form_table_spacer() + + get_form_table_row('Hostname Match:', '') + + get_form_table_caption("Enter a regular expression to auto-assign servers to this group by their hostnames, e.g. \"^mtx\\d+\\.\".") + + get_form_table_spacer() + + get_form_table_row('Server Class:', '') + + get_form_table_caption("Select whether servers in the group are eligible to become the manager server, or run as workers only.") + + '
'; + + app.confirm( '  ' + (edit ? "Edit Server Group" : "Add Server Group"), html, edit ? "Save Changes" : "Add Group", function(result) { + app.clearError(); + + if (result) { + group.title = $('#fe_eg_title').val(); + if (!group.title) return app.badField('fe_eg_title', "Please enter a title for the server group."); + group.regexp = $('#fe_eg_regexp').val().replace(/^\/(.+)\/$/, '$1'); + if (!group.regexp) return app.badField('fe_eg_regexp', "Please enter a regular expression for the server group."); + + try { new RegExp(group.regexp); } + catch(err) { + return app.badField('fe_eg_regexp', "Invalid regular expression: " + err); + } + + group.manager = parseInt( $('#fe_eg_manager').val() ); + Dialog.hide(); + + // pro-tip: embed id in title as bracketed prefix + if (!edit && group.title.match(/^\[(\w+)\]\s*(.+)$/)) { + group.id = RegExp.$1; + group.title = RegExp.$2; + } + + app.showProgress( 1.0, edit ? "Saving group..." : "Adding group..." ); + app.api.post( edit ? 'app/update_server_group' : 'app/create_server_group', group, function(resp) { + app.hideProgress(); + app.showMessage('success', "Server group was " + (edit ? "saved" : "added") + " successfully."); + // self['gosub_servers'](self.args); + } ); // api.post + } // user clicked add + } ); // app.confirm + + setTimeout( function() { + if (!$('#fe_eg_title').val()) $('#fe_eg_title').focus(); + }, 1 ); + }, + + delete_group: function(idx) { + // delete selected server group + var group = this.server_groups[idx]; + + // make sure user isn't deleting final manager group + if (group.manager) { + var num_managers = 0; + for (var idx = 0, len = this.server_groups.length; idx < len; idx++) { + if (this.server_groups[idx].manager) num_managers++; + } + if (num_managers == 1) { + return app.doError("Sorry, you cannot delete the last manager Eligible server group."); + } + } + + // check for events first + var group_events = find_objects( app.schedule, { target: group.id } ); + var num_events = group_events.length; + if (num_events) return app.doError("Sorry, you cannot delete a group that has events assigned to it."); + + // proceed with delete + var self = this; + app.confirm( 'Delete Server Group', "Are you sure you want to delete the server group "+group.title+"? There is no way to undo this action.", "Delete", function(result) { + if (result) { + app.showProgress( 1.0, "Deleting group..." ); + app.api.post( 'app/delete_server_group', group, function(resp) { + app.hideProgress(); + app.showMessage('success', "Server group was deleted successfully."); + // self.gosub_servers(self.args); + } ); + } + } ); + }, + + restart_server: function(idx) { + // restart server after confirmation + var self = this; + var server = this.servers[idx]; + + app.confirm( 'Restart Server', "Are you sure you want to restart the server "+server.hostname+"? All server jobs will be aborted.", "Restart", function(result) { + if (result) { + app.showProgress( 1.0, "Restarting server..." ); + app.api.post( 'app/restart_server', server, function(resp) { + app.hideProgress(); + app.showMessage('success', "Server is being restarted in the background."); + // self.gosub_servers(self.args); + } ); + } + } ); + }, + + shutdown_server: function(idx) { + // shutdown server after confirmation + var self = this; + var server = this.servers[idx]; + + app.confirm( 'Shutdown Server', "Are you sure you want to shutdown the server "+server.hostname+"? All server jobs will be aborted.", "Shutdown", function(result) { + if (result) { + app.showProgress( 1.0, "Shutting down server..." ); + app.api.post( 'app/shutdown_server', server, function(resp) { + app.hideProgress(); + app.showMessage('success', "Server is being shut down in the background."); + // self.gosub_servers(self.args); + } ); + } + } ); + } + +}); \ No newline at end of file diff --git a/htdocs/js/pages/admin/Users.js b/htdocs/js/pages/admin/Users.js new file mode 100644 index 0000000..092a2d7 --- /dev/null +++ b/htdocs/js/pages/admin/Users.js @@ -0,0 +1,627 @@ +// Cronicle Admin Page -- Users + +Class.add(Page.Admin, { + + gosub_users: function (args) { + // show user list + app.setWindowTitle("User List"); + this.div.addClass('loading'); + if (!args.offset) args.offset = 0; + if (!args.limit) args.limit = 25; + app.api.post('user/admin_get_users', copy_object(args), this.receive_users.bind(this)); + }, + + receive_users: function (resp) { + // receive page of users from server, render it + this.lastUsersResp = resp; + + var html = ''; + this.div.removeClass('loading'); + + var size = get_inner_window_size(); + var col_width = Math.floor(((size.width * 0.9) + 200) / 7); + + this.users = []; + if (resp.rows) this.users = resp.rows; + + html += this.getSidebarTabs('users', + [ + ['activity', "Activity Log"], + ['conf_keys', "Config Keys"], + ['api_keys', "API Keys"], + ['categories', "Categories"], + ['plugins', "Plugins"], + ['servers', "Servers"], + ['users', "Users"] + ] + ); + + var cols = ['Username', 'Full Name', 'Email Address', 'Status', 'Type', 'Created', 'Actions']; + + // html += '
'; + html += '
'; + + html += '
'; + html += 'User Accounts'; + // html += '
Refresh
'; + html += '
 
'; + html += '
'; + html += '
'; + + var self = this; + html += this.getPaginatedTable(resp, cols, 'user', function (user, idx) { + var actions = [ + 'Edit', + 'Delete' + ]; + return [ + '
' + self.getNiceUsername(user, true, col_width) + '
', + '
' + user.full_name + '
', + '', + user.active ? ' Active' : ' Suspended', + user.privileges.admin ? ' Admin' : 'Standard', + '' + get_nice_date(user.created, true) + '', + actions.join(' | ') + ]; + }); + + html += '
'; + html += '
'; + html += ''; + html += '
  Add User...
'; + + html += '
'; // padding + html += '
'; // sidebar tabs + + this.div.html(html); + + setTimeout(function () { + $('#fe_ul_search').keypress(function (event) { + if (event.keyCode == '13') { // enter key + event.preventDefault(); + $P().do_user_search($('#fe_ul_search').val()); + } + }) + .blur(function () { app.hideMessage(250); }) + .keydown(function () { app.hideMessage(); }); + }, 1); + }, + + do_user_search: function (username) { + // see if user exists, edit if so + app.api.post('user/admin_get_user', { username: username }, + function (resp) { + Nav.go('Admin?sub=edit_user&username=' + username); + }, + function (resp) { + app.doError("User not found: " + username, 10); + } + ); + }, + + edit_user: function (idx) { + // jump to edit sub + if (idx > -1) Nav.go('#Admin?sub=edit_user&username=' + this.users[idx].username); + else if (app.config.external_users) { + app.doError("Users are managed by an external system, so you cannot add users from here."); + } + else Nav.go('#Admin?sub=new_user'); + }, + + delete_user: function (idx) { + // delete user from search results + this.user = this.users[idx]; + this.show_delete_account_dialog(); + }, + + gosub_new_user: function (args) { + // create new user + var html = ''; + app.setWindowTitle("Add New User"); + this.div.removeClass('loading'); + + html += this.getSidebarTabs('new_user', + [ + ['activity', "Activity Log"], + ['conf_keys', "Config Keys"], + ['api_keys', "API Keys"], + ['categories', "Categories"], + ['plugins', "Plugins"], + ['servers', "Servers"], + ['users', "Users"], + ['new_user', "Add New User"] + ] + ); + + html += '
Add New User
'; + + html += '
'; + html += '
'; + + this.user = { + privileges: copy_object(config.default_privileges) + }; + + html += this.get_user_edit_html(); + + // notify user + html += get_form_table_row('Notify', ''); + html += get_form_table_caption("Select notification options for the new user."); + html += get_form_table_spacer(); + + // buttons at bottom + html += ''; + + html += '
'; + html += '
'; + + html += ''; + html += ''; + html += ''; + if (config.debug) { + html += ''; + html += ''; + } + html += ''; + html += '
Cancel
 
Randomize...
 
  Create User
'; + + html += '
'; + html += '
'; // table wrapper div + + html += ''; // sidebar tabs + + this.div.html(html); + + setTimeout(function () { + $('#fe_eu_username').focus(); + }, 1); + }, + + cancel_user_edit: function () { + // cancel editing user and return to list + Nav.go('Admin?sub=users'); + }, + + populate_random_user: function () { + // grab random user data (for testing only) + var self = this; + + $.ajax({ + url: 'http://api.randomuser.me/', + dataType: 'json', + success: function (data) { + // console.log(data); + if (data.results && data.results[0] && data.results[0].user) { + var user = data.results[0].user; + $('#fe_eu_username').val(user.username); + $('#fe_eu_email').val(user.email); + $('#fe_eu_fullname').val(ucfirst(user.name.first) + ' ' + ucfirst(user.name.last)); + $('#fe_eu_send_email').prop('checked', false); + self.generate_password(); + self.checkUserExists('eu'); + } + } + }); + }, + + do_new_user: function (force) { + // create new user + app.clearError(); + var user = this.get_user_form_json(); + if (!user) return; // error + + // if external auth is checked, password field will be disabled on "create user" form + // since password can't be null in storage random value will be generated (could be reset by admin later if auth setting will change) + // no one will know this password and as long as external auth is checked, it won't be ever used. + if (user.ext_auth) { + user.password = b64_md5(get_unique_id()).substring(0, 12); + } + + if (!user.username.length) { + return app.badField('#fe_eu_username', "Please enter a username for the new account."); + } + // username should be alphanumeric or email-like (for External Auth) + if (!user.username.match(/^[\w\.\-]+@?[\w\.\-]+$/)) { + return app.badField('#fe_eu_username', "Please make sure the username contains only alphanumerics, periods and dashes."); + } + if (!user.email.length) { + return app.badField('#fe_eu_email', "Please enter an e-mail address where the user can be reached."); + } + if (!user.email.match(/^\S+\@\S+$/)) { + return app.badField('#fe_eu_email', "The e-mail address you entered does not appear to be correct."); + } + if (!user.full_name.length) { + return app.badField('#fe_eu_fullname', "Please enter the user's first and last names."); + } + if (!user.password.length) { + return app.badField('#fe_eu_password', "Please enter a secure password to protect the account."); + } + + user.send_email = $('#fe_eu_send_email').is(':checked') ? 1 : 0; + + this.user = user; + + app.showProgress(1.0, "Creating user..."); + app.api.post('user/admin_create', user, this.new_user_finish.bind(this)); + }, + + new_user_finish: function (resp) { + // new user created successfully + app.hideProgress(); + + Nav.go('Admin?sub=edit_user&username=' + this.user.username); + + setTimeout(function () { + app.showMessage('success', "The new user account was created successfully."); + }, 150); + }, + + gosub_edit_user: function (args) { + // edit user subpage + this.div.addClass('loading'); + app.api.post('user/admin_get_user', { username: args.username }, this.receive_user.bind(this)); + }, + + receive_user: function (resp) { + // edit existing user + var html = ''; + app.setWindowTitle("Editing User \"" + (this.args.username) + "\""); + this.div.removeClass('loading'); + + html += this.getSidebarTabs('edit_user', + [ + ['activity', "Activity Log"], + ['conf_keys', "Config Keys"], + ['api_keys', "API Keys"], + ['categories', "Categories"], + ['plugins', "Plugins"], + ['servers', "Servers"], + ['users', "Users"], + ['edit_user', "Edit User"] + ] + ); + + html += '
Editing User “' + (this.args.username) + '”
'; + + html += '
'; + html += '
'; + html += ''; + + this.user = resp.user; + + html += this.get_user_edit_html(); + + html += ''; + + html += '
'; + html += '
'; + + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += '
Cancel
 
Delete Account...
 
  Save Changes
'; + + html += '
'; + html += '
'; + html += '
'; // table wrapper div + + html += ''; // sidebar tabs + + this.div.html(html); + + setTimeout(function () { + $('#fe_eu_username').attr('disabled', true); + $('#fe_eu_extauth').attr('disabled', true); + $P().setExternalAuth(); + + + if (app.config.external_users) { + app.showMessage('warning', "Users are managed by an external system, so making changes here may have little effect."); + // self.div.find('input').prop('disabled', true); + } + }, 1); + }, + + do_save_user: function () { + // create new user + app.clearError(); + var user = this.get_user_form_json(); + if (!user) return; // error + + // if changing password, give server a hint + if (user.password) { + user.new_password = user.password; + delete user.password; + } + + this.user = user; + + app.showProgress(1.0, "Saving user account..."); + app.api.post('user/admin_update', user, this.save_user_finish.bind(this)); + }, + + save_user_finish: function (resp, tx) { + // new user created successfully + app.hideProgress(); + app.showMessage('success', "The user was saved successfully."); + window.scrollTo(0, 0); + + // if we edited ourself, update header + if (this.args.username == app.username) { + app.user = resp.user; + app.updateHeaderInfo(); + } + + $('#fe_eu_password').val(''); + }, + + show_delete_account_dialog: function () { + // show dialog confirming account delete action + var self = this; + + var msg = "Are you sure you want to permanently delete the user account \"" + this.user.username + "\"? There is no way to undo this action, and no way to recover the data."; + + if (app.config.external_users) { + msg = "Are you sure you want to delete the user account \"" + this.user.username + "\"? Users are managed by an external system, so this will have little effect here."; + // return app.doError("Users are managed by an external system, so you cannot make changes here."); + } + + app.confirm('Delete Account', msg, 'Delete', function (result) { + if (result) { + app.showProgress(1.0, "Deleting Account..."); + app.api.post('user/admin_delete', { + username: self.user.username + }, self.delete_user_finish.bind(self)); + } + }); + }, + + delete_user_finish: function (resp, tx) { + // finished deleting, immediately log user out + var self = this; + app.hideProgress(); + + Nav.go('Admin?sub=users', 'force'); + + setTimeout(function () { + app.showMessage('success', "The user account '" + self.user.username + "' was deleted successfully."); + }, 150); + }, + + get_user_edit_html: function () { + // get html for editing a user (or creating a new one) + var html = ''; + var user = this.user; + + // user id + html += get_form_table_row('Username', + '' + + '' + + '' + + '
' + ); + html += get_form_table_caption("Enter the username which identifies this account. Once entered, it cannot be changed. "); + html += get_form_table_spacer(); + + // account status + html += get_form_table_row('Account Status', ''); + html += get_form_table_caption("'Suspended' means that the account remains in the system, but the user cannot log in."); + html += get_form_table_spacer(); + + // full name + html += get_form_table_row('Full Name', ''); + html += get_form_table_caption("User's first and last name. They will not be shared with anyone outside the server."); + html += get_form_table_spacer(); + + // email + html += get_form_table_row('Email Address', ''); + html += get_form_table_caption("This can be used to recover the password if the user forgets. It will not be shared with anyone outside the server."); + html += get_form_table_spacer(); + + // password with ext_auth checkbox + + var pwdDisabledIfExtAuth = user.ext_auth ? "disabled" : ' '; + var userExtAuthChecked = user.ext_auth ? 'checked="checked"' : ' ' + + html += get_form_table_row(user.password ? 'Change Password' : 'Password', ` « Generate Random`); + html += get_form_table_caption(user.password ? "Optionally enter a new password here to reset it. Please make it secure." : "Enter a password for the account. Please make it secure."); + html += get_form_table_row('', ``); + html += get_form_table_caption("use external authentication (it cannot be changed once user is created)"); + html += get_form_table_spacer(); + + // privilege list + var priv_html = ''; + var user_is_admin = !!user.privileges.admin; + + for (var idx = 0, len = config.privilege_list.length; idx < len; idx++) { + var priv = config.privilege_list[idx]; + var has_priv = !!user.privileges[priv.id]; + var priv_visible = (priv.id == 'admin') || !user_is_admin; + var priv_class = (priv.id == 'admin') ? 'priv_group_admin' : 'priv_group_other'; + + priv_html += '
'; + priv_html += ''; + priv_html += ''; + priv_html += '
'; + } + + // user can be limited to certain categories + var priv = { id: "cat_limit", title: "Limit to Categories" }; + var has_priv = !!user.privileges[priv.id]; + var priv_visible = !user_is_admin; + + priv_html += '
'; + priv_html += ''; + priv_html += ''; + priv_html += '
'; + + priv_html += '
'; + + // sort by title ascending + var categories = app.categories.sort(function (a, b) { + // return (b.title < a.title) ? 1 : -1; + return a.title.toLowerCase().localeCompare(b.title.toLowerCase()); + }); + + + for (var idx = 0, len = categories.length; idx < len; idx++) { + var cat = categories[idx]; + var priv = { id: 'cat_' + cat.id, title: cat.title }; + var has_priv = !!user.privileges[priv.id]; + var priv_visible = !!user.privileges.cat_limit; + + priv_html += '
'; + priv_html += ''; + priv_html += ''; + priv_html += '
'; + } + + priv_html += '
'; + + // user can be limited to certain server groups + var priv = { id: "grp_limit", title: "Limit to Server Groups" }; + var has_priv = !!user.privileges[priv.id]; + var priv_visible = !user_is_admin; + + priv_html += '
'; + priv_html += ''; + priv_html += ''; + priv_html += '
'; + + priv_html += '
'; + + // sort by title ascending + var groups = app.server_groups.sort(function (a, b) { + // return (b.title < a.title) ? 1 : -1; + return a.title.toLowerCase().localeCompare(b.title.toLowerCase()); + }); + + for (var idx = 0, len = groups.length; idx < len; idx++) { + var group = groups[idx]; + var priv = { id: 'grp_' + group.id, title: group.title }; + var has_priv = !!user.privileges[priv.id]; + var priv_visible = !!user.privileges.grp_limit; + + priv_html += '
'; + priv_html += ''; + priv_html += ''; + priv_html += '
'; + } + + priv_html += '
'; + + html += get_form_table_row('Privileges', priv_html); + html += get_form_table_caption("Select which privileges the user account should have. Administrators have all privileges."); + html += get_form_table_spacer(); + + return html; + }, + + change_admin_checkbox: function () { + // toggle admin checkbox + var is_checked = $('#fe_eu_priv_admin').is(':checked'); + if (is_checked) $('div.priv_group_other').hide(250); + else $('div.priv_group_other').show(250); + }, + + change_cat_checkbox: function () { + // toggle category limit checkbox + var is_checked = $('#fe_eu_priv_cat_limit').is(':checked'); + if (is_checked) $('div.priv_group_cat').show(250); + else $('div.priv_group_cat').hide(250); + }, + + change_grp_checkbox: function () { + // toggle server group limit checkbox + var is_checked = $('#fe_eu_priv_grp_limit').is(':checked'); + if (is_checked) $('div.priv_group_grp').show(250); + else $('div.priv_group_grp').hide(250); + }, + + get_user_form_json: function () { + // get user elements from form, used for new or edit + var user = { + username: trim($('#fe_eu_username').val().toLowerCase()), + active: $('#fe_eu_status').val(), + full_name: trim($('#fe_eu_fullname').val()), + email: trim($('#fe_eu_email').val()), + password: $('#fe_eu_password').val(), + ext_auth: $('#fe_eu_extauth').is(":checked"), + privileges: {} + }; + + user.privileges.admin = $('#fe_eu_priv_admin').is(':checked') ? 1 : 0; + + if (!user.privileges.admin) { + for (var idx = 0, len = config.privilege_list.length; idx < len; idx++) { + var priv = config.privilege_list[idx]; + user.privileges[priv.id] = $('#fe_eu_priv_' + priv.id).is(':checked') ? 1 : 0; + } + + // category limit privs + user.privileges.cat_limit = $('#fe_eu_priv_cat_limit').is(':checked') ? 1 : 0; + + if (user.privileges.cat_limit) { + var num_cat_privs = 0; + for (var idx = 0, len = app.categories.length; idx < len; idx++) { + var cat = app.categories[idx]; + var priv = { id: 'cat_' + cat.id }; + if ($('#fe_eu_priv_' + priv.id).is(':checked')) { + user.privileges[priv.id] = 1; + num_cat_privs++; + } + } + + if (!num_cat_privs) return app.doError("Please select at least one category privilege."); + } // cat limit + + // server group limit privs + user.privileges.grp_limit = $('#fe_eu_priv_grp_limit').is(':checked') ? 1 : 0; + + if (user.privileges.grp_limit) { + var num_grp_privs = 0; + for (var idx = 0, len = app.server_groups.length; idx < len; idx++) { + var grp = app.server_groups[idx]; + var priv = { id: 'grp_' + grp.id }; + if ($('#fe_eu_priv_' + priv.id).is(':checked')) { + user.privileges[priv.id] = 1; + num_grp_privs++; + } + } + + if (!num_grp_privs) return app.doError("Please select at least one server group privilege."); + } // grp limit + } // not admin + + return user; + }, + + generate_password: function () { + // generate random password + $('#fe_eu_password').val(b64_md5(get_unique_id()).substring(0, 8)); + }, + + // this will enbale/disable password field based on "ext_auth" checkbox + setExternalAuth: function () { + let pwd = $("#fe_eu_password") + let checkBox = $("#fe_eu_extauth") + let genButton = $("#generate_pwd") + if (checkBox.is(':checked')) { + pwd.val(' '); // set blank password if checked. It will be replcaed with random value on submitting + pwd.prop('disabled', true); + genButton.hide(); + } else { + pwd.prop('disabled', false); + genButton.show(); + } + } + +}); diff --git a/htdocs/jsonTree/icons.svg b/htdocs/jsonTree/icons.svg new file mode 100644 index 0000000..cc8298a --- /dev/null +++ b/htdocs/jsonTree/icons.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/htdocs/jsonTree/jsonTree.css b/htdocs/jsonTree/jsonTree.css new file mode 100644 index 0000000..3812440 --- /dev/null +++ b/htdocs/jsonTree/jsonTree.css @@ -0,0 +1,107 @@ +/* + * JSON Tree Viewer + * http://github.com/summerstyle/jsonTreeViewer + * + * Copyright 2017 Vera Lobacheva (http://iamvera.com) + * Released under the MIT license (LICENSE.txt) + */ + +/* Background for the tree. May use for element */ +.jsontree_bg { + background: #FFF; +} + +/* Styles for the container of the tree (e.g. fonts, margins etc.) */ +.jsontree_tree { + margin-left: 30px; + font-family: 'PT Mono', monospace; + font-size: 14px; +} + +/* Styles for a list of child nodes */ +.jsontree_child-nodes { + display: none; + margin-left: 35px; + margin-bottom: 5px; + line-height: 2; +} +.jsontree_node_expanded > .jsontree_value-wrapper > .jsontree_value > .jsontree_child-nodes { + display: block; +} + +/* Styles for labels */ +.jsontree_label-wrapper { + float: left; + margin-right: 8px; +} +.jsontree_label { + font-weight: normal; + vertical-align: top; + color: #000; + position: relative; + padding: 1px; + border-radius: 4px; + cursor: default; +} +.jsontree_node_marked > .jsontree_label-wrapper > .jsontree_label { + background: #fff2aa; +} + +/* Styles for values */ +.jsontree_value-wrapper { + display: block; + overflow: hidden; +} +.jsontree_node_complex > .jsontree_value-wrapper { + overflow: inherit; +} +.jsontree_value { + vertical-align: top; + display: inline; +} +.jsontree_value_null { + color: #777; + font-weight: bold; +} +.jsontree_value_string { + color: #025900; + font-weight: bold; +} +.jsontree_value_number { + color: #000E59; + font-weight: bold; +} +.jsontree_value_boolean { + color: #600100; + font-weight: bold; +} + +/* Styles for active elements */ +.jsontree_expand-button { + position: absolute; + top: 3px; + left: -15px; + display: block; + width: 11px; + height: 11px; + background-image: url('icons.svg'); +} +.jsontree_node_expanded > .jsontree_label-wrapper > .jsontree_label > .jsontree_expand-button { + background-position: 0 -11px; +} +.jsontree_show-more { + cursor: pointer; +} +.jsontree_node_expanded > .jsontree_value-wrapper > .jsontree_value > .jsontree_show-more { + display: none; +} +.jsontree_node_empty > .jsontree_label-wrapper > .jsontree_label > .jsontree_expand-button, +.jsontree_node_empty > .jsontree_value-wrapper > .jsontree_value > .jsontree_show-more { + display: none !important; +} +.jsontree_node_complex > .jsontree_label-wrapper > .jsontree_label { + cursor: pointer; +} +.jsontree_node_empty > .jsontree_label-wrapper > .jsontree_label { + cursor: default !important; +} diff --git a/htdocs/jsonTree/jsonTree.js b/htdocs/jsonTree/jsonTree.js new file mode 100644 index 0000000..51bbf01 --- /dev/null +++ b/htdocs/jsonTree/jsonTree.js @@ -0,0 +1,819 @@ +/** + * JSON Tree library (a part of jsonTreeViewer) + * http://github.com/summerstyle/jsonTreeViewer + * + * Copyright 2017 Vera Lobacheva (http://iamvera.com) + * Released under the MIT license (LICENSE.txt) + */ + +var jsonTree = (function() { + + /* ---------- Utilities ---------- */ + var utils = { + + /* + * Returns js-"class" of value + * + * @param val {any type} - value + * @returns {string} - for example, "[object Function]" + */ + getClass : function(val) { + return Object.prototype.toString.call(val); + }, + + /** + * Checks for a type of value (for valid JSON data types). + * In other cases - throws an exception + * + * @param val {any type} - the value for new node + * @returns {string} ("object" | "array" | "null" | "boolean" | "number" | "string") + */ + getType : function(val) { + if (val === null) { + return 'null'; + } + + switch (typeof val) { + case 'number': + return 'number'; + + case 'string': + return 'string'; + + case 'boolean': + return 'boolean'; + } + + switch(utils.getClass(val)) { + case '[object Array]': + return 'array'; + + case '[object Object]': + return 'object'; + } + + throw new Error('Bad type: ' + utils.getClass(val)); + }, + + /** + * Applies for each item of list some function + * and checks for last element of the list + * + * @param obj {Object | Array} - a list or a dict with child nodes + * @param func {Function} - the function for each item + */ + forEachNode : function(obj, func) { + var type = utils.getType(obj), + isLast; + + switch (type) { + case 'array': + isLast = obj.length - 1; + + obj.forEach(function(item, i) { + func(i, item, i === isLast); + }); + + break; + + case 'object': + var keys = Object.keys(obj).sort(); + + isLast = keys.length - 1; + + keys.forEach(function(item, i) { + func(item, obj[item], i === isLast); + }); + + break; + } + + }, + + /** + * Implements the kind of an inheritance by + * using parent prototype and + * creating intermediate constructor + * + * @param Child {Function} - a child constructor + * @param Parent {Function} - a parent constructor + */ + inherits : (function() { + var F = function() {}; + + return function(Child, Parent) { + F.prototype = Parent.prototype; + Child.prototype = new F(); + Child.prototype.constructor = Child; + }; + })(), + + /* + * Checks for a valid type of root node* + * + * @param {any type} jsonObj - a value for root node + * @returns {boolean} - true for an object or an array, false otherwise + */ + isValidRoot : function(jsonObj) { + switch (utils.getType(jsonObj)) { + case 'object': + case 'array': + return true; + default: + return false; + } + }, + + /** + * Extends some object + */ + extend : function(targetObj, sourceObj) { + for (var prop in sourceObj) { + if (sourceObj.hasOwnProperty(prop)) { + targetObj[prop] = sourceObj[prop]; + } + } + } + }; + + + /* ---------- Node constructors ---------- */ + + /** + * The factory for creating nodes of defined type. + * + * ~~~ Node ~~~ is a structure element of an onject or an array + * with own label (a key of an object or an index of an array) + * and value of any json data type. The root object or array + * is a node without label. + * {... + * [+] "label": value, + * ...} + * + * Markup: + *
  • + * + * + * + * "label" + * + * : + * + * <(div|span) class="jsontree_value jsontree_value_(object|array|boolean|null|number|string)"> + * ... + * + *
  • + * + * @param label {string} - key name + * @param val {Object | Array | string | number | boolean | null} - a value of node + * @param isLast {boolean} - true if node is last in list of siblings + * + * @return {Node} + */ + function Node(label, val, isLast) { + var nodeType = utils.getType(val); + + if (nodeType in Node.CONSTRUCTORS) { + return new Node.CONSTRUCTORS[nodeType](label, val, isLast); + } else { + throw new Error('Bad type: ' + utils.getClass(val)); + } + } + + Node.CONSTRUCTORS = { + 'boolean' : NodeBoolean, + 'number' : NodeNumber, + 'string' : NodeString, + 'null' : NodeNull, + 'object' : NodeObject, + 'array' : NodeArray + }; + + + /* + * The constructor for simple types (string, number, boolean, null) + * {... + * [+] "label": value, + * ...} + * value = string || number || boolean || null + * + * Markup: + *
  • + * + * "age" + * : + * + * 25 + * , + *
  • + * + * @abstract + * @param label {string} - key name + * @param val {string | number | boolean | null} - a value of simple types + * @param isLast {boolean} - true if node is last in list of parent childNodes + */ + function _NodeSimple(label, val, isLast) { + if (this.constructor === _NodeSimple) { + throw new Error('This is abstract class'); + } + + var self = this, + el = document.createElement('li'), + labelEl, + template = function(label, val) { + var str = '\ + \ + "' + + label + + '" : \ + \ + \ + ' + + val + + '' + + (!isLast ? ',' : '') + + ''; + + return str; + }; + + self.label = label; + self.isComplex = false; + + el.classList.add('jsontree_node'); + el.innerHTML = template(label, val); + + self.el = el; + + labelEl = el.querySelector('.jsontree_label'); + + labelEl.addEventListener('click', function(e) { + if (e.altKey) { + self.toggleMarked(); + return; + } + + if (e.shiftKey) { + document.getSelection().removeAllRanges(); + alert(self.getJSONPath()); + return; + } + }, false); + } + + _NodeSimple.prototype = { + constructor : _NodeSimple, + + /** + * Mark node + */ + mark : function() { + this.el.classList.add('jsontree_node_marked'); + }, + + /** + * Unmark node + */ + unmark : function() { + this.el.classList.remove('jsontree_node_marked'); + }, + + /** + * Mark or unmark node + */ + toggleMarked : function() { + this.el.classList.toggle('jsontree_node_marked'); + }, + + /** + * Expands parent node of this node + * + * @param isRecursive {boolean} - if true, expands all parent nodes + * (from node to root) + */ + expandParent : function(isRecursive) { + if (!this.parent) { + return; + } + + this.parent.expand(); + this.parent.expandParent(isRecursive); + }, + + /** + * Returns JSON-path of this + * + * @param isInDotNotation {boolean} - kind of notation for returned json-path + * (by default, in bracket notation) + * @returns {string} + */ + getJSONPath : function(isInDotNotation) { + if (this.isRoot) { + return "$"; + } + + var currentPath; + + if (this.parent.type === 'array') { + currentPath = "[" + this.label + "]"; + } else { + currentPath = isInDotNotation ? "." + this.label : "['" + this.label + "']"; + } + + return this.parent.getJSONPath(isInDotNotation) + currentPath; + } + }; + + + /* + * The constructor for boolean values + * {... + * [+] "label": boolean, + * ...} + * boolean = true || false + * + * @constructor + * @param label {string} - key name + * @param val {boolean} - value of boolean type, true or false + * @param isLast {boolean} - true if node is last in list of parent childNodes + */ + function NodeBoolean(label, val, isLast) { + this.type = "boolean"; + + _NodeSimple.call(this, label, val, isLast); + } + utils.inherits(NodeBoolean,_NodeSimple); + + + /* + * The constructor for number values + * {... + * [+] "label": number, + * ...} + * number = 123 + * + * @constructor + * @param label {string} - key name + * @param val {number} - value of number type, for example 123 + * @param isLast {boolean} - true if node is last in list of parent childNodes + */ + function NodeNumber(label, val, isLast) { + this.type = "number"; + + _NodeSimple.call(this, label, val, isLast); + } + utils.inherits(NodeNumber,_NodeSimple); + + + /* + * The constructor for string values + * {... + * [+] "label": string, + * ...} + * string = "abc" + * + * @constructor + * @param label {string} - key name + * @param val {string} - value of string type, for example "abc" + * @param isLast {boolean} - true if node is last in list of parent childNodes + */ + function NodeString(label, val, isLast) { + this.type = "string"; + + _NodeSimple.call(this, label, '"' + val + '"', isLast); + } + utils.inherits(NodeString,_NodeSimple); + + + /* + * The constructor for null values + * {... + * [+] "label": null, + * ...} + * + * @constructor + * @param label {string} - key name + * @param val {null} - value (only null) + * @param isLast {boolean} - true if node is last in list of parent childNodes + */ + function NodeNull(label, val, isLast) { + this.type = "null"; + + _NodeSimple.call(this, label, val, isLast); + } + utils.inherits(NodeNull,_NodeSimple); + + + /* + * The constructor for complex types (object, array) + * {... + * [+] "label": value, + * ...} + * value = object || array + * + * Markup: + *
  • + * + * + * + * "label" + * + * : + * + *
    + * { + *
      + * } + * , + *
    + *
  • + * + * @abstract + * @param label {string} - key name + * @param val {Object | Array} - a value of complex types, object or array + * @param isLast {boolean} - true if node is last in list of parent childNodes + */ + function _NodeComplex(label, val, isLast) { + if (this.constructor === _NodeComplex) { + throw new Error('This is abstract class'); + } + + var self = this, + el = document.createElement('li'), + template = function(label, sym) { + var comma = (!isLast) ? ',' : '', + str = '\ +
    \ +
    \ + ' + sym[0] + '\ + \ +
      \ + ' + sym[1] + '' + + '
      ' + comma + + '
      '; + + if (label !== null) { + str = '\ + \ + ' + + '' + + '"' + label + + '" : \ + ' + str; + } + + return str; + }, + childNodesUl, + labelEl, + moreContentEl, + childNodes = []; + + self.label = label; + self.isComplex = true; + + el.classList.add('jsontree_node'); + el.classList.add('jsontree_node_complex'); + el.innerHTML = template(label, self.sym); + + childNodesUl = el.querySelector('.jsontree_child-nodes'); + + if (label !== null) { + labelEl = el.querySelector('.jsontree_label'); + moreContentEl = el.querySelector('.jsontree_show-more'); + + labelEl.addEventListener('click', function(e) { + if (e.altKey) { + self.toggleMarked(); + return; + } + + if (e.shiftKey) { + document.getSelection().removeAllRanges(); + alert(self.getJSONPath()); + return; + } + + self.toggle(e.ctrlKey || e.metaKey); + }, false); + + moreContentEl.addEventListener('click', function(e) { + self.toggle(e.ctrlKey || e.metaKey); + }, false); + + self.isRoot = false; + } else { + self.isRoot = true; + self.parent = null; + + el.classList.add('jsontree_node_expanded'); + } + + self.el = el; + self.childNodes = childNodes; + self.childNodesUl = childNodesUl; + + utils.forEachNode(val, function(label, node, isLast) { + self.addChild(new Node(label, node, isLast)); + }); + + self.isEmpty = !Boolean(childNodes.length); + if (self.isEmpty) { + el.classList.add('jsontree_node_empty'); + } + } + + utils.inherits(_NodeComplex, _NodeSimple); + + utils.extend(_NodeComplex.prototype, { + constructor : _NodeComplex, + + /* + * Add child node to list of child nodes + * + * @param child {Node} - child node + */ + addChild : function(child) { + this.childNodes.push(child); + this.childNodesUl.appendChild(child.el); + child.parent = this; + }, + + /* + * Expands this list of node child nodes + * + * @param isRecursive {boolean} - if true, expands all child nodes + */ + expand : function(isRecursive){ + if (this.isEmpty) { + return; + } + + if (!this.isRoot) { + this.el.classList.add('jsontree_node_expanded'); + } + + if (isRecursive) { + this.childNodes.forEach(function(item, i) { + if (item.isComplex) { + item.expand(isRecursive); + } + }); + } + }, + + /* + * Collapses this list of node child nodes + * + * @param isRecursive {boolean} - if true, collapses all child nodes + */ + collapse : function(isRecursive) { + if (this.isEmpty) { + return; + } + + if (!this.isRoot) { + this.el.classList.remove('jsontree_node_expanded'); + } + + if (isRecursive) { + this.childNodes.forEach(function(item, i) { + if (item.isComplex) { + item.collapse(isRecursive); + } + }); + } + }, + + /* + * Expands collapsed or collapses expanded node + * + * @param {boolean} isRecursive - Expand all child nodes if this node is expanded + * and collapse it otherwise + */ + toggle : function(isRecursive) { + if (this.isEmpty) { + return; + } + + this.el.classList.toggle('jsontree_node_expanded'); + + if (isRecursive) { + var isExpanded = this.el.classList.contains('jsontree_node_expanded'); + + this.childNodes.forEach(function(item, i) { + if (item.isComplex) { + item[isExpanded ? 'expand' : 'collapse'](isRecursive); + } + }); + } + }, + + /** + * Find child nodes that match some conditions and handle it + * + * @param {Function} matcher + * @param {Function} handler + * @param {boolean} isRecursive + */ + findChildren : function(matcher, handler, isRecursive) { + if (this.isEmpty) { + return; + } + + this.childNodes.forEach(function(item, i) { + if (matcher(item)) { + handler(item); + } + + if (item.isComplex && isRecursive) { + item.findChildren(matcher, handler, isRecursive); + } + }); + } + }); + + + /* + * The constructor for object values + * {... + * [+] "label": object, + * ...} + * object = {"abc": "def"} + * + * @constructor + * @param label {string} - key name + * @param val {Object} - value of object type, {"abc": "def"} + * @param isLast {boolean} - true if node is last in list of siblings + */ + function NodeObject(label, val, isLast) { + this.sym = ['{', '}']; + this.type = "object"; + + _NodeComplex.call(this, label, val, isLast); + } + utils.inherits(NodeObject,_NodeComplex); + + + /* + * The constructor for array values + * {... + * [+] "label": array, + * ...} + * array = [1,2,3] + * + * @constructor + * @param label {string} - key name + * @param val {Array} - value of array type, [1,2,3] + * @param isLast {boolean} - true if node is last in list of siblings + */ + function NodeArray(label, val, isLast) { + this.sym = ['[', ']']; + this.type = "array"; + + _NodeComplex.call(this, label, val, isLast); + } + utils.inherits(NodeArray, _NodeComplex); + + + /* ---------- The tree constructor ---------- */ + + /* + * The constructor for json tree. + * It contains only one Node (Array or Object), without property name. + * CSS-styles of .tree define main tree styles like font-family, + * font-size and own margins. + * + * Markup: + *
        + * {Node} + *
      + * + * @constructor + * @param jsonObj {Object | Array} - data for tree + * @param domEl {DOMElement} - DOM-element, wrapper for tree + */ + function Tree(jsonObj, domEl) { + this.wrapper = document.createElement('ul'); + this.wrapper.className = 'jsontree_tree clearfix'; + + this.rootNode = null; + + this.sourceJSONObj = jsonObj; + + this.loadData(jsonObj); + this.appendTo(domEl); + } + + Tree.prototype = { + constructor : Tree, + + /** + * Fill new data in current json tree + * + * @param {Object | Array} jsonObj - json-data + */ + loadData : function(jsonObj) { + if (!utils.isValidRoot(jsonObj)) { + alert('The root should be an object or an array'); + return; + } + + this.sourceJSONObj = jsonObj; + + this.rootNode = new Node(null, jsonObj, 'last'); + this.wrapper.innerHTML = ''; + this.wrapper.appendChild(this.rootNode.el); + }, + + /** + * Appends tree to DOM-element (or move it to new place) + * + * @param {DOMElement} domEl + */ + appendTo : function(domEl) { + domEl.appendChild(this.wrapper); + }, + + /** + * Expands all tree nodes (objects or arrays) recursively + * + * @param {Function} filterFunc - 'true' if this node should be expanded + */ + expand : function(filterFunc) { + if (this.rootNode.isComplex) { + if (typeof filterFunc == 'function') { + this.rootNode.childNodes.forEach(function(item, i) { + if (item.isComplex && filterFunc(item)) { + item.expand(); + } + }); + } else { + this.rootNode.expand('recursive'); + } + } + }, + + /** + * Collapses all tree nodes (objects or arrays) recursively + */ + collapse : function() { + if (typeof this.rootNode.collapse === 'function') { + this.rootNode.collapse('recursive'); + } + }, + + /** + * Returns the source json-string (pretty-printed) + * + * @param {boolean} isPrettyPrinted - 'true' for pretty-printed string + * @returns {string} - for exemple, '{"a":2,"b":3}' + */ + toSourceJSON : function(isPrettyPrinted) { + if (!isPrettyPrinted) { + return JSON.stringify(this.sourceJSONObj); + } + + var DELIMETER = "[%^$#$%^%]", + jsonStr = JSON.stringify(this.sourceJSONObj, null, DELIMETER); + + jsonStr = jsonStr.split("\n").join("
      "); + jsonStr = jsonStr.split(DELIMETER).join("    "); + + return jsonStr; + }, + + /** + * Find all nodes that match some conditions and handle it + */ + findAndHandle : function(matcher, handler) { + this.rootNode.findChildren(matcher, handler, 'isRecursive'); + }, + + /** + * Unmark all nodes + */ + unmarkAll : function() { + this.rootNode.findChildren(function(node) { + return true; + }, function(node) { + node.unmark(); + }, 'isRecursive'); + } + }; + + + /* ---------- Public methods ---------- */ + return { + /** + * Creates new tree by data and appends it to the DOM-element + * + * @param jsonObj {Object | Array} - json-data + * @param domEl {DOMElement} - the wrapper element + * @returns {Tree} + */ + create : function(jsonObj, domEl) { + return new Tree(jsonObj, domEl); + } + }; +})(); diff --git a/htdocs/jsonTree/reset.css b/htdocs/jsonTree/reset.css new file mode 100644 index 0000000..eb179df --- /dev/null +++ b/htdocs/jsonTree/reset.css @@ -0,0 +1,59 @@ +html, body, div, span, applet, object, +h1, h2, h3, h4, h5, h6, p, a, em, img, +strong, ol, ul, li, dl, dd, dt, +form, label, input, +article, aside, canvas, details, +embed, figure, figcaption, footer, +header, hgroup, menu, nav, output, +ruby, section, summary, time, mark, +audio, video { + margin: 0; + padding: 0; + border: 0; + outline: 0; + font-style: inherit; + font-size: 100%; + font-family: inherit; + vertical-align: baseline; + background: transparent; +} +:focus { + outline: 0; +} +ul, ol { + list-style: none; +} +html { + height: 100%; +} +body { + padding: 0; + margin: 0; + font-family: 'Trebuchet MS', Arial, sans-serif; + font-size: 14px; + color: #000; + height: 100%; + min-height: 100%; + background: #FFF; + line-height: 1; + overflow-y: scroll; +} +img { + border: 0; +} +a:link, a:visited { + outline: none; + text-decoration: none; + color: #000; +} +.clear { + clear: both; +} +.clearfix:after { + content: "."; + display: block; + clear: both; + visibility: hidden; + line-height: 0; + height: 0; +} \ No newline at end of file diff --git a/lib/api.js b/lib/api.js new file mode 100644 index 0000000..4e748d2 --- /dev/null +++ b/lib/api.js @@ -0,0 +1,566 @@ +// Cronicle API Layer +// Copyright (c) 2015 Joseph Huckaby +// Released under the MIT License + + +const assert = require("assert"); +const Class = require("pixl-class"); +const Tools = require("pixl-tools"); +const crypto = require('crypto'); + +module.exports = Class.create({ + + __mixins: [ + require('./api/config.js'), + require('./api/category.js'), + require('./api/group.js'), + require('./api/plugin.js'), + require('./api/event.js'), + require('./api/job.js'), + require('./api/admin.js'), + require('./api/apikey.js'), + require('./api/confkey.js') + ], + + api_ping: function (args, callback) { + // hello + callback({ code: 0 }); + }, + + api_echo: function (args, callback) { + // for testing: adds 1 second delay, echoes everything back + setTimeout(function () { + callback({ + code: 0, + query: args.query || {}, + params: args.params || {}, + files: args.files || {} + }); + }, 1000); + }, + + api_check_user_exists: function (args, callback) { + // checks if username is taken (used for showing green checkmark on form) + var self = this; + var query = args.query; + var path = 'users/' + this.usermgr.normalizeUsername(query.username); + + if (!this.requireParams(query, { + username: this.usermgr.usernameMatch + }, callback)) return; + + // do not cache this API response + this.forceNoCacheResponse(args); + + this.storage.get(path, function (err, user) { + callback({ code: 0, user_exists: !!user }); + }); + }, + + api_status: function (args, callback) { + // simple status, used by monitoring tools + var tick_age = 0; + var now = Tools.timeNow(); + if (this.lastTick) tick_age = now - this.lastTick; + + // do not cache this API response + this.forceNoCacheResponse(args); + + var data = { + code: 0, + version: this.server.__version, + node: process.version, + hostname: this.server.hostname, + ip: this.server.ip, + pid: process.pid, + now: now, + uptime: Math.floor(now - (this.server.started || now)), + last_tick: this.lastTick || now, + tick_age: tick_age, + cpu: process.cpuUsage(), + mem: process.memoryUsage() + }; + + callback(data); + + // self-check: if tick_age is over 60 seconds, log a level 1 debug alert + if (tick_age > 60) { + var msg = "EMERGENCY: Tick age is over 60 seconds (" + Math.floor(tick_age) + "s) -- Server should be restarted immediately."; + this.logDebug(1, msg, data); + + // JH 2018-08-28 Commenting this out for now, because an unsecured API should not have the power to cause an internal restart. + // This kind of thing should be handled by external monitoring tools. + // this.restartLocalServer({ reason: msg }); + } + }, + + forceNoCacheResponse: function (args) { + // make sure this response isn't cached, ever + args.response.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate, proxy-revalidate'); + args.response.setHeader('Expires', 'Thu, 01 Jan 1970 00:00:00 GMT'); + }, + + getServerBaseAPIURL: function (hostname, ip) { + // construct fully-qualified URL to API on specified hostname + // use proper protocol and ports as needed + var api_url = ''; + + if (ip && !this.server.config.get('server_comm_use_hostnames')) hostname = ip; + + if (this.web.config.get('https') && this.web.config.get('https_force')) { + api_url = 'https://' + hostname; + if (this.web.config.get('https_port') != 443) api_url += ':' + this.web.config.get('https_port'); + } + else { + api_url = 'http://' + hostname; + if (this.web.config.get('http_port') != 80) api_url += ':' + this.web.config.get('http_port'); + } + api_url += this.api.config.get('base_uri'); + + return api_url; + }, + + validateOptionalParams: function (params, rules, callback) { + // vaildate optional params given rule set + assert(arguments.length == 3, "Wrong number of arguments to validateOptionalParams"); + + for (var key in rules) { + if (key in params) { + var rule = rules[key]; + var type_regexp = rule[0]; + var value_regexp = rule[1]; + var value = params[key]; + var type_value = typeof (value); + + if (!type_value.match(type_regexp)) { + this.doError('api', "Malformed parameter type: " + key + " (" + type_value + ")", callback); + return false; + } + else if (!value.toString().match(value_regexp)) { + this.doError('api', "Malformed parameter value: " + key, callback); + return false; + } + } + } + + return true; + }, + + requireValidEventData: function (event, callback) { + // make sure params contains valid event data (optional params) + // otherwise throw an API error and return false + // used by create_event, update_event, run_event and update_job APIs + var RE_TYPE_STRING = /^(string)$/, + RE_TYPE_BOOL = /^(boolean|number)$/, + RE_TYPE_NUM = /^(number)$/, + RE_ALPHANUM = /^\w+$/, + RE_POS_INT = /^\d+$/, + RE_BOOL = /^(\d+|true|false)$/; + + var rules = { + algo: [RE_TYPE_STRING, RE_ALPHANUM], + api_key: [RE_TYPE_STRING, RE_ALPHANUM], + catch_up: [RE_TYPE_BOOL, RE_BOOL], + category: [RE_TYPE_STRING, RE_ALPHANUM], + chain: [RE_TYPE_STRING, /^\w*$/], + chain_error: [RE_TYPE_STRING, /^\w*$/], + cpu_limit: [RE_TYPE_NUM, RE_POS_INT], + cpu_sustain: [RE_TYPE_NUM, RE_POS_INT], + created: [RE_TYPE_NUM, RE_POS_INT], + detached: [RE_TYPE_BOOL, RE_BOOL], + enabled: [RE_TYPE_BOOL, RE_BOOL], + id: [RE_TYPE_STRING, RE_ALPHANUM], + log_max_size: [RE_TYPE_NUM, RE_POS_INT], + max_children: [RE_TYPE_NUM, RE_POS_INT], + memory_limit: [RE_TYPE_NUM, RE_POS_INT], + memory_sustain: [RE_TYPE_NUM, RE_POS_INT], + modified: [RE_TYPE_NUM, RE_POS_INT], + multiplex: [RE_TYPE_BOOL, RE_BOOL], + notes: [RE_TYPE_STRING, /.*/], + notify_fail: [RE_TYPE_STRING, /.*/], + notify_success: [RE_TYPE_STRING, /.*/], + plugin: [RE_TYPE_STRING, RE_ALPHANUM], + queue: [RE_TYPE_BOOL, RE_BOOL], + queue_max: [RE_TYPE_NUM, RE_POS_INT], + retries: [RE_TYPE_NUM, RE_POS_INT], + retry_delay: [RE_TYPE_NUM, RE_POS_INT], + stagger: [RE_TYPE_NUM, RE_POS_INT], + target: [RE_TYPE_STRING, /^[\w\-\.]+$/], + timeout: [RE_TYPE_NUM, RE_POS_INT], + timezone: [RE_TYPE_STRING, /.*/], + title: [RE_TYPE_STRING, /\S/], + username: [RE_TYPE_STRING, /^[\w\.\-]+@?[\w\.\-]+$/], + web_hook: [RE_TYPE_STRING, /(^$|\w+|https?\:\/\/\S+)/i] + }; + if (!this.validateOptionalParams(event, rules, callback)) return false; + + // params + if (("params" in event) && (typeof (event.params) != 'object')) { + this.doError('api', "Malformed event parameter: params (must be object)", callback); + return false; + } + + // timing (can be falsey, or object) + if (event.timing) { + if (typeof (event.timing) != 'object') { + this.doError('api', "Malformed event parameter: timing (must be object)", callback); + return false; + } + + // check timing keys, should all be arrays of ints + var timing = event.timing; + for (var key in timing) { + if (!key.match(/^(years|months|days|weekdays|hours|minutes)$/)) { + this.doError('api', "Unknown event timing parameter: " + key, callback); + return false; + } + var values = timing[key]; + if (!Tools.isaArray(values)) { + this.doError('api', "Malformed event timing parameter: " + key + " (must be array)", callback); + return false; + } + for (var idx = 0, len = values.length; idx < len; idx++) { + var value = values[idx]; + if (typeof (value) != 'number') { + this.doError('api', "Malformed event timing parameter: " + key + " (must be array of numbers)", callback); + return false; + } + if ((key == 'years') && (value < 1)) { + this.doError('api', "Malformed event timing parameter: " + key + " (value out of range: " + value + ")", callback); + return false; + } + if ((key == 'months') && ((value < 1) || (value > 12))) { + this.doError('api', "Malformed event timing parameter: " + key + " (value out of range: " + value + ")", callback); + return false; + } + if ((key == 'days') && ((value < 1) || (value > 31))) { + this.doError('api', "Malformed event timing parameter: " + key + " (value out of range: " + value + ")", callback); + return false; + } + if ((key == 'weekdays') && ((value < 0) || (value > 6))) { + this.doError('api', "Malformed event timing parameter: " + key + " (value out of range: " + value + ")", callback); + return false; + } + if ((key == 'hours') && ((value < 0) || (value > 23))) { + this.doError('api', "Malformed event timing parameter: " + key + " (value out of range: " + value + ")", callback); + return false; + } + if ((key == 'minutes') && ((value < 0) || (value > 59))) { + this.doError('api', "Malformed event timing parameter: " + key + " (value out of range: " + value + ")", callback); + return false; + } + } + } + } // timing + + return true; + }, + + requireValidUser: function (session, user, callback) { + // make sure user and session are valid + // otherwise throw an API error and return false + + if (session && (session.type == 'api')) { + // session is simulated, created by API key + if (!user) { + return this.doError('api', "Invalid API Key: " + session.api_key, callback); + } + if (!user.active) { + return this.doError('api', "API Key is disabled: " + session.api_key, callback); + } + return true; + } // api key + + if (!session) { + return this.doError('session', "Session has expired or is invalid.", callback); + } + if (!user) { + return this.doError('user', "User not found: " + session.username, callback); + } + if (!user.active) { + return this.doError('user', "User account is disabled: " + session.username, callback); + } + return true; + }, + + requireAdmin: function (session, user, callback) { + // make sure user and session are valid, and user is an admin + // otherwise throw an API error and return false + if (!this.requireValidUser(session, user, callback)) return false; + + if (session.type == 'api') { + // API Keys cannot be admins + return this.doError('api', "API Key cannot use administrator features", callback); + } + + if (!user.privileges.admin) { + return this.doError('user', "User is not an administrator: " + session.username, callback); + } + + return true; + }, + + hasPrivilege: function (user, priv_id) { + // return true if user has privilege, false otherwise + if (user.privileges.admin) return true; // admins can do everything + if (user.privileges[priv_id]) return true; + return false; + }, + + requirePrivilege: function (user, priv_id, callback) { + // make sure user has the specified privilege + // otherwise throw an API error and return false + if (this.hasPrivilege(user, priv_id)) return true; + + if (user.key) { + return this.doError('api', "API Key ('" + user.title + "') does not have the required privileges to perform this action (" + priv_id + ").", callback); + } + else { + return this.doError('user', "User '" + user.username + "' does not have the required account privileges to perform this action (" + priv_id + ").", callback); + } + }, + + requireCategoryPrivilege: function (user, cat_id, callback) { + // make sure user has the specified category privilege + // otherwise throw an API error and return false + if (user.privileges.admin) return true; // admins can do everything + if (!user.privileges.cat_limit) return true; // user is not limited to categories + + var priv_id = 'cat_' + cat_id; + return this.requirePrivilege(user, priv_id, callback); + }, + + requireGroupPrivilege: function (args, user, grp_id, callback) { + // make sure user has the specified server group privilege + // otherwise throw an API error and return false + if (user.privileges.admin) return true; // admins can do everything + if (!user.privileges.grp_limit) return true; // user is not limited to groups + + var priv_id = 'grp_' + grp_id; + var result = this.hasPrivilege(user, priv_id); + if (result) return true; + + // user may have targeted an individual server, so find its groups + if (!args.server_groups) return false; // no groups loaded? hmmm... + var groups = args.server_groups.filter(function (group) { + return grp_id.match(group.regexp); + }); + + // we just need one group to match, then the user has permission to target the server + for (var idx = 0, len = groups.length; idx < len; idx++) { + priv_id = 'grp_' + groups[idx].id; + if (this.hasPrivilege(user, priv_id, callback)) return true; + } + + // user does not have group privilege + if (user.key) { + return this.doError('api', "API Key ('" + user.title + "') does not have the required privileges to perform this action (" + priv_id + ").", callback); + } + else { + return this.doError('user', "User '" + user.username + "' does not have the required account privileges to perform this action (" + priv_id + ").", callback); + } + }, + + requiremanager: function (args, callback) { + // make sure we are the manager server + // otherwise throw an API error and return false + if (this.multi.manager) return true; + + var status = "200 OK"; + var headers = {}; + + if (this.multi.managerHostname) { + // we know who manager is, so let's give the client a hint + status = "302 Found"; + + var url = ''; + if (this.web.config.get('https') && this.web.config.get('https_force')) { + url = 'https://' + (this.server.config.get('server_comm_use_hostnames') ? this.multi.managerHostname : this.multi.managerIP); + if (this.web.config.get('https_port') != 443) url += ':' + this.web.config.get('https_port'); + } + else { + url = 'http://' + (this.server.config.get('server_comm_use_hostnames') ? this.multi.managerHostname : this.multi.managerIP); + if (this.web.config.get('http_port') != 80) url += ':' + this.web.config.get('http_port'); + } + url += args.request.url; + + headers['Location'] = url; + } + + var msg = "This API call can only be invoked on the manager server."; + // this.logError( 'manager', msg ); + callback({ code: 'manager', description: msg }, status, headers); + return false; + }, + + getClientInfo: function (args, params) { + // proxy over to user module + // var info = this.usermgr.getClientInfo(args, params); + var info = null; + if (params) info = Tools.copyHash(params, true); + else info = {}; + + info.ip = args.ip; + info.headers = args.request.headers; + + // augment with our own additions + if (args.admin_user) info.username = args.admin_user.username; + else if (args.user) { + if (args.user.key) { + // API Key + info.api_key = args.user.key; + info.api_title = args.user.title; + } + else { + info.username = args.user.username; + } + } + + return info; + }, + + // check if storage list contains items with the same key/s as target object + // returns number of items with matching keys (to validate insert/update) + validateUnique: async function (listName, obj, keyArray) { + return new Promise((resolve, reject) => { + if (!keyArray || !Array.isArray(keyArray)) resolve(0); + this.storage.listGet(listName, 0, 0, function (err, items, list) { + if (Array.isArray(items)) { + let arr = items.filter(e => { + let result = false; + keyArray.forEach(k => { if (obj[k] == e[k]) result = true }) + return result; + }) + resolve(arr.length); + + } + resolve(0); + }); + }); + + }, + + loadSession: function (args, callback) { + // Load user session or validate API Key + var self = this; + + // git hub integration + if (args.request.headers['x-hub-signature'] && self.server.config.get('git_hub_key')) { + + let gitInfo = args.request.params; + + let expectedSignature = "sha1=" + crypto.createHmac("sha1", self.server.config.get('git_hub_key')) + .update(JSON.stringify(gitInfo)) + .digest("hex"); + if (expectedSignature == args.request.headers['X-Hub-Signature']) { + return callback( + null + , { type: "github", username: (gitInfo.sender || {}).login || 'unknown' } + , { username: "internal", active: true, privileges: { run_events: 1 } } + ); + + } + else { + return callback(new Error("Invalid signature, expected " + expectedSignature), null, null); + } + } + + var session_id = args.cookies['session_id'] || args.request.headers['x-session-id'] || args.params.session_id || args.query.session_id; + + if (session_id) { + + // digest auth + // if(session_id == Tools.digestHex(Math.floor(Tools.timeNow(true)/3600) + self.server.config.get('secret_key'))) { // digest of current hour + // return callback(null, {type: "api", username: "internal"}, { username: "internal", active:true, privileges: {run_events:1} }); + // } + + this.storage.get('sessions/' + session_id, function (err, session) { + if (err) return callback(err, null, null); + + // also load user + self.storage.get('users/' + self.usermgr.normalizeUsername(session.username), function (err, user) { + if (err) return callback(err, null, null); + + // set type to discern this from API Key sessions + session.type = 'user'; + + // get session_id out of args.params, so it doesn't interfere with API calls + delete args.params.session_id; + + // pass both session and user to callback + callback(null, session, user); + }); + }); + return; + } + + // no session found, look for API Key + var api_key = args.request.headers['x-api-key'] || args.request.headers['x-gitlab-token'] || args.params.api_key || args.query.api_key; + + if (this.temp_keys[api_key]) { // check if temp api key exists + return callback(null, { type: "api", username: "internal" }, { username: "internal", active: true, privileges: { run_events: 1, abort_events: 1 } }); + } + + // if no api key also check for git hub integration + if (args.request.headers['x-hub-signature'] && self.server.config.get('git_hub_key') && !api_key) { + + if (!args.params) return callback(new Error("Invalid Payload"), null, null); + + let gitData = args.params; + let gitUser = (gitData.sender || {}).login || 'unknown' + let repo = (gitData.repository || {}).full_name || 'unknown'; + + let key = self.server.config.get('git_hub_key') || process.env('GIT_HUB_KEY') || self.server.config.get('secret_key') + + let expectedSignature = "sha1=" + crypto.createHmac("sha1", `${key}`) + .update(JSON.stringify(gitData)) + .digest("hex"); + if (expectedSignature == args.request.headers['x-hub-signature']) { + return callback( + null + , { type: "git", signature: expectedSignature } + , { username: gitUser, gitrepo: repo, signature: expectedSignature, active: true, privileges: { run_events: 1 } } + ); + + } + else { + return callback(new Error("Invalid Signature"), null, null); + } + } + + // finally return error if no session or api key detected + if (!api_key) return callback(new Error("No Session ID or API Key could be found"), null, null); + + if (!api_key) return callback(new Error("No Session ID or API Key could be found"), null, null); + this.storage.listFind('global/api_keys', { key: api_key }, function (err, item) { + if (err) return callback(new Error("API Key is invalid: " + api_key), null, null); + + // create simulated session and user objects + var session = { + type: 'api', + api_key: api_key + }; + var user = item; + + // get api_key out of args.params, so it doesn't interfere with API calls + delete args.params.api_key; + + // pass both "session" and "user" to callback + callback(null, session, user); + }); + return; + }, + + requireParams: function (params, rules, callback) { + // proxy over to user module + assert(arguments.length == 3, "Wrong number of arguments to requireParams"); + return this.usermgr.requireParams(params, rules, callback); + }, + + doError: function (code, msg, callback) { + // proxy over to user module + assert(arguments.length == 3, "Wrong number of arguments to doError"); + return this.usermgr.doError(code, msg, callback); + } + +}); diff --git a/lib/api/admin.js b/lib/api/admin.js new file mode 100644 index 0000000..e7a00ad --- /dev/null +++ b/lib/api/admin.js @@ -0,0 +1,569 @@ +// Cronicle API Layer - Administrative +// Copyright (c) 2015 Joseph Huckaby +// Released under the MIT License + +var fs = require('fs'); +var assert = require("assert"); +var async = require('async'); + +var Class = require("pixl-class"); +var Tools = require("pixl-tools"); + +const { Readable } = require("stream"); + +module.exports = Class.create({ + + // + // Servers / manager Control + // + + api_check_add_server: function(args, callback) { + // check if it is okay to manually add this server to a remote cluster + // (This is a server-to-server API, sent from manager to a potential remote worker) + var self = this; + var params = args.params; + + if (!this.requireParams(params, { + manager: /\S/, + now: /^\d+$/, + token: /\S/ + }, callback)) return; + + if (params.token != Tools.digestHex( params.manager + params.now + this.server.config.get('secret_key') )) { + return this.doError('server', "Secret keys do not match. Please synchronize your config files.", callback); + } + if (this.multi.manager) { + return this.doError('server', "Server is already a manager server, controlling its own cluster.", callback); + } + if (this.multi.managerHostname && (this.multi.managerHostname != params.manager)) { + return this.doError('server', "Server is already a member of a cluster (manager: " + this.multi.managerHostname + ")", callback); + } + + callback({ code: 0, hostname: this.server.hostname, ip: this.server.ip }); + }, + + api_add_server: function(args, callback) { + // add any arbitrary server to cluster (i.e. outside of UDP broadcast range) + var self = this; + var params = args.params; + if (!this.requiremanager(args, callback)) return; + + if (!this.requireParams(params, { + hostname: /\S/ + }, callback)) return; + + var hostname = params.hostname.toLowerCase(); + + this.loadSession(args, function(err, session, user) { + if (err) return self.doError('session', err.message, callback); + if (!self.requireAdmin(session, user, callback)) return; + + args.user = user; + args.session = session; + + // make sure server isn't already added + if (self.workers[hostname]) { + return self.doError('server', "Server is already in cluster: " + hostname, callback); + } + + // send HTTP request to server, to make sure we can reach it + var api_url = self.getServerBaseAPIURL( hostname ) + '/app/check_add_server'; + var now = Tools.timeNow(true); + var api_args = { + manager: self.server.hostname, + now: now, + token: Tools.digestHex( self.server.hostname + now + self.server.config.get('secret_key') ) + }; + self.logDebug(9, "Sending API request to remote server: " + api_url); + + // send request + self.request.json( api_url, api_args, { timeout: 8 * 1000 }, function(err, resp, data) { + if (err) { + return self.doError('server', "Failed to contact server: " + err.message, callback ); + } + if (resp.statusCode != 200) { + return self.doError('server', "Failed to contact server: " + hostname + ": HTTP " + resp.statusCode + " " + resp.statusMessage, callback); + } + if (data.code != 0) { + return self.doError('server', "Failed to add server to cluster: " + hostname + ": " + data.description, callback); + } + + // replace user-entered hostname with one returned from server (check_add_server api response) + // just in case user entered an IP, or some CNAME + hostname = data.hostname; + + // re-check this, for sanity + if (self.workers[hostname]) { + return self.doError('server', "Server is already in cluster: " + hostname, callback); + } + + // one more sanity check, with the IP this time + for (var key in self.workers) { + var worker = self.workers[key]; + if (worker.ip == data.ip) { + return self.doError('server', "Server is already in cluster: " + hostname + " (" + data.ip + ")", callback); + } + } + + // okay to add + var stub = { hostname: hostname, ip: data.ip }; + self.logDebug(4, "Adding remote worker server to cluster: " + hostname, stub); + self.addServer(stub, args); + + // add to global/servers list + self.storage.listFind( 'global/servers', { hostname: hostname }, function(err, item) { + if (item) { + // server is already in list, just ignore and go + return callback({ code: 0 }); + } + + // okay to add + self.storage.listPush( 'global/servers', stub, function(err) { + if (err) { + // should never happen + self.logError('server', "Failed to add server to storage: " + hostname + ": " + err); + } + + // success + callback({ code: 0 }); + } ); // listPush + } ); // listFind + } ); // http request + } ); // load session + }, + + api_remove_server: function(args, callback) { + // remove any manually-added server from cluster + var self = this; + var params = args.params; + if (!this.requiremanager(args, callback)) return; + + if (!this.requireParams(params, { + hostname: /\S/ + }, callback)) return; + + var hostname = params.hostname.toLowerCase(); + + this.loadSession(args, function(err, session, user) { + if (err) return self.doError('session', err.message, callback); + if (!self.requireAdmin(session, user, callback)) return; + + args.user = user; + args.session = session; + + // do not allow removal of current manager + if (hostname == self.server.hostname) { + return self.doError('server', "Cannot remove current manager server: " + hostname, callback); + } + + var worker = self.workers[hostname]; + if (!worker) { + return self.doError('server', "Server not found in cluster: " + hostname, callback); + } + + // Do not allow removing server if it has any active jobs + var all_jobs = self.getAllActiveJobs(true); + for (var key in all_jobs) { + var job = all_jobs[key]; + if (job.hostname == hostname) { + var err = "Still has running jobs"; + return self.doError('server', "Failed to remove server: " + err, callback); + } // matches server + } // foreach job + + // okay to remove + self.logDebug(4, "Removing remote worker server from cluster: " + hostname); + self.removeServer({ hostname: hostname }, args); + + // delete from global/servers list + self.storage.listFindDelete( 'global/servers', { hostname: hostname }, function(err) { + if (err) { + // should never happen + self.logError('server', "Failed to remove server from storage: " + hostname + ": " + err); + } + + // success + callback({ code: 0 }); + } ); // listFindDelete + } ); // load session + }, + + api_restart_server: function(args, callback) { + // restart any server in cluster + var self = this; + var params = args.params; + if (!this.requiremanager(args, callback)) return; + + if (!this.requireParams(params, { + hostname: /\S/ + }, callback)) return; + + this.loadSession(args, function(err, session, user) { + if (err) return self.doError('session', err.message, callback); + if (!self.requireAdmin(session, user, callback)) return; + + args.user = user; + args.session = session; + + self.logTransaction('server_restart', '', self.getClientInfo(args, params)); + self.normalShutdown = true; + self.logActivity('server_restart', params, args); + + var reason = "User request by: " + user.username; + + if (params.hostname == self.server.hostname) { + // restart this server + self.restartLocalServer({ reason: reason }); + callback({ code: 0 }); + } + else { + // restart another server in the cluster + var worker = self.workers[ params.hostname ]; + if (worker && worker.socket) { + self.logDebug(6, "Sending remote restart command to: " + worker.hostname); + worker.socket.emit( 'restart_server', { reason: reason } ); + callback({ code: 0 }); + } + else { + callback({ code: 1, description: "Could not locate server: " + params.hostname }); + } + } + + } ); + }, + + api_shutdown_server: function(args, callback) { + // shutdown any server in cluster + var self = this; + var params = args.params; + if (!this.requiremanager(args, callback)) return; + + if (!this.requireParams(params, { + hostname: /\S/ + }, callback)) return; + + this.loadSession(args, function(err, session, user) { + if (err) return self.doError('session', err.message, callback); + if (!self.requireAdmin(session, user, callback)) return; + + args.user = user; + args.session = session; + + self.logTransaction('server_shutdown', '', self.getClientInfo(args, params)); + self.normalShutdown = true; + self.logActivity('server_shutdown', params, args); + + var reason = "User request by: " + user.username; + + if (params.hostname == self.server.hostname) { + // shutdown this server + self.shutdownLocalServer({ reason: reason }); + callback({ code: 0 }); + } + else { + // shutdown another server in the cluster + var worker = self.workers[ params.hostname ]; + if (worker && worker.socket) { + self.logDebug(6, "Sending remote shutdown command to: " + worker.hostname); + worker.socket.emit( 'shutdown_server', { reason: reason } ); + callback({ code: 0 }); + } + else { + callback({ code: 1, description: "Could not locate server: " + params.hostname }); + } + } + + } ); + }, + + api_update_manager_state: function(args, callback) { + // update manager state (i.e. scheduler enabled) + var self = this; + var params = args.params; + if (!this.requiremanager(args, callback)) return; + + this.loadSession(args, function(err, session, user) { + if (err) return self.doError('session', err.message, callback); + if (!self.requireValidUser(session, user, callback)) return; + if (!self.requirePrivilege(user, "state_update", callback)) return; + + args.user = user; + args.session = session; + + // import params into state + self.logDebug(4, "Updating manager state:", params); + self.logTransaction('state_update', '', self.getClientInfo(args, params)); + self.logActivity('state_update', params, args); + + if (params.enabled) { + // need to re-initialize schedule if being enabled + var now = Tools.normalizeTime( Tools.timeNow(), { sec: 0 } ); + var cursors = self.state.cursors; + + self.storage.listGet( 'global/schedule', 0, 0, function(err, items) { + // got all schedule items + for (var idx = 0, len = items.length; idx < len; idx++) { + var item = items[idx]; + + // reset cursor to now if event is NOT set to catch up + if (!item.catch_up) { + cursors[ item.id ] = now; + } + } // foreach item + + // now it's safe to enable + Tools.mergeHashInto( self.state, params ); + self.authSocketEmit( 'update', { state: self.state } ); + } ); // loaded schedule + } // params.enabled + else { + // not enabling scheduler, so merge right away + Tools.mergeHashInto( self.state, params ); + self.authSocketEmit( 'update', { state: self.state } ); + } + + callback({ code: 0 }); + } ); + }, + + api_get_activity: function(args, callback) { + // get rows from activity log (with pagination) + var self = this; + var params = args.params; + + this.loadSession(args, function(err, session, user) { + if (err) return self.doError('session', err.message, callback); + if (!self.requireAdmin(session, user, callback)) return; + + self.storage.listGet( 'logs/activity', parseInt(params.offset || 0), parseInt(params.limit || 50), function(err, items, list) { + if (err) { + // no rows found, not an error for this API + return callback({ code: 0, rows: [], list: { length: 0 } }); + } + + // success, return rows and list header + callback({ code: 0, rows: items, list: list }); + } ); // got data + } ); // loaded session + }, + + // list current server config. used by Config Viewer page + api_get_config: function(args, callback) { + var self = this; + this.loadSession(args, function(err, session, user) { + if (err) return self.doError('session', err.message, callback); + if (!self.requireAdmin(session, user, callback)) return; + let confCopy = JSON.parse(JSON.stringify(self.server.config.get())); + delete confCopy.secret_key // do not transmit secret key + //confCopy['temp_keys'] = self.temp_keys; // just for monitoring + callback({ code: 0, config: confCopy }); + }); + }, + + api_export: function (args, callback) { + var self = this; + var params = args.params; + + this.loadSession(args, function (err, session, user) { + if (err) return self.doError('session', err.message, callback); + if (!self.requireAdmin(session, user, callback)) return; + if (!self.requiremanager(args, callback)) return; + + args.user = user; + args.session = session; + + // file header (for humans) + var txt = "# Cronicle Data Export v1.0\n" + + "# Hostname: " + "local" + "\n" + + "# Date/Time: " + (new Date()).toString() + "\n" + + "# Format: KEY - JSON\n\n"; + + // need to handle users separately, as they're stored as a list + individual records + self.storage.listEach('global/users', + function (item, idx, callback) { + var username = item.username; + var key = 'users/' + username.toString().toLowerCase().replace(/\W+/g, ''); + self.logDebug(6, "Exporting user: " + username + "\n"); + + self.storage.get(key, function (err, user) { + if (err) { + // user deleted? + // self.logDebug(6, "Failed to fetch user: " + key + ": " + err + "\n\n" ); + // return callback(); + return self.doError('schedule_export', "Failed to create event: " + err, callback); + } + + txt += (key + ' - ' + JSON.stringify(user) + "\n") + setTimeout(callback, 10); + }); // get + }, + function (err) { + // ignoring errors here + // proceed to the rest of the lists + async.eachSeries( + [ + 'global/users', + //'global/plugins', + 'global/categories', + // 'global/server_groups', + 'global/schedule', + 'global/servers', + 'global/api_keys', + 'global/conf_keys' + ], + function (list_key, callback) { + // first get the list header + self.logDebug(6, "Exporting list: " + list_key + "\n"); + + self.storage.get(list_key, function (err, list) { + //if (err) return callback(new Error("Failed to fetch list: " + list_key + ": " + err)); + if (err) return self.doError('schedule_export', "Failed to fetch list: " + list_key + ": " + err, callback); + + txt += list_key + ' - ' + JSON.stringify(list) + "\n"; + + // now iterate over all the list pages + var page_idx = list.first_page; + + async.whilst( + function () { return page_idx <= list.last_page; }, + function (callback) { + // load each page + var page_key = list_key + '/' + page_idx; + page_idx++; + + self.logDebug(6, "Exporting list page: " + page_key + "\n"); + + self.storage.get(page_key, function (err, page) { + if (err) return callback(new Error("Failed to fetch list page: " + page_key + ": " + err)); + txt += (page_key + ' - ' + JSON.stringify(page) + "\n"); + setTimeout(callback, 10); + }); // page get + }, // iterator + callback + ); // whilst + + }); // get + }, // iterator + function (err) { + if (err) { + self.logActivity('backup_failure', params, args); + self.logDebug(6, "Failed to export schedule", { status: 'failure' }); + callback({ code: 1, err: err.message }); + } + + self.logActivity('backup', params, args); + self.logDebug(6, "Schedule exported successfully", { status: 'success' }); + //verbose_warn( "\nExport completed at " + (new Date()).toString() + ".\nExiting.\n\n" ); + callback({ code: 0, data: txt }); + + } // done done + ); // list eachSeries + } // done with users + ); // users listEach + + }); + }, + + api_import: function (args, callback) { + var self = this; + var params = args.params; + var info = {} + + this.loadSession(args, function (err, session, user) { + if (err) return self.doError('session', err.message, callback); + if (!self.requireAdmin(session, user, callback)) return; + if (!self.requiremanager(args, callback)) return; + + args.user = user; + args.session = session; + + var count = 0; + var resultList = []; + var serverCount = 0 + + var queue = async.queue(function (line, callback) { + // process each line + if (line.match(/^(\w[\w\-\.\/]*)\s+\-\s+(\{.+\})\s*$/)) { + var key = RegExp.$1; + var json_raw = RegExp.$2; + self.logDebug(6, "Schedule Import: Importing record: " + key + "\n"); + // print("Importing record: " + key + "\n"); + + var data = null; + try { data = JSON.parse(json_raw); } + catch (err) { + // warn("Failed to parse JSON for key: " + key + ": " + err + "\n"); + self.logDebug(6, "Schedule Import: Failed to parse JSON for key: " + key + ": " + err + "\n"); + return callback(); + } + + // check if key is allowed (should be like global/[...][/0] or users/username) + // ignore servers, server groups and plugins to avoid interference + var validKey = key.match(/^global\/(users|schedule|categories|api_keys|conf_keys)(\/0){0,1}$/g) || key.match(/^users\/([\w\.\@]+)$/g) + if (!validKey) { + self.logDebug(6, "Schedule Import: invalid key - " + key + "\n"); + resultList.push({key: key, code:2, desc: "Not allowed (skip)"}) + return callback(); + } + + let cnt = Array.isArray(data.items) ? data.items.length : 0 + if(key == 'global/schedule/0') info["events"] = cnt + if(key == 'global/categories/0') info["cats"] = cnt + if(key == 'global/users/0') info["users"] = cnt + if(key == 'global/api_keys/0') info["api"] = cnt + if(key == 'global/conf_keys/0') info["conf"] = cnt + + self.storage.put(key, data, function (err) { + if (err) { + // warn("Failed to store record: " + key + ": " + err + "\n"); + result.push({key: key, code:1, desc: "Failed to import"}) + return callback(); + } + count++; + itemCount = Array.isArray(data.items) ? data.items.length : ''; + resultList.push({key: key, code:0, desc: "Imported successfully", count: itemCount}) + callback(); + }); + } + else callback(); + }, 1); + + // setup readline to line-read from file or stdin + var readline = require('readline'); + + var rl = readline.createInterface({ + input: Readable.from([args.params.txt]) // backup string + }); + + rl.on('line', function (line) { + // enqueue each line + queue.push(line); + }); + + rl.on('close', function () { + // end of input stream + var complete = function (err) { + // final step + if (err) { + callback({ code: 1, err: err.message }) + self.logActivity('restore_failure', {err: err.message}, args); + } + else { + callback({ code: 0, result: resultList, count: count }) + self.authSocketEmit('update', { state: self.state }); + self.updateClientData('schedule'); //refresh event list + self.updateClientData('categories'); // to refresh cat list + self.logActivity('restore', {info: info}, args); + } + + }; + + // fire complete on queue drain + if (queue.idle()) complete(); + else queue.drain = complete; + }); // rl close + }); // seesion + } + + +}); diff --git a/lib/api/apikey.js b/lib/api/apikey.js new file mode 100644 index 0000000..11d5a71 --- /dev/null +++ b/lib/api/apikey.js @@ -0,0 +1,201 @@ +// Cronicle API Layer - API Keys +// Copyright (c) 2015 Joseph Huckaby +// Released under the MIT License + +const Class = require("pixl-class"); +const Tools = require("pixl-tools"); +const crypto = require('crypto'); + +module.exports = Class.create({ + + api_get_api_keys: function(args, callback) { + // get list of all api_keys + var self = this; + var params = args.params; + if (!this.requiremanager(args, callback)) return; + + this.loadSession(args, function(err, session, user) { + if (err) return self.doError('session', err.message, callback); + if (!self.requireAdmin(session, user, callback)) return; + + self.storage.listGet( 'global/api_keys', 0, 0, function(err, items, list) { + if (err) { + // no keys found, not an error for this API + return callback({ code: 0, rows: [], list: { length: 0 } }); + } + + // success, return keys and list header + callback({ code: 0, rows: items, list: list }); + } ); // got api_key list + } ); // loaded session + }, + + api_get_api_key: function(args, callback) { + // get single API Key for editing + var self = this; + var params = args.params; + if (!this.requiremanager(args, callback)) return; + + if (!this.requireParams(params, { + id: /^\w+$/ + }, callback)) return; + + this.loadSession(args, function(err, session, user) { + if (err) return self.doError('session', err.message, callback); + if (!self.requireAdmin(session, user, callback)) return; + + self.storage.listFind( 'global/api_keys', { id: params.id }, function(err, item) { + if (err || !item) { + return self.doError('api_key', "Failed to locate API Key: " + params.id, callback); + } + + // success, return key + callback({ code: 0, api_key: item }); + } ); // got api_key + } ); // loaded session + }, + + api_get_event_token: function (args, callback) { + // get event specific token + let self = this; + let params = args.params; + if (!this.requiremanager(args, callback)) return; + + if (!this.requireParams(params, { + id: /^\w+$/, + salt: /^\w+$/ + }, callback)) return; + + this.loadSession(args, function (err, session, user) { + if (err) return self.doError('session', err.message, callback); + if (!self.requireAdmin(session, user, callback)) return; + + let token = crypto.createHmac("sha1", `${self.server.config.get("secret_key")}`) + .update(`${params.id + params.salt}`) + .digest("hex"); + callback({ code: 0, token: token }); + + }); // loaded session + }, + + + api_create_api_key: function(args, callback) { + // add new API Key + var self = this; + var params = args.params; + if (!this.requiremanager(args, callback)) return; + + if (!this.requireParams(params, { + title: /\S/, + key: /\S/ + }, callback)) return; + + this.loadSession(args, function(err, session, user) { + if (err) return self.doError('session', err.message, callback); + if (!self.requireAdmin(session, user, callback)) return; + + args.user = user; + args.session = session; + + params.id = self.getUniqueID('k'); + params.username = user.username; + params.created = params.modified = Tools.timeNow(true); + + if (!params.active) params.active = 1; + if (!params.description) params.description = ""; + if (!params.privileges) params.privileges = {}; + + self.logDebug(6, "Creating new API Key: " + params.title, params); + + self.storage.listUnshift( 'global/api_keys', params, function(err) { + if (err) { + return self.doError('api_key', "Failed to create api_key: " + err, callback); + } + + self.logDebug(6, "Successfully created api_key: " + params.title, params); + self.logTransaction('apikey_create', params.title, self.getClientInfo(args, { api_key: params })); + self.logActivity('apikey_create', { api_key: params }, args); + + callback({ code: 0, id: params.id, key: params.key }); + + // broadcast update to all websocket clients + self.authSocketEmit( 'update', { api_keys: {} } ); + } ); // list insert + } ); // load session + }, + + api_update_api_key: function(args, callback) { + // update existing API Key + var self = this; + var params = args.params; + if (!this.requiremanager(args, callback)) return; + + if (!this.requireParams(params, { + id: /^\w+$/ + }, callback)) return; + + this.loadSession(args, function(err, session, user) { + if (err) return self.doError('session', err.message, callback); + if (!self.requireAdmin(session, user, callback)) return; + + args.user = user; + args.session = session; + + params.modified = Tools.timeNow(true); + + self.logDebug(6, "Updating API Key: " + params.id, params); + + self.storage.listFindUpdate( 'global/api_keys', { id: params.id }, params, function(err, api_key) { + if (err) { + return self.doError('api_key', "Failed to update API Key: " + err, callback); + } + + self.logDebug(6, "Successfully updated API Key: " + api_key.title, params); + self.logTransaction('apikey_update', api_key.title, self.getClientInfo(args, { api_key: api_key })); + self.logActivity('apikey_update', { api_key: api_key }, args); + + callback({ code: 0 }); + + // broadcast update to all websocket clients + self.authSocketEmit( 'update', { api_keys: {} } ); + } ); + } ); + }, + + api_delete_api_key: function(args, callback) { + // delete existing API Key + var self = this; + var params = args.params; + if (!this.requiremanager(args, callback)) return; + + if (!this.requireParams(params, { + id: /^\w+$/ + }, callback)) return; + + this.loadSession(args, function(err, session, user) { + if (err) return self.doError('session', err.message, callback); + if (!self.requireAdmin(session, user, callback)) return; + + args.user = user; + args.session = session; + + self.logDebug(6, "Deleting API Key: " + params.id, params); + + self.storage.listFindDelete( 'global/api_keys', { id: params.id }, function(err, api_key) { + if (err) { + return self.doError('api_key', "Failed to delete API Key: " + err, callback); + } + + self.logDebug(6, "Successfully deleted API Key: " + api_key.title, api_key); + self.logTransaction('apikey_delete', api_key.title, self.getClientInfo(args, { api_key: api_key })); + self.logActivity('apikey_delete', { api_key: api_key }, args); + + callback({ code: 0 }); + + // broadcast update to all websocket clients + self.authSocketEmit( 'update', { api_keys: {} } ); + } ); + } ); + } + +} ); diff --git a/lib/api/category.js b/lib/api/category.js new file mode 100644 index 0000000..efefd1a --- /dev/null +++ b/lib/api/category.js @@ -0,0 +1,208 @@ +// Cronicle API Layer - Categories +// Copyright (c) 2015 Joseph Huckaby +// Released under the MIT License + +var fs = require('fs'); +var assert = require("assert"); +var async = require('async'); + +var Class = require("pixl-class"); +var Tools = require("pixl-tools"); + +module.exports = Class.create({ + + // + // Categories: + // + + api_get_categories: function(args, callback) { + // get list of categories (with pagination) + var self = this; + var params = args.params; + if (!this.requiremanager(args, callback)) return; + + this.loadSession(args, function(err, session, user) { + if (err) return self.doError('session', err.message, callback); + if (!self.requireValidUser(session, user, callback)) return; + + self.storage.listGet( 'global/categories', parseInt(params.offset || 0), parseInt(params.limit || 0), function(err, items, list) { + if (err) { + // no cats found, not an error for this API + return callback({ code: 0, rows: [], list: { length: 0 } }); + } + + // success, return cats and list header + callback({ code: 0, rows: items, list: list }); + } ); // got category list + } ); // loaded session + }, + + api_create_category: function(args, callback) { + // add new category + var self = this; + var cat = args.params; + if (!this.requiremanager(args, callback)) return; + + if (!this.requireParams(cat, { + title: /\S/, + max_children: /^\d+$/ + }, callback)) return; + + this.loadSession(args, function(err, session, user) { + if (err) return self.doError('session', err.message, callback); + if (!self.requireAdmin(session, user, callback)) return; + + args.user = user; + args.session = session; + + if (cat.id) cat.id = cat.id.toString().toLowerCase().replace(/\W+/g, ''); + if (!cat.id) cat.id = self.getUniqueID('c'); + + cat.created = cat.modified = Tools.timeNow(true); + + if (user.key) { + // API Key + cat.api_key = user.key; + } + else { + cat.username = user.username; + } + + self.logDebug(6, "Creating new category: " + cat.title, cat); + + self.storage.listUnshift( 'global/categories', cat, function(err) { + if (err) { + return self.doError('category', "Failed to create category: " + err, callback); + } + + self.logDebug(6, "Successfully created category: " + cat.title, cat); + self.logTransaction('cat_create', cat.title, self.getClientInfo(args, { cat: cat })); + self.logActivity('cat_create', { cat: cat }, args); + + callback({ code: 0, id: cat.id }); + + // broadcast update to all websocket clients + self.updateClientData( 'categories' ); + } ); // list insert + } ); // load session + }, + + api_update_category: function(args, callback) { + // update existing category + var self = this; + var params = args.params; + if (!this.requiremanager(args, callback)) return; + + if (!this.requireParams(params, { + id: /^\w+$/ + }, callback)) return; + + this.loadSession(args, function(err, session, user) { + if (err) return self.doError('session', err.message, callback); + if (!self.requireAdmin(session, user, callback)) return; + + args.user = user; + args.session = session; + + self.storage.listFind( 'global/categories', { id: params.id }, function(err, cat) { + if (err || !cat) { + return self.doError('event', "Failed to locate category: " + params.id, callback); + } + + params.modified = Tools.timeNow(true); + + self.logDebug(6, "Updating category: " + cat.title, params); + + // pull abort flag out of event object, for use later + var abort_jobs = 0; + if (params.abort_jobs) { + abort_jobs = params.abort_jobs; + delete params.abort_jobs; + } + + self.storage.listFindUpdate( 'global/categories', { id: params.id }, params, function(err) { + if (err) { + return self.doError('category', "Failed to update category: " + err, callback); + } + + // merge params into cat, just so we have the full updated record + for (var key in params) cat[key] = params[key]; + + self.logDebug(6, "Successfully updated category: " + cat.title, params); + self.logTransaction('cat_update', cat.title, self.getClientInfo(args, { cat: params })); + self.logActivity('cat_update', { cat: params }, args); + + callback({ code: 0 }); + + // broadcast update to all websocket clients + self.updateClientData( 'categories' ); + + // if cat is disabled, abort all applicable jobs + if (!cat.enabled && abort_jobs) { + var all_jobs = self.getAllActiveJobs(true); + for (var key in all_jobs) { + var job = all_jobs[key]; + if ((job.category == cat.id) && !job.detached) { + var msg = "Category '" + cat.title + "' has been disabled."; + self.logDebug(4, "Job " + job.id + " is being aborted: " + msg); + self.abortJob({ id: job.id, reason: msg }); + } // matches cat + } // foreach job + } // cat disabled + + // if cat is being enabled, force scheduler to re-tick the minute + var dargs = Tools.getDateArgs( new Date() ); + if (params.enabled && !self.schedulerGraceTimer && !self.schedulerTicking && (dargs.sec != 59)) { + self.schedulerMinuteTick( null, true ); + } + + } ); // update cat + } ); // find cat + } ); // load session + }, + + api_delete_category: function(args, callback) { + // delete existing category + var self = this; + var params = args.params; + if (!this.requiremanager(args, callback)) return; + + if (!this.requireParams(params, { + id: /^\w+$/ + }, callback)) return; + + this.loadSession(args, function(err, session, user) { + if (err) return self.doError('session', err.message, callback); + if (!self.requireAdmin(session, user, callback)) return; + + args.user = user; + args.session = session; + + // Do not allow deleting category if any matching events in schedule + self.storage.listFind( 'global/schedule', { category: params.id }, function(err, item) { + if (item) { + return self.doError('plugin', "Failed to delete category: Still in use by one or more events.", callback); + } + + self.logDebug(6, "Deleting category: " + params.id); + + self.storage.listFindDelete( 'global/categories', { id: params.id }, function(err, cat) { + if (err) { + return self.doError('category', "Failed to delete category: " + err, callback); + } + + self.logDebug(6, "Successfully deleted category: " + cat.title, cat); + self.logTransaction('cat_delete', cat.title, self.getClientInfo(args, { cat: cat })); + self.logActivity('cat_delete', { cat: cat }, args); + + callback({ code: 0 }); + + // broadcast update to all websocket clients + self.updateClientData( 'categories' ); + + } ); // listFindDelete (category) + } ); // listFind (schedule) + } ); // load session + } + +} ); diff --git a/lib/api/config.js b/lib/api/config.js new file mode 100644 index 0000000..8832ed2 --- /dev/null +++ b/lib/api/config.js @@ -0,0 +1,59 @@ +// Cronicle API Layer - Configuration +// Copyright (c) 2015 Joseph Huckaby +// Released under the MIT License + +var fs = require('fs'); +var assert = require("assert"); +var async = require('async'); + +var Class = require("pixl-class"); +var Tools = require("pixl-tools"); + +module.exports = Class.create({ + + api_config: function(args, callback) { + // send config to client + var self = this; + + // do not cache this API response + this.forceNoCacheResponse(args); + + // if there is no manager server, this has to fail (will be polled for retries) + if (!this.multi.managerHostname) { + return callback({ code: 'manager', description: "No manager server found" }); + } + + var resp = { + code: 0, + version: this.server.__version, + config: Tools.mergeHashes( this.server.config.get('client'), { + debug: this.server.debug ? 1 : 0, + job_memory_max: this.server.config.get('job_memory_max'), + base_api_uri: this.api.config.get('base_uri'), + default_privileges: this.usermgr.config.get('default_privileges'), + free_accounts: this.usermgr.config.get('free_accounts'), + external_users: this.usermgr.config.get('external_user_api') ? 1 : 0, + external_user_api: this.usermgr.config.get('external_user_api') || '', + web_socket_use_hostnames: this.server.config.get('web_socket_use_hostnames') || 0, + web_direct_connect: this.server.config.get('web_direct_connect') || 0, + custom_live_log_socket_url: this.server.config.get('custom_live_log_socket_url'), + ui: this.server.config.get('ui') || {}, + socket_io_transports: this.server.config.get('socket_io_transports') || 0 + } ), + port: args.request.headers.ssl ? this.web.config.get('https_port') : this.web.config.get('http_port'), + manager_hostname: this.multi.managerHostname + }; + + // if we're manager, then return our ip for websocket connect + if (this.multi.manager) { + resp.servers = {}; + resp.servers[ this.server.hostname ] = { + hostname: this.server.hostname, + ip: this.server.ip + }; + } + + callback(resp); + } + +} ); diff --git a/lib/api/confkey.js b/lib/api/confkey.js new file mode 100644 index 0000000..766571d --- /dev/null +++ b/lib/api/confkey.js @@ -0,0 +1,255 @@ +// Cronicle API Layer - Config Keys +// Copyright (c) 2015 Joseph Huckaby +// Released under the MIT License + +var fs = require('fs'); +var assert = require("assert"); +var async = require('async'); + +var Class = require("pixl-class"); +var Tools = require("pixl-tools"); +var ld = require("lodash"); + +module.exports = Class.create({ + + api_get_conf_keys: function (args, callback) { + // get list of all conf_keys + var self = this; + var params = args.params; + if (!this.requiremanager(args, callback)) return; + + this.loadSession(args, function (err, session, user) { + if (err) return self.doError('session', err.message, callback); + if (!self.requireAdmin(session, user, callback)) return; + + self.storage.listGet('global/conf_keys', 0, 0, function (err, items, list) { + if (err) { + // no keys found, not an error for this API + return callback({ code: 0, rows: [], list: { length: 0 } }); + } + + // success, return keys and list header + callback({ code: 0, rows: items, list: list }); + }); // got conf_key list + }); // loaded session + }, + + api_get_conf_key: function (args, callback) { + // get single Config Key for editing + var self = this; + var params = args.params; + if (!this.requiremanager(args, callback)) return; + + if (!this.requireParams(params, { + id: /^\w+$/ + }, callback)) return; + + this.loadSession(args, function (err, session, user) { + if (err) return self.doError('session', err.message, callback); + if (!self.requireAdmin(session, user, callback)) return; + + self.storage.listFind('global/conf_keys', { id: params.id }, function (err, item) { + if (err || !item) { + return self.doError('conf_key', "Failed to locate Config Key: " + params.id, callback); + } + + // success, return key + callback({ code: 0, conf_key: item }); + }); // got conf_key + }); // loaded session + }, + + api_create_conf_key: function (args, callback) { + // add new Config Key + var self = this; + var params = args.params; + if (!this.requiremanager(args, callback)) return; + + if (!this.requireParams(params, { + title: /\S/, + key: /\S/ + }, callback)) return; + + this.loadSession(args, async function (err, session, user) { + if (err) return self.doError('session', err.message, callback); + if (!self.requireAdmin(session, user, callback)) return; + + args.user = user; + args.session = session; + + params.id = self.getUniqueID('c'); + params.username = user.username; + params.created = params.modified = Tools.timeNow(true); + + var conf_name = String(params.title).trim(); + var qc = conf_name.toUpperCase(); + if (!conf_name || qc == 'SECRET_KEY' || qc.startsWith("STORAGE") || qc.startsWith("WEBSERVER")) { + return self.doError('conf_key', "This Config Name is not allowed", callback) + } + + params.title = conf_name + + if (!params.key) params.key = ""; // just in case + if (params.key === 'true') params.key = true; + else if (params.key === 'false') params.key = false; + else if (params.key.match(/^\-?\d+$/)) params.key = parseInt(params.key); + else if (params.key.match(/^\-?\d+\.\d+$/)) params.key = parseFloat(params.key); + + + if (!params.description) params.description = ""; + + // check if same title or id already exist + let alreadyExist = await self.validateUnique('global/conf_keys', params, ["id", "title"]) + if (alreadyExist > 0) { + return self.doError('conf_key', `Failed to create config key: (${params.title}) already exists`, callback); + } + + self.logDebug(6, "Creating new Config Key: " + params.title, params); + + self.storage.listUnshift('global/conf_keys', params, function (err) { + if (err) { + return self.doError('conf_key', "Failed to create conf_key: " + err, callback); + } + + ld.set(self.server.config.get(), params.title, params.key); // live update + self.logDebug(6, "Successfully created conf_key: " + params.title, params); + self.logTransaction('confkey_create', params.title, self.getClientInfo(args, { conf_key: params })); + self.logActivity('confkey_create', { conf_key: params }, args); + + callback({ code: 0, id: params.id, key: params.key }); + + // broadcast update to all websocket clients + self.authSocketEmit('update', { conf_keys: {} }); + }); // list insert + }); // load session + }, + + api_update_conf_key: function (args, callback) { + // update existing Config Key + var self = this; + var params = args.params; + if (!this.requiremanager(args, callback)) return; + + if (!this.requireParams(params, { + id: /^\w+$/, + title: /\S/, + }, callback)) return; + + this.loadSession(args, async function (err, session, user) { + if (err) return self.doError('session', err.message, callback); + if (!self.requireAdmin(session, user, callback)) return; + + args.user = user; + args.session = session; + + params.modified = Tools.timeNow(true); + + var conf_name = String(params.title).trim() + if (!conf_name || conf_name == 'secret_key' || conf_name.toUpperCase().startsWith("STORAGE") || conf_name.toUpperCase().startsWith("WEBSERVER")) { + return self.doError('conf_key', "This Config Name is not allowed", callback) + } + + if (!params.key) params.key = ""; // just in case + if (params.key === 'true') params.key = true; + else if (params.key === 'false') params.key = false; + else if (params.key.match(/^\-?\d+$/)) params.key = parseInt(params.key); + else if (params.key.match(/^\-?\d+\.\d+$/)) params.key = parseFloat(params.key); + + self.logDebug(6, "Updating Config Key: " + params.id, params); + + self.storage.listFindUpdate('global/conf_keys', { id: params.id, title: params.title }, params, function (err, conf_key) { + if (err) { + return self.doError('conf_key', "Failed to update Config Key: " + err, callback); + } + + ld.set(self.server.config.get(), params.title, params.key); // live update + self.logDebug(6, "Successfully updated Config Key: " + conf_key.title, params); + self.logTransaction('confkey_update', conf_key.title, self.getClientInfo(args, { conf_key: conf_key })); + self.logActivity('confkey_update', { conf_key: conf_key }, args); + + callback({ code: 0 }); + + // broadcast update to all websocket clients + self.authSocketEmit('update', { conf_keys: {} }); + }); + }); + }, + + api_delete_conf_key: function (args, callback) { + // delete existing Config Key + var self = this; + var params = args.params; + if (!this.requiremanager(args, callback)) return; + + if (!this.requireParams(params, { + id: /^\w+$/ + }, callback)) return; + + this.loadSession(args, function (err, session, user) { + if (err) return self.doError('session', err.message, callback); + if (!self.requireAdmin(session, user, callback)) return; + + args.user = user; + args.session = session; + + self.logDebug(6, "Deleting Config Key: " + params.id, params); + + self.storage.listFindDelete('global/conf_keys', { id: params.id }, function (err, conf_key) { + if (err) { + return self.doError('conf_key', "Failed to delete Config Key: " + err, callback); + } + + ld.unset(self.server.config.get(), params.title); // live update + self.logDebug(6, "Successfully deleted Config Key: " + conf_key.title, conf_key); + self.logTransaction('confkey_delete', conf_key.title, self.getClientInfo(args, { conf_key: conf_key })); + self.logActivity('confkey_delete', { conf_key: conf_key }, args); + + + + callback({ code: 0 }); + + // broadcast update to all websocket clients + self.authSocketEmit('update', { conf_keys: {} }); + }); + }); + }, + + // reload all configs + api_reload_conf_key: function (args, callback) { + var self = this; + var params = args.params; + if (!this.requiremanager(args, callback)) return; + + this.loadSession(args, function (err, session, user) { + if (err) return self.doError('session', err.message, callback); + if (!self.requireAdmin(session, user, callback)) return; + + args.user = user; + args.session = session; + + self.logDebug(6, "Reloading Config Keys: ", params); + + let config = self.server.config.get() + + self.storage.listGet('global/conf_keys', 0, 0, function (err, items, list) { + if (err) { + return self.doError('conf_key', "Failed to reload Config Keys: " + err, callback); + } + if (items) { // items only would exist on master + for (i = 0; i < items.length; i++) { + if (items[i].title) ld.set(config, items[i].title, items[i].key); + //console.log(items[i]); + } + } + + self.logDebug(6, "Successfully Reloaded Config Keys: "); + + callback({ code: 0 }); + + // broadcast update to all websocket clients + self.authSocketEmit('update', { conf_keys: {} }); + }); + }); + } + +}); diff --git a/lib/api/event.js b/lib/api/event.js new file mode 100644 index 0000000..1ef59cf --- /dev/null +++ b/lib/api/event.js @@ -0,0 +1,598 @@ +// Cronicle API Layer - Events +// Copyright (c) 2015 Joseph Huckaby +// Released under the MIT License + +const Class = require("pixl-class"); +const Tools = require("pixl-tools"); +const crypto = require('crypto'); + + +module.exports = Class.create({ + + // + // Events: + // + + api_get_schedule: function (args, callback) { + // get list of scheduled events (with pagination) + var self = this; + var params = Tools.mergeHashes(args.params, args.query); + if (!this.requiremanager(args, callback)) return; + + this.loadSession(args, function (err, session, user) { + if (err) return self.doError('session', err.message, callback); + if (!self.requireValidUser(session, user, callback)) return; + + self.storage.listGet('global/schedule', parseInt(params.offset || 0), parseInt(params.limit || 0), function (err, items, list) { + if (err) { + // no items found, not an error for this API + return callback({ code: 0, rows: [], list: { length: 0 } }); + } + + // success, return keys and list header + callback({ code: 0, rows: items, list: list }); + }); // got event list + }); // loaded session + }, + + api_get_event: function (args, callback) { + // get single event for editing + var self = this; + var params = Tools.mergeHashes(args.params, args.query); + if (!this.requiremanager(args, callback)) return; + + var criteria = {}; + if (params.id) criteria.id = params.id; + else if (params.title) criteria.title = params.title; + else return this.doError('event', "Failed to locate event: No criteria specified", callback); + + this.loadSession(args, function (err, session, user) { + if (err) return self.doError('session', err.message, callback); + if (!self.requireValidUser(session, user, callback)) return; + + self.storage.listFind('global/schedule', criteria, function (err, item) { + if (err || !item) { + return self.doError('event', "Failed to locate event: " + (params.id || params.title), callback); + } + + // success, return event + var resp = { code: 0, event: item }; + if (item.queue) resp.queue = self.eventQueue[item.id] || 0; + + // if event has any active jobs, include those as well + var all_jobs = self.getAllActiveJobs(true); + var event_jobs = []; + for (var key in all_jobs) { + var job = all_jobs[key]; + if (job.event == item.id) event_jobs.push(job); + } + if (event_jobs.length) resp.jobs = event_jobs; + + callback(resp); + }); // got event + }); // loaded session + }, + + api_create_event: function (args, callback) { + // add new event + var self = this; + var event = args.params; + if (!this.requiremanager(args, callback)) return; + + if (!this.requireParams(event, { + title: /\S/, + enabled: /^(\d+|true|false)$/, + category: /^\w+$/, + target: /^[\w\-\.]+$/, + plugin: /^\w+$/ + }, callback)) return; + + // validate optional event data parameters + if (!this.requireValidEventData(event, callback)) return; + + this.loadSession(args, async function (err, session, user) { + if (err) return self.doError('session', err.message, callback); + if (!self.requireValidUser(session, user, callback)) return; + if (!self.requirePrivilege(user, "create_events", callback)) return; + if (!self.requireCategoryPrivilege(user, event.category, callback)) return; + if (!self.requireGroupPrivilege(args, user, event.target, callback)) return; + + // check if event with same title or id already exist + let alreadyExist = await self.validateUnique('global/schedule', event, ["id", "title"]) + if (alreadyExist > 0) { + return self.doError('event', `Event with title "${event.title}" ${event.id ? "or id (" + event.id + ")" : ""} already exists`, callback); + } + + args.user = user; + args.session = session; + + if (event.id) event.id = event.id.toString().toLowerCase().replace(/\W+/g, ''); + if (!event.id) event.id = self.getUniqueID('e'); + + event.created = event.modified = Tools.timeNow(true); + + if (!event.max_children) event.max_children = 0; + if (!event.timeout) event.timeout = 0; + if (!event.timezone) event.timezone = self.tz; + if (!event.params) event.params = {}; + + if (user.key) { + // API Key + event.api_key = user.key; + } + else { + event.username = user.username; + } + + // validate category + self.storage.listFind('global/categories', { id: event.category }, function (err, item) { + if (err || !item) { + return self.doError('event', "Failed to create event: Category not found: " + event.category, callback); + } + + self.logDebug(6, "Creating new event: " + event.title, event); + + self.storage.listUnshift('global/schedule', event, function (err) { + if (err) { + return self.doError('event', "Failed to create event: " + err, callback); + } + + self.logDebug(6, "Successfully created event: " + event.title, event); + self.logTransaction('event_create', event.title, self.getClientInfo(args, { event: event })); + self.logActivity('event_create', { event: event }, args); + + callback({ code: 0, id: event.id }); + + // broadcast update to all websocket clients + self.updateClientData('schedule'); + + // create cursor for new event + var now = Tools.normalizeTime(Tools.timeNow(), { sec: 0 }); + self.state.cursors[event.id] = now; + + // send new state data to all web clients + self.authSocketEmit('update', { state: self.state }); + + }); // list insert + }); // find cat + }); // load session + }, + + api_update_event: function (args, callback) { + // update existing event + var self = this; + var params = args.params; + if (!this.requiremanager(args, callback)) return; + + if (!this.requireParams(params, { + id: /^\w+$/ + }, callback)) return; + + // validate optional event data parameters + if (!this.requireValidEventData(params, callback)) return; + + this.loadSession(args, async function (err, session, user) { + if (err) return self.doError('session', err.message, callback); + if (!self.requireValidUser(session, user, callback)) return; + if (!self.requirePrivilege(user, "edit_events", callback)) return; + if (params.abort_jobs && !self.requirePrivilege(user, "abort_events", callback)) return; + + args.user = user; + args.session = session; + + self.storage.listFind('global/schedule', { id: params.id }, async function (err, event) { + if (err || !event) { + return self.doError('event', "Failed to locate event: " + params.id, callback); + } + + // if updating title - check if it already exists + if (event.title != params.title) { + let titleExists = await self.validateUnique('global/schedule', params, ["title"]) + if (titleExists > 0) { + return self.doError('event', `Event with title "${params.title}" already exists`, callback); + } + } + + var prevState = event.enabled + + // validate category + self.storage.listFind('global/categories', { id: params.category || event.category }, function (err, item) { + if (err || !item) { + return self.doError('event', "Failed to update event: Category not found: " + params.category, callback); + } + + if (!self.requireCategoryPrivilege(user, event.category, callback)) return; + if (!self.requireGroupPrivilege(args, user, event.target, callback)) return; + + params.modified = Tools.timeNow(true); + + self.logDebug(6, "Updating event: " + event.title, params); + + // pull cursor reset out of event object, for use later + var new_cursor = 0; + if (params.reset_cursor) { + new_cursor = Tools.normalizeTime(params.reset_cursor - 60, { sec: 0 }); + delete params.reset_cursor; + } + + // pull abort flag out of event object, for use later + var abort_jobs = 0; + if (params.abort_jobs) { + abort_jobs = params.abort_jobs; + delete params.abort_jobs; + } + + self.storage.listFindUpdate('global/schedule', { id: params.id }, params, function (err) { + if (err) { + return self.doError('event', "Failed to update event: " + err, callback); + } + + // merge params into event, just so we have the full updated record + for (var key in params) event[key] = params[key]; + + // optionally reset cursor + if (new_cursor) { + var dargs = Tools.getDateArgs(new_cursor); + self.logDebug(6, "Resetting event cursor to: " + dargs.yyyy_mm_dd + ' ' + dargs.hh_mi_ss); + self.state.cursors[params.id] = new_cursor; + + // send new state data to all web clients + self.authSocketEmit('update', { state: self.state }); + } + + var changeType = "event_update" + if (prevState != event.enabled) { + changeType = event.enabled ? 'event_enabled' : 'event_disabled' + } + + self.logDebug(6, "Successfully updated event: " + event.id + " (" + event.title + ")"); + self.logTransaction('event_update', event.title, self.getClientInfo(args, { event: params })); + self.logActivity(changeType, { event: params }, args); + + // send response to web client + callback({ code: 0 }); + + // broadcast update to all websocket clients + self.updateClientData('schedule'); + + // if event is disabled, abort all applicable jobs + if (!event.enabled && abort_jobs) { + var all_jobs = self.getAllActiveJobs(true); + for (var key in all_jobs) { + var job = all_jobs[key]; + if ((job.event == event.id) && !job.detached) { + var msg = "Event '" + event.title + "' has been disabled."; + self.logDebug(4, "Job " + job.id + " is being aborted: " + msg); + self.abortJob({ id: job.id, reason: msg }); + } // matches event + } // foreach job + } // event disabled + + // if this is a catch_up event and is being enabled, force scheduler to re-tick the minute + var dargs = Tools.getDateArgs(new Date()); + if (params.enabled && event.catch_up && !self.schedulerGraceTimer && !self.schedulerTicking && (dargs.sec != 59)) { + self.schedulerMinuteTick(null, true); + } + + // check event queue + if (self.eventQueue[event.id]) { + if (event.queue) self.checkEventQueues(event.id); + else self.deleteEventQueues(event.id); + } + }); // update event + }); // find cat + }); // find event + }); // load session + }, + + api_delete_event: function (args, callback) { + // delete existing event + var self = this; + var params = args.params; + if (!this.requiremanager(args, callback)) return; + + if (!this.requireParams(params, { + id: /^\w+$/ + }, callback)) return; + + this.loadSession(args, function (err, session, user) { + if (err) return self.doError('session', err.message, callback); + if (!self.requireValidUser(session, user, callback)) return; + if (!self.requirePrivilege(user, "delete_events", callback)) return; + + args.user = user; + args.session = session; + + self.storage.listFind('global/schedule', { id: params.id }, function (err, event) { + if (err || !event) { + return self.doError('event', "Failed to locate event: " + params.id, callback); + } + + if (!self.requireCategoryPrivilege(user, event.category, callback)) return; + if (!self.requireGroupPrivilege(args, user, event.target, callback)) return; + + // Do not allow deleting event if any active jobs + var all_jobs = self.getAllActiveJobs(true); + for (var key in all_jobs) { + var job = all_jobs[key]; + if (job.event == params.id) { + var err = "Still has running jobs"; + return self.doError('event', "Failed to delete event: " + err, callback); + } // matches event + } // foreach job + + self.logDebug(6, "Deleting event: " + params.id); + + self.storage.listFindDelete('global/schedule', { id: params.id }, function (err) { + if (err) { + return self.doError('event', "Failed to delete event: " + err, callback); + } + + self.logDebug(6, "Successfully deleted event: " + event.title, event); + self.logTransaction('event_delete', event.title, self.getClientInfo(args, { event: event })); + self.logActivity('event_delete', { event: event }, args); + + callback({ code: 0 }); + + // broadcast update to all websocket clients + self.updateClientData('schedule'); + + // schedule event's activity log to be deleted at next maint run + self.storage.expire('logs/events/' + event.id, Tools.timeNow(true) + 86400); + + // delete state data + delete self.state.cursors[event.id]; + if (self.state.robins) delete self.state.robins[event.id]; + + // send new state data to all web clients + self.authSocketEmit('update', { state: self.state }); + + // check event queue + if (self.eventQueue[event.id]) { + self.deleteEventQueues(event.id); + } + + }); // delete + }); // listFind + }); // load session + }, + + api_run_event: function (args, callback) { + // run event manually (via "Run" button in UI or by API Key) + // can include any event overrides in params (such as 'now') + var self = this; + if (!this.requiremanager(args, callback)) return; + + // default behavor: merge post params and query together + // alt behavior (post_data): store post params into post_data + var params = Tools.copyHash(args.query, true); + if (args.query.post_data) params.post_data = args.params; + else Tools.mergeHashInto(params, args.params); + + var criteria = {}; + if (params.id) criteria.id = params.id; + else if (params.title) criteria.title = params.title; + else return this.doError('event', "Failed to locate event: No criteria specified", callback); + + // validate optional event data parameters + if (!this.requireValidEventData(params, callback)) return; + + this.loadSession(args, function (err, session, user) { + + if (!err) { + if (!self.requireValidUser(session, user, callback)) return; + if (!self.requirePrivilege(user, "run_events", callback)) return; + + args.user = user; + args.session = session; + } + else if (params.token) { + session = { type: "token", token: params.token } + user = { token: params.token, username: params.id, active: true, privileges: { run_events: 1 } } + } + else { + return self.doError('session', err.message, callback); + } + + + self.storage.listFind('global/schedule', criteria, function (err, event) { + if (err || !event) { + return self.doError('event', "Failed to locate event: " + (params.id || params.title), callback); + } + + if (user.token) { // token auth + if (!event.salt) return self.doError('event', 'token is disabled for this event', callback); + let expectedToken = crypto.createHmac("sha1", `${self.server.config.get("secret_key")}`) + .update(`${event.id + event.salt}`) + .digest("hex"); + if (expectedToken != user.token) return self.doError('event', 'invalid token', callback); + } + + if (!self.requireCategoryPrivilege(user, event.category, callback)) return; + if (!self.requireGroupPrivilege(args, user, event.target, callback)) return; + + delete params.id; + delete params.title; + delete params.catch_up; + delete params.category; + delete params.multiplex; + delete params.stagger; + delete params.detached; + delete params.queue; + delete params.queue_max; + delete params.max_children; + delete params.session_id; + + // allow for ¶ms/foo=bar and the like + for (var key in params) { + if (key.match(/^(\w+)\/(\w+)$/)) { + var parent_key = RegExp.$1; + var sub_key = RegExp.$2; + if (!params[parent_key]) params[parent_key] = {}; + params[parent_key][sub_key] = params[key]; + delete params[key]; + } + } + + // allow sparsely populated event params in request + if (params.params && event.params) { + for (var key in event.params) { + if (!(key in params.params)) params.params[key] = event.params[key]; + } + } + + // only allow customize event params (like script in shell plug) to users with edit priv + let canEdit = user.privileges.edit_events || user.privileges.create_events || user.privileges.admin + if(!canEdit && params.params) { + delete params.params; + } + + var job = Tools.mergeHashes(Tools.copyHash(event, true), params); + if (user.key) { + // API Key + job.source = "API Key (" + user.title + ")"; + job.api_key = user.key; + } + else if (user.token) { + job.source = "Event Token"; + job.api_key = user.token; + } + else if (user.gitrepo) { + job.source = `GitHub (${user.gitrepo})`; + job.api_key = user.signature; + } + else { + job.source = "Manual (" + user.username + ")"; + job.username = user.username; + } + + self.logDebug(6, "Running event manually: " + job.title, job); + + self.launchOrQueueJob(job, function (err, jobs_launched) { + if (err) { + return self.doError('event', "Failed to launch event: " + err.message, callback); + } + + // multiple jobs may have been launched (multiplex) + var ids = []; + for (var idx = 0, len = jobs_launched.length; idx < len; idx++) { + var job = jobs_launched[idx]; + var stub = { id: job.id, event: job.event }; + self.logTransaction('job_run', job.event_title, self.getClientInfo(args, stub)); + if (self.server.config.get('track_manual_jobs')) self.logActivity('job_run', stub, args); + ids.push(job.id); + } + + var resp = { code: 0, ids: ids }; + if (!ids.length) resp.queue = self.eventQueue[event.id] || 1; + callback(resp); + }); // launch job + }); // find event + }); // load session + }, + + api_flush_event_queue: function (args, callback) { + // flush event queue + var self = this; + var params = args.params; + if (!this.requiremanager(args, callback)) return; + + if (!this.requireParams(params, { + id: /^\w+$/ + }, callback)) return; + + this.loadSession(args, function (err, session, user) { + if (err) return self.doError('session', err.message, callback); + if (!self.requireValidUser(session, user, callback)) return; + if (!self.requirePrivilege(user, "abort_events", callback)) return; + + args.user = user; + args.session = session; + + self.storage.listFind('global/schedule', { id: params.id }, function (err, event) { + if (err || !event) { + return self.doError('event', "Failed to locate event: " + params.id, callback); + } + + if (!self.requireCategoryPrivilege(user, event.category, callback)) return; + if (!self.requireGroupPrivilege(args, user, event.target, callback)) return; + + self.deleteEventQueues(params.id, function () { + callback({ code: 0 }); + }); + + }); // listFind + }); // loadSession + }, + + api_get_event_history: function (args, callback) { + // get event history + var self = this; + var params = Tools.mergeHashes(args.params, args.query); + + if (!this.requireParams(params, { + id: /^\w+$/ + }, callback)) return; + + this.loadSession(args, function (err, session, user) { + if (err) return self.doError('session', err.message, callback); + if (!self.requireValidUser(session, user, callback)) return; + + args.user = user; + args.session = session; + + self.storage.listFind('global/schedule', { id: params.id }, function (err, event) { + if (err || !event) { + return self.doError('event', "Failed to locate event: " + params.id, callback); + } + + if (!self.requireCategoryPrivilege(user, event.category, callback)) return; + if (!self.requireGroupPrivilege(args, user, event.target, callback)) return; + + self.storage.listGet('logs/events/' + params.id, parseInt(params.offset || 0), parseInt(params.limit || 100), function (err, items, list) { + if (err) { + // no rows found, not an error for this API + return callback({ code: 0, rows: [], list: { length: 0 } }); + } + + // success, return rows and list header + callback({ code: 0, rows: items, list: list }); + + }); // got data + }); // listFind + }); // load session + }, + + api_get_history: function (args, callback) { + // get list of completed jobs for ALL events (with pagination) + var self = this; + var params = Tools.mergeHashes(args.params, args.query); + + this.loadSession(args, function (err, session, user) { + if (err) return self.doError('session', err.message, callback); + if (!self.requireValidUser(session, user, callback)) return; + + // user may be limited to certain categories + // but in order to keep pagination sane, just mask out IDs + var privs = user.privileges; + var cat_limited = (!privs.admin && privs.cat_limit); + + self.storage.listGet('logs/completed', parseInt(params.offset || 0), parseInt(params.limit || 50), function (err, items, list) { + if (err) { + // no rows found, not an error for this API + return callback({ code: 0, rows: [], list: { length: 0 } }); + } + + // mask out IDs for cats that user shouldn't see + if (cat_limited) items.forEach(function (item) { + var priv_id = 'cat_' + item.category; + if (!privs[priv_id]) item.id = ''; + }); + + // success, return rows and list header + callback({ code: 0, rows: items, list: list }); + }); // got data + }); // loaded session + } + +}); diff --git a/lib/api/group.js b/lib/api/group.js new file mode 100644 index 0000000..f0d66c1 --- /dev/null +++ b/lib/api/group.js @@ -0,0 +1,150 @@ +// Cronicle API Layer - Server Groups +// Copyright (c) 2015 Joseph Huckaby +// Released under the MIT License + +var fs = require('fs'); +var assert = require("assert"); +var async = require('async'); + +var Class = require("pixl-class"); +var Tools = require("pixl-tools"); + +module.exports = Class.create({ + + // + // Server Groups: + // + + api_create_server_group: function(args, callback) { + // add new server group + var self = this; + var group = args.params; + if (!this.requiremanager(args, callback)) return; + + if (!this.requireParams(group, { + title: /\S/, + regexp: /\S/ + }, callback)) return; + + this.loadSession(args, function(err, session, user) { + if (err) return self.doError('session', err.message, callback); + if (!self.requireAdmin(session, user, callback)) return; + + args.user = user; + args.session = session; + + if (group.id) group.id = group.id.toString().toLowerCase().replace(/\W+/g, ''); + if (!group.id) group.id = self.getUniqueID('g'); + + self.logDebug(6, "Creating new server group: " + group.title, group); + + self.storage.listUnshift( 'global/server_groups', group, function(err) { + if (err) { + return self.doError('group', "Failed to create server group: " + err, callback); + } + + self.logDebug(6, "Successfully created server group: " + group.title, group); + self.logTransaction('group_create', group.title, self.getClientInfo(args, { group: group })); + self.logActivity('group_create', { group: group }, args); + + callback({ code: 0, id: group.id }); + + // broadcast update to all websocket clients + self.updateClientData( 'server_groups' ); + + // notify all worker servers about the change as well + // this may have changed their manager server eligibility + self.workerNotifyGroupChange(); + } ); // list insert + } ); // load session + }, + + api_update_server_group: function(args, callback) { + // update existing server group + var self = this; + var params = args.params; + if (!this.requiremanager(args, callback)) return; + + if (!this.requireParams(params, { + id: /^\w+$/ + }, callback)) return; + + this.loadSession(args, function(err, session, user) { + if (err) return self.doError('session', err.message, callback); + if (!self.requireAdmin(session, user, callback)) return; + + args.user = user; + args.session = session; + + self.logDebug(6, "Updating server group: " + params.id, params); + + self.storage.listFindUpdate( 'global/server_groups', { id: params.id }, params, function(err, group) { + if (err) { + return self.doError('group', "Failed to update server group: " + err, callback); + } + + self.logDebug(6, "Successfully updated server group: " + group.title, group); + self.logTransaction('group_update', group.title, self.getClientInfo(args, { group: group })); + self.logActivity('group_update', { group: group }, args); + + callback({ code: 0 }); + + // broadcast update to all websocket clients + self.updateClientData( 'server_groups' ); + + // notify all worker servers about the change as well + // this may have changed their manager server eligibility + self.workerNotifyGroupChange(); + } ); + } ); + }, + + api_delete_server_group: function(args, callback) { + // delete existing server group + var self = this; + var params = args.params; + if (!this.requiremanager(args, callback)) return; + + if (!this.requireParams(params, { + id: /^\w+$/ + }, callback)) return; + + this.loadSession(args, function(err, session, user) { + if (err) return self.doError('session', err.message, callback); + if (!self.requireAdmin(session, user, callback)) return; + + args.user = user; + args.session = session; + + // Do not allow deleting group if any matching events in schedule + self.storage.listFind( 'global/schedule', { target: params.id }, function(err, item) { + if (item) { + return self.doError('plugin', "Failed to delete server group: Still targeted by one or more events.", callback); + } + + self.logDebug(6, "Deleting server group: " + params.id, params); + + self.storage.listFindDelete( 'global/server_groups', { id: params.id }, function(err, group) { + if (err) { + return self.doError('group', "Failed to delete server group: " + err, callback); + } + + self.logDebug(6, "Successfully deleted server group: " + group.title, group); + self.logTransaction('group_delete', group.title, self.getClientInfo(args, { group: group })); + self.logActivity('group_delete', { group: group }, args); + + callback({ code: 0 }); + + // broadcast update to all websocket clients + self.updateClientData( 'server_groups' ); + + // notify all worker servers about the change as well + // this may have changed their manager server eligibility + self.workerNotifyGroupChange(); + + } ); // listFindDelete (group) + } ); // listFind (schedule) + } ); // load session + } + +} ); diff --git a/lib/api/job.js b/lib/api/job.js new file mode 100644 index 0000000..e87933a --- /dev/null +++ b/lib/api/job.js @@ -0,0 +1,587 @@ +// Cronicle API Layer - Jobs +// Copyright (c) 2015 Joseph Huckaby +// Released under the MIT License + +const fs = require('fs'); +const async = require('async'); +const Class = require("pixl-class"); +const Tools = require("pixl-tools"); +const readLastLines = require('read-last-lines'); + +module.exports = Class.create({ + + api_get_job_log: function (args, callback) { + // view job log (plain text or download) + // client API, no auth + var self = this; + + if (!this.requireParams(args.query, { + id: /^\w+$/ + }, callback)) return; + + var key = 'jobs/' + args.query.id + '/log.txt.gz'; + + self.storage.getStream(key, function (err, stream) { + if (err) { + return callback("404 Not Found", {}, "(No log file found.)\n"); + } + + var headers = { + 'Content-Type': "text/plain; charset=utf-8", + 'Content-Encoding': "gzip" + }; + + // optional download instead of view + if (args.query.download) { + headers['Content-disposition'] = "attachment; filename=Cronicle-Job-Log-" + args.query.id + '.txt'; + } + + // pass stream to web server + callback("200 OK", headers, stream); + }); + }, + + api_get_live_console: async function (args, callback) { + // runs on manager + const self = this; + + self.loadSession(args, function (err, session, user) { + if (err) return self.doError('session', err.message, callback); + if (!self.requireValidUser(session, user, callback)) return; + + //let query = args.query; + let params = Tools.mergeHashes(args.params, args.query); + + if (!self.requireParams(params, { + id: /^\w+$/ + }, callback)) return; + + let activeJobs = self.getAllActiveJobs(true) + + let job = activeJobs[params.id] + + if (!job) return self.doError('job', "Invalid or Completed job", callback); + + //self.server.config.get('WebServer').http_port + + let port = self.server.config.get('WebServer').http_port; + let tailUrl = `http://${job.hostname}:${port}/api/app/get_live_log_tail` //?id=${job.id} + let tailSize = parseInt(params.tail) || 80; + let auth = Tools.digestHex(params.id + self.server.config.get('secret_key')) + let reqParams = { id: job.id, tail: tailSize, download: params.download || 0, auth: auth } + + self.request.json(tailUrl, reqParams, (err, resp, data) => { + if (err) return self.doError('job', "Failed to fetch live job log: " + err.message, callback); + data.hostname = job.hostname; + data.event_title = job.event_title; + callback(data); + }); + }); + }, + + api_get_live_log_tail: function (args, callback) { + // internal api, runs on target machine + const self = this; + + let params = Tools.mergeHashes(args.params, args.query); + + if (!this.requireParams(params, { + id: /^\w+$/, + auth: /^\w+$/ + }, callback)) return; + + if (params.auth != Tools.digestHex(params.id + self.server.config.get('secret_key'))) { + return callback("403 Forbidden", {}, "Authentication failure.\n"); + } + + // see if log file exists on this server + var log_file = self.server.config.get('log_dir') + '/jobs/' + params.id + '.log'; + + let tailSize = parseInt(params.tail) || 80; + if (params.download == 1) { // read entire file + fs.readFile(log_file, { encoding: 'utf-8' }, (err, data) => { + if (err) return self.doError('job', "Failed to fetch job log: invalid or completed job", callback); + callback({ data: data }); + }); + + } + else { + readLastLines.read(log_file, tailSize ) + .then( lines => callback({data: lines})) + .catch(e => { return self.doError('job', "Failed to fetch job log: invalid or completed job", callback)}) + } + }, + + + api_get_live_job_log: function (args, callback) { + // get live job job, as it is being written + // client API, no auth + var self = this; + var query = args.query; + + if (!this.requireParams(query, { + id: /^\w+$/ + }, callback)) return; + + // see if log file exists on this server + var log_file = this.server.config.get('log_dir') + '/jobs/' + query.id + '.log'; + + fs.stat(log_file, function (err, stats) { + if (err) { + return self.doError('job', "Failed to fetch job log: " + err, callback); + } + + var headers = { 'Content-Type': "text/html; charset=utf-8" }; + + // optional download instead of view + if (query.download) { + headers['Content-disposition'] = "attachment; filename=Cronicle-Partial-Job-Log-" + query.id + '.txt'; + } + + // get readable stream to file + var stream = fs.createReadStream(log_file); + + // stream to client as plain text + callback("200 OK", headers, stream); + }); + }, + + api_fetch_delete_job_log: function (args, callback) { + // fetch and delete job log, part of finish process + // server-to-server API, deletes log, requires secret key auth + var self = this; + var query = args.query; + + if (!this.requireParams(query, { + path: /^[\w\-\.\/]+\.log$/, + auth: /^\w+$/ + }, callback)) return; + + if (query.auth != Tools.digestHex(query.path + this.server.config.get('secret_key'))) { + return callback("403 Forbidden", {}, "Authentication failure.\n"); + } + + var log_file = query.path; + + fs.stat(log_file, function (err, stats) { + if (err) { + return callback("404 Not Found", {}, "Log file not found: " + log_file + ".\n"); + } + + var headers = { 'Content-Type': "text/plain" }; + + // get readable stream to file + var stream = fs.createReadStream(log_file); + + // stream to client as plain text + callback("200 OK", headers, stream); + + args.response.on('finish', function () { + // only delete local log file once log is COMPLETELY sent + self.logDebug(4, "Deleting log file: " + log_file); + + fs.unlink(log_file, function (err) { + // ignore error + }); + + }); // response finish + + }); // fs.stat + }, + + api_get_log_watch_auth: function (args, callback) { + // generate auth token for watching live job log stream + // (websocket to target server which may be a worker, hence might not have storage) + var self = this; + var params = args.params; + if (!this.requiremanager(args, callback)) return; + + if (!this.requireParams(params, { + id: /^\w+$/ + }, callback)) return; + + this.loadSession(args, function (err, session, user) { + if (err) return self.doError('session', err.message, callback); + if (!self.requireValidUser(session, user, callback)) return; + + args.user = user; + args.session = session; + + var job = self.findJob(params); + if (!job) return self.doError('job', "Failed to locate job: " + params.id, callback); + if (!self.requireCategoryPrivilege(user, job.category, callback)) return; + if (!self.requireGroupPrivilege(args, user, job.target, callback)) return; + + // generate token + var token = Tools.digestHex(params.id + self.server.config.get('secret_key')); + + callback({ code: 0, token: token }); + }); + }, + + api_update_job: function (args, callback) { + // update running job + var self = this; + var params = args.params; + if (!this.requiremanager(args, callback)) return; + + if (!this.requireParams(params, { + id: /^\w+$/ + }, callback)) return; + + this.loadSession(args, function (err, session, user) { + if (err) return self.doError('session', err.message, callback); + if (!self.requireValidUser(session, user, callback)) return; + if (!self.requirePrivilege(user, "edit_events", callback)) return; + + args.user = user; + args.session = session; + + var job = self.findJob(params); + if (!job) return self.doError('job', "Failed to locate job: " + params.id, callback); + if (!self.requireCategoryPrivilege(user, job.category, callback)) return; + if (!self.requireGroupPrivilege(args, user, job.target, callback)) return; + + var result = self.updateJob(params); + if (!result) return self.doError('job', "Failed to update job.", callback); + + self.logTransaction('job_update', params.id, self.getClientInfo(args, params)); + + callback({ code: 0 }); + }); + }, + + api_update_jobs: function (args, callback) { + // update multiple running jobs, search based on criteria (plugin, category, event) + // stash updates in 'updates' key + var self = this; + var params = args.params; + if (!this.requiremanager(args, callback)) return; + + this.loadSession(args, function (err, session, user) { + if (err) return self.doError('session', err.message, callback); + if (!self.requireValidUser(session, user, callback)) return; + if (!self.requirePrivilege(user, "edit_events", callback)) return; + + args.user = user; + args.session = session; + + var updates = params.updates; + delete params.updates; + + var all_jobs = self.getAllActiveJobs(true); + var jobs_arr = []; + for (var key in all_jobs) { + jobs_arr.push(all_jobs[key]); + } + var jobs = Tools.findObjects(jobs_arr, params); + var count = 0; + + for (var idx = 0, len = jobs.length; idx < len; idx++) { + var job = jobs[idx]; + if (!self.requireCategoryPrivilege(user, job.category, callback)) return; + if (!self.requireGroupPrivilege(args, user, job.target, callback)) return; + } + + for (var idx = 0, len = jobs.length; idx < len; idx++) { + var job = jobs[idx]; + var result = self.updateJob(Tools.mergeHashes(updates, { id: job.id })); + if (result) { + count++; + self.logTransaction('job_update', job.id, self.getClientInfo(args, updates)); + } + } // foreach job + + callback({ code: 0, count: count }); + }); + }, + + api_abort_job: function (args, callback) { + // abort running job + var self = this; + var params = args.params; + if (!this.requiremanager(args, callback)) return; + + if (!this.requireParams(params, { + id: /^\w+$/ + }, callback)) return; + + this.loadSession(args, function (err, session, user) { + if (err) return self.doError('session', err.message, callback); + if (!self.requireValidUser(session, user, callback)) return; + if (!self.requirePrivilege(user, "abort_events", callback)) return; + + args.user = user; + args.session = session; + + var job = self.findJob(params); + if (!job) return self.doError('job', "Failed to locate job: " + params.id, callback); + if (!self.requireCategoryPrivilege(user, job.category, callback)) return; + if (!self.requireGroupPrivilege(args, user, job.target, callback)) return; + + var reason = ''; + if (user.key) { + // API Key + reason = "Manually aborted by API Key: " + user.key + " (" + user.title + ")"; + } + else { + reason = "Manually aborted by user: " + user.username; + } + + var result = self.abortJob({ + id: params.id, + reason: reason, + no_rewind: 1 // don't rewind cursor for manually aborted jobs + }); + if (!result) return self.doError('job', "Failed to abort job.", callback); + + callback({ code: 0 }); + }); + }, + + api_abort_jobs: function (args, callback) { + // abort multiple running jobs, search based on criteria (plugin, category, event) + // by default this WILL rewind catch_up events, unless 'no_rewind' is specified + // this will NOT abort any detached jobs + var self = this; + var params = args.params; + if (!this.requiremanager(args, callback)) return; + + this.loadSession(args, function (err, session, user) { + if (err) return self.doError('session', err.message, callback); + if (!self.requireValidUser(session, user, callback)) return; + if (!self.requirePrivilege(user, "abort_events", callback)) return; + + args.user = user; + args.session = session; + + var reason = ''; + if (user.key) { + // API Key + reason = "Manually aborted by API Key: " + user.key + " (" + user.title + ")"; + } + else { + reason = "Manually aborted by user: " + user.username; + } + + var no_rewind = params.no_rewind || 0; + delete params.no_rewind; + + var all_jobs = self.getAllActiveJobs(true); + var jobs_arr = []; + for (var key in all_jobs) { + jobs_arr.push(all_jobs[key]); + } + var jobs = Tools.findObjects(jobs_arr, params); + var count = 0; + + for (var idx = 0, len = jobs.length; idx < len; idx++) { + var job = jobs[idx]; + if (!self.requireCategoryPrivilege(user, job.category, callback)) return; + if (!self.requireGroupPrivilege(args, user, job.target, callback)) return; + } + + for (var idx = 0, len = jobs.length; idx < len; idx++) { + var job = jobs[idx]; + if (!job.detached) { + var result = self.abortJob({ + id: job.id, + reason: reason, + no_rewind: no_rewind + }); + if (result) count++; + } + } // foreach job + + callback({ code: 0, count: count }); + }); + }, + + api_get_job_details: function (args, callback) { + // get details for completed job + // need_log: will fail unless job log is also in storage + var self = this; + var params = Tools.mergeHashes(args.params, args.query); + if (!this.requiremanager(args, callback)) return; + + if (!this.requireParams(params, { + id: /^\w+$/ + }, callback)) return; + + this.loadSession(args, function (err, session, user) { + if (err) return self.doError('session', err.message, callback); + if (!self.requireValidUser(session, user, callback)) return; + + args.user = user; + args.session = session; + + // job log must be available for this to work + self.storage.head('jobs/' + params.id + '/log.txt.gz', function (err, info) { + if (err && params.need_log) { + return self.doError('job', "Failed to fetch job details: " + err, callback); + } + + // now fetch job details + self.storage.get('jobs/' + params.id, function (err, job) { + if (err) { + return self.doError('job', "Failed to fetch job details: " + err, callback); + } + + if (!self.requireCategoryPrivilege(user, job.category, callback)) return; + if (!self.requireGroupPrivilege(args, user, job.target, callback)) return; + + delete job.params; // do not expose params on UI + + callback({ code: 0, job: job }); + }); // job get + }); // log head + }); // session + }, + + api_get_job_status: function (args, callback) { + // get details for job in progress, or completed job + // can be used for polling for completion, look for `complete` flag + var self = this; + var params = Tools.mergeHashes(args.params, args.query); + if (!this.requiremanager(args, callback)) return; + + if (!this.requireParams(params, { + id: /^\w+$/ + }, callback)) return; + + this.loadSession(args, function (err, session, user) { + if (err) return self.doError('session', err.message, callback); + if (!self.requireValidUser(session, user, callback)) return; + + args.user = user; + args.session = session; + + // check live jobs first + var all_jobs = self.getAllActiveJobs(); + var job = all_jobs[params.id]; + if (job) { + if (!self.requireCategoryPrivilege(user, job.category, callback)) return; + if (!self.requireGroupPrivilege(args, user, job.target, callback)) return; + + return callback({ + code: 0, + job: Tools.mergeHashes(job, { + elapsed: Tools.timeNow() - job.time_start + }) + }); + } // found job + + // TODO: Rare but possible race condition here... + // worker server may have removed job from activeJobs, and synced with manager, + // but before manager created the job record + + // no good? see if job completed... + self.storage.get('jobs/' + params.id, function (err, job) { + if (err) { + return self.doError('job', "Failed to fetch job details: " + err, callback); + } + + if (!self.requireCategoryPrivilege(user, job.category, callback)) return; + if (!self.requireGroupPrivilege(args, user, job.target, callback)) return; + + callback({ code: 0, job: job }); + }); // job get + }); // session + }, + + api_delete_job: function (args, callback) { + // delete all files for completed job + var self = this; + var params = Tools.mergeHashes(args.params, args.query); + if (!this.requiremanager(args, callback)) return; + + if (!this.requireParams(params, { + id: /^\w+$/ + }, callback)) return; + + this.loadSession(args, function (err, session, user) { + if (err) return self.doError('session', err.message, callback); + if (!self.requireAdmin(session, user, callback)) return; + + args.user = user; + args.session = session; + + // fetch job details + self.storage.get('jobs/' + params.id, function (err, job) { + if (err) { + return self.doError('job', "Failed to fetch job details: " + err, callback); + } + + var stub = { + action: 'job_delete', + id: job.id, + event: job.event + }; + + async.series( + [ + function (callback) { + // update event history + // ignore error as this may fail for a variety of reasons + self.storage.listFindReplace('logs/events/' + job.event, { id: job.id }, stub, function (err) { callback(); }); + }, + function (callback) { + // update global history + // ignore error as this may fail for a variety of reasons + self.storage.listFindReplace('logs/completed', { id: job.id }, stub, function (err) { callback(); }); + }, + function (callback) { + // delete job log + // ignore error as this may fail for a variety of reasons + self.storage.delete('jobs/' + job.id + '/log.txt.gz', function (err) { callback(); }); + }, + function (callback) { + // delete job details + // this should never fail + self.storage.delete('jobs/' + job.id, callback); + } + ], + function (err) { + // check for error + if (err) { + return self.doError('job', "Failed to delete job: " + err, callback); + } + + // add note to admin log + self.logActivity('job_delete', stub, args); + + // log transaction + self.logTransaction('job_delete', job.id, self.getClientInfo(args)); + + // and we're done + callback({ code: 0 }); + } + ); // async.series + }); // job get + }); // session + }, + + api_get_active_jobs: function (args, callback) { + // get all active jobs in progress + var self = this; + var params = Tools.mergeHashes(args.params, args.query); + if (!this.requiremanager(args, callback)) return; + + this.loadSession(args, function (err, session, user) { + if (err) return self.doError('session', err.message, callback); + if (!self.requireValidUser(session, user, callback)) return; + + // make a copy of active job, remove .params property since it might contain key info + let activeJobs = JSON.parse(JSON.stringify(self.getAllActiveJobs(true))); + for (let id in activeJobs) { + delete activeJobs[id].params + } + + return callback({ + code: 0, + jobs: activeJobs + }); + }); // session + } + +}); diff --git a/lib/api/plugin.js b/lib/api/plugin.js new file mode 100644 index 0000000..3583c9e --- /dev/null +++ b/lib/api/plugin.js @@ -0,0 +1,228 @@ +// Cronicle API Layer - Plugins +// Copyright (c) 2015 Joseph Huckaby +// Released under the MIT License + +var fs = require('fs'); +var sqparse = require('shell-quote').parse; + +var Class = require("pixl-class"); +var Tools = require("pixl-tools"); + +module.exports = Class.create({ + + // + // Plugins: + // + + api_get_plugins: function(args, callback) { + // get list of plugins (with pagination) + var self = this; + var params = args.params; + if (!this.requiremanager(args, callback)) return; + + this.loadSession(args, function(err, session, user) { + if (err) return self.doError('session', err.message, callback); + if (!self.requireValidUser(session, user, callback)) return; + + self.storage.listGet( 'global/plugins', parseInt(params.offset || 0), parseInt(params.limit || 0), function(err, items, list) { + if (err) { + // no plugins found, not an error for this API + return callback({ code: 0, rows: [], list: { length: 0 } }); + } + + // success, return plugins and list header + callback({ code: 0, rows: items, list: list }); + } ); // got plugin list + } ); // loaded session + }, + + api_create_plugin: function(args, callback) { + // add new plugin + var self = this; + var plugin = args.params; + if (!this.requiremanager(args, callback)) return; + + if (!this.requireParams(plugin, { + title: /\S/, + command: /\S/ + }, callback)) return; + + if (!this.requireValidPluginCommand(plugin.command, callback)) return; + + this.loadSession(args, function(err, session, user) { + if (err) return self.doError('session', err.message, callback); + if (!self.requireAdmin(session, user, callback)) return; + + args.user = user; + args.session = session; + + if (plugin.id) plugin.id = plugin.id.toString().toLowerCase().replace(/\W+/g, ''); + if (!plugin.id) plugin.id = self.getUniqueID('p'); + + plugin.params = plugin.params || []; + plugin.username = user.username; + plugin.created = plugin.modified = Tools.timeNow(true); + + self.logDebug(6, "Creating new plugin: " + plugin.title, plugin); + + self.storage.listUnshift( 'global/plugins', plugin, function(err) { + if (err) { + return self.doError('plugin', "Failed to create plugin: " + err, callback); + } + + self.logDebug(6, "Successfully created plugin: " + plugin.title, plugin); + self.logTransaction('plugin_create', plugin.title, self.getClientInfo(args, { plugin: plugin })); + self.logActivity('plugin_create', { plugin: plugin }, args); + + callback({ code: 0, id: plugin.id }); + + // broadcast update to all websocket clients + self.updateClientData( 'plugins' ); + } ); // list insert + } ); // load session + }, + + api_update_plugin: function(args, callback) { + // update existing plugin + var self = this; + var params = args.params; + if (!this.requiremanager(args, callback)) return; + + if (!this.requireParams(params, { + id: /^\w+$/ + }, callback)) return; + + if (params.command) { + if (!this.requireValidPluginCommand(params.command, callback)) return; + } + + this.loadSession(args, function(err, session, user) { + if (err) return self.doError('session', err.message, callback); + if (!self.requireAdmin(session, user, callback)) return; + + args.user = user; + args.session = session; + + self.storage.listFind( 'global/plugins', { id: params.id }, function(err, plugin) { + if (err || !plugin) { + return self.doError('event', "Failed to locate plugin: " + params.id, callback); + } + + params.modified = Tools.timeNow(true); + + self.logDebug(6, "Updating plugin: " + plugin.title, params); + + // avoid erasing secret value on save, if value is not provided + params.secret = params.secret || plugin.secret; + + // pull abort flag out of event object, for use later + var abort_jobs = 0; + if (params.abort_jobs) { + abort_jobs = params.abort_jobs; + delete params.abort_jobs; + } + + self.storage.listFindUpdate( 'global/plugins', { id: params.id }, params, function(err) { + if (err) { + return self.doError('plugin', "Failed to update plugin: " + err, callback); + } + + // merge params into plugin, just so we have the full updated record + for (var key in params) plugin[key] = params[key]; + + self.logDebug(6, "Successfully updated plugin: " + plugin.title, params); + self.logTransaction('plugin_update', plugin.title, self.getClientInfo(args, { plugin: params })); + self.logActivity('plugin_update', { plugin: params }, args); + + callback({ code: 0 }); + + // broadcast update to all websocket clients + self.updateClientData( 'plugins' ); + + // if plugin is disabled, abort all applicable jobs + if (!plugin.enabled && abort_jobs) { + var all_jobs = self.getAllActiveJobs(true); + for (var key in all_jobs) { + var job = all_jobs[key]; + if ((job.plugin == plugin.id) && !job.detached) { + var msg = "Plugin '" + plugin.title + "' has been disabled."; + self.logDebug(4, "Job " + job.id + " is being aborted: " + msg); + self.abortJob({ id: job.id, reason: msg }); + } // matches plugin + } // foreach job + } // plugin disabled + + // if plugin is being enabled, force scheduler to re-tick the minute + var dargs = Tools.getDateArgs( new Date() ); + if (params.enabled && !self.schedulerGraceTimer && !self.schedulerTicking && (dargs.sec != 59)) { + self.schedulerMinuteTick( null, true ); + } + + } ); // update plugin + } ); // find plugin + } ); // load session + }, + + api_delete_plugin: function(args, callback) { + // delete existing plugin + var self = this; + var params = args.params; + if (!this.requiremanager(args, callback)) return; + + if (!this.requireParams(params, { + id: /^\w+$/ + }, callback)) return; + + this.loadSession(args, function(err, session, user) { + if (err) return self.doError('session', err.message, callback); + if (!self.requireAdmin(session, user, callback)) return; + + args.user = user; + args.session = session; + + // Do not allow deleting plugin if any matching events in schedule + self.storage.listFind( 'global/schedule', { plugin: params.id }, function(err, item) { + if (item) { + return self.doError('plugin', "Failed to delete plugin: Still assigned to one or more events.", callback); + } + + self.logDebug(6, "Deleting plugin: " + params.id); + + // Okay to delete + self.storage.listFindDelete( 'global/plugins', { id: params.id }, function(err, plugin) { + if (err) { + return self.doError('plugin', "Failed to delete plugin: " + err, callback); + } + + self.logDebug(6, "Successfully deleted plugin: " + plugin.title, plugin); + self.logTransaction('plugin_delete', plugin.title, self.getClientInfo(args, { plugin: plugin })); + self.logActivity('plugin_delete', { plugin: plugin }, args); + + callback({ code: 0 }); + + // broadcast update to all websocket clients + self.updateClientData( 'plugins' ); + + } ); // listFindDelete + } ); // listFind + } ); // load session + }, + + requireValidPluginCommand: function(command, callback) { + // make sure plugin command is valid + if (command.match(/\s+(.+)$/)) { + var cargs_raw = RegExp.$1; + var cargs = sqparse( cargs_raw, {} ); + + for (var idx = 0, len = cargs.length; idx < len; idx++) { + var carg = cargs[idx]; + if (typeof(carg) == 'object') { + return this.doError('plugin', "Plugin executable cannot contain any shell redirects or pipes.", callback); + } + } + } + + return true; + } + +} ); diff --git a/lib/comm.js b/lib/comm.js new file mode 100644 index 0000000..d7dc44b --- /dev/null +++ b/lib/comm.js @@ -0,0 +1,553 @@ +// Cronicle Server Communication Layer +// Copyright (c) 2015 Joseph Huckaby +// Released under the MIT License + +var cp = require('child_process'); +var dns = require("dns"); +var SocketIO = require('socket.io'); +var SocketIOClient = require('socket.io-client'); + +var Class = require("pixl-class"); +var Tools = require("pixl-tools"); + +module.exports = Class.create({ + + workers: null, + sockets: null, + + setupCluster: function() { + // establish communication channel with all workers + var self = this; + + // workers are servers the manager can send jobs to + this.workers = {}; + + // we're a worker too (but no socket needed) + this.workers[ this.server.hostname ] = { + manager: 1, + hostname: this.server.hostname + }; + + // add any registered workers + this.storage.listGet( 'global/servers', 0, 0, function(err, servers) { + if (err) servers = []; + for (var idx = 0, len = servers.length; idx < len; idx++) { + var server = servers[idx]; + self.addServer( server ); + } + } ); + }, + + addServer: function(server, args) { + // add new server to cluster + var self = this; + if (this.workers[ server.hostname ]) return; + + this.logDebug(5, "Adding worker to cluster: " + server.hostname + " (" + (server.ip || 'n/a') + ")"); + + var worker = { + hostname: server.hostname, + ip: server.ip || '' + }; + + // connect via socket.io + this.connectToworker(worker); + + // add worker to cluster + this.workers[ worker.hostname ] = worker; + + // notify clients of the server change + this.authSocketEmit( 'update', { servers: this.getAllServers() } ); + + // log activity for new server + this.logActivity( 'server_add', { hostname: worker.hostname, ip: worker.ip || '' }, args ); + }, + + connectToworker: function(worker) { + // establish communication with worker via socket.io + var self = this; + var port = this.web.config.get('http_port'); + + var url = ''; + if (this.server.config.get('server_comm_use_hostnames')) { + url = 'http://' + worker.hostname + ':' + port; + } + else { + url = 'http://' + (worker.ip || worker.hostname) + ':' + port; + } + + this.logDebug(8, "Connecting to worker via socket.io: " + url); + + var socket = new SocketIOClient( url, { + multiplex: false, + forceNew: true, + reconnection: false, + // reconnectionDelay: 1000, + // reconnectionDelayMax: 1000, + // reconnectionDelayMax: this.server.config.get('manager_ping_freq') * 1000, + // reconnectionAttempts: Infinity, + // randomizationFactor: 0, + timeout: 5000 + } ); + + socket.on('connect', function() { + self.logDebug(6, "Successfully connected to worker: " + worker.hostname); + + var now = Tools.timeNow(true); + var token = Tools.digestHex( self.server.hostname + now + self.server.config.get('secret_key') ); + + // authenticate server-to-server with time-based token + socket.emit( 'authenticate', { + token: token, + now: now, + manager_hostname: self.server.hostname + } ); + + // remove disabled flag, in case this is a reconnect + if (worker.disabled) { + delete worker.disabled; + self.logDebug(5, "Marking worker as enabled: " + worker.hostname); + + // log activity for this + self.logActivity( 'server_enable', { hostname: worker.hostname, ip: worker.ip || '' } ); + + // notify clients of the server change + self.authSocketEmit( 'update', { servers: self.getAllServers() } ); + } // disabled + + // reset reconnect delay + delete worker.socketReconnectDelay; + } ); + + /*socket.on('reconnectingDISABLED', function(err) { + self.logDebug(6, "Reconnecting to worker: " + worker.hostname); + + // mark worker as disabled to avoid sending it new jobs + if (!worker.disabled) { + worker.disabled = true; + self.logDebug(5, "Marking worker as disabled: " + worker.hostname); + + // notify clients of the server change + self.authSocketEmit( 'update', { servers: self.getAllServers() } ); + + // if worker had active jobs, move them to limbo + if (worker.active_jobs) { + for (var id in worker.active_jobs) { + self.logDebug(5, "Moving job to limbo: " + id); + self.deadJobs[id] = worker.active_jobs[id]; + self.deadJobs[id].time_dead = Tools.timeNow(true); + } + delete worker.active_jobs; + } + } // not disabled yet + } );*/ + + socket.on('disconnect', function() { + if (!socket._pixl_disconnected) { + self.logError('server', "worker disconnected unexpectedly: " + worker.hostname); + self.reconnectToworker(worker); + } + else { + self.logDebug(5, "worker disconnected: " + worker.hostname, socket.id); + } + } ); + socket.on('error', function(err) { + self.logError('server', "worker socket error: " + worker.hostname + ": " + err); + } ); + socket.on('connect_error', function(err) { + self.logError('server', "worker connection failed: " + worker.hostname + ": " + err); + if (!socket._pixl_disconnected) self.reconnectToworker(worker); + } ); + socket.on('connect_timeout', function() { + self.logError('server', "worker connection timeout: " + worker.hostname); + } ); + /*socket.on('reconnect_error', function(err) { + self.logError('server', "worker reconnection failed: " + worker.hostname + ": " + err); + } ); + socket.on('reconnect_failed', function() { + self.logError('server', "worker retries exhausted: " + worker.hostname); + } );*/ + + // Custom commands: + + socket.on('status', function(status) { + self.logDebug(10, "Got status from worker: " + worker.hostname, status); + Tools.mergeHashInto( worker, status ); + self.checkServerClock(worker); + self.checkServerJobs(worker); + + // sanity check (should never happen) + if (worker.manager) self.managerConflict(worker); + } ); + + socket.on('finish_job', function(job) { + self.finishJob( job ); + } ); + + socket.on('fetch_job_log', function(job) { + self.fetchStoreJobLog( job ); + } ); + + socket.on('auth_failure', function(data) { + var err_msg = "Authentication failure, cannot add worker: " + worker.hostname + " ("+data.description+")"; + self.logError('server', err_msg); + self.logActivity('error', { description: err_msg } ); + self.removeServer( worker ); + } ); + + worker.socket = socket; + }, + + reconnectToworker: function(worker) { + // reconnect to worker after socket error + var self = this; + + // mark worker as disabled to avoid sending it new jobs + if (!worker.disabled) { + worker.disabled = true; + self.logDebug(5, "Marking worker as disabled: " + worker.hostname); + + // log activity for this + self.logActivity( 'server_disable', { hostname: worker.hostname, ip: worker.ip || '' } ); + + // notify clients of the server change + self.authSocketEmit( 'update', { servers: self.getAllServers() } ); + + // if worker had active jobs, move them to limbo + if (worker.active_jobs) { + for (var id in worker.active_jobs) { + self.logDebug(5, "Moving job to limbo: " + id); + self.deadJobs[id] = worker.active_jobs[id]; + self.deadJobs[id].time_dead = Tools.timeNow(true); + } + delete worker.active_jobs; + } + } // not disabled yet + + // slowly back off retries to N sec to avoid spamming the logs too much + if (!worker.socketReconnectDelay) worker.socketReconnectDelay = 0; + if (worker.socketReconnectDelay < this.server.config.get('manager_ping_freq')) worker.socketReconnectDelay++; + + worker.socketReconnectTimer = setTimeout( function() { + delete worker.socketReconnectTimer; + if (!self.server.shut) { + self.logDebug(6, "Reconnecting to worker: " + worker.hostname); + self.connectToworker(worker); + } + }, worker.socketReconnectDelay * 1000 ); + }, + + checkServerClock: function(worker) { + // make sure worker clock is close to ours + if (!worker.clock_drift) worker.clock_drift = 0; + var now = Tools.timeNow(); + var drift = Math.abs( now - worker.epoch ); + + if ((drift >= 10) && (worker.clock_drift < 10)) { + var err_msg = "Server clock is " + Tools.shortFloat(drift) + " seconds out of sync: " + worker.hostname; + this.logError('server', err_msg); + this.logActivity('error', { description: err_msg } ); + } + + worker.clock_drift = drift; + }, + + checkServerJobs: function(worker) { + // remove any worker jobs from limbo, if applicable + if (worker.active_jobs) { + for (var id in worker.active_jobs) { + if (this.deadJobs[id]) { + this.logDebug(5, "Taking job out of limbo: " + id); + delete this.deadJobs[id]; + } + } + } + }, + + removeServer: function(server, args) { + // remove server from cluster + var worker = this.workers[ server.hostname ]; + if (!worker) return; + + this.logDebug(5, "Removing worker from cluster: " + worker.hostname + " (" + (worker.ip || 'n/a') + ")"); + + // Deal with active jobs that were on the lost server + // Stick them in limbo with a short timeout + if (worker.active_jobs) { + for (var id in worker.active_jobs) { + this.logDebug(5, "Moving job to limbo: " + id); + this.deadJobs[id] = worker.active_jobs[id]; + this.deadJobs[id].time_dead = Tools.timeNow(true); + } + delete worker.active_jobs; + } + + if (worker.socket) { + worker.socket._pixl_disconnected = true; + worker.socket.off('disconnect'); + worker.socket.disconnect(); + delete worker.socket; + } + if (worker.socketReconnectTimer) { + clearTimeout( worker.socketReconnectTimer ); + delete worker.socketReconnectTimer; + } + + delete this.workers[ worker.hostname ]; + + // notify clients of the server change + this.authSocketEmit( 'update', { servers: this.getAllServers() } ); + + // log activity for lost server + this.logActivity( 'server_remove', { hostname: worker.hostname }, args ); + }, + + startSocketListener: function() { + // start listening for websocket connections + this.numSocketClients = 0; + this.sockets = {}; + this.io = SocketIO(); + + this.io.attach( this.web.http ); + if (this.web.https) this.io.attach( this.web.https ); + + this.io.on('connection', this.handleNewSocket.bind(this) ); + }, + + handleNewSocket: function(socket) { + // handle new socket connection from socket.io + // this could be from a web browser, or a server-to-server conn + var self = this; + var ip = socket.request.connection.remoteAddress || socket.client.conn.remoteAddress || 'Unknown'; + + socket._pixl_auth = false; + + this.numSocketClients++; + this.sockets[ socket.id ] = socket; + this.logDebug(5, "New socket.io client connected: " + socket.id + " (IP: " + ip + ")"); + + socket.on('authenticate', function(params) { + // client is trying to authenticate + if (params.manager_hostname && params.now && params.token) { + // manager-to-worker connection (we are the worker) + var correct_token = Tools.digestHex( params.manager_hostname + params.now + self.server.config.get('secret_key') ); + if (params.token != correct_token) { + socket.emit( 'auth_failure', { description: "Secret Keys do not match." } ); + return; + } + /*if (Math.abs(Tools.timeNow() - params.now) > 60) { + socket.emit( 'auth_failure', { description: "Server clocks are too far out of sync." } ); + return; + }*/ + + self.logDebug(4, "Socket client " + socket.id + " has authenticated via secret key (IP: "+ip+")"); + socket._pixl_auth = true; + socket._pixl_manager = true; + + // force multi-server init (quick startup: to skip waiting for the tock) + self.logDebug(3, "manager server is: " + params.manager_hostname); + + // set some flags + self.multi.cluster = true; + self.multi.managerHostname = params.manager_hostname; + self.multi.managerIP = ip; + self.multi.manager = false; + self.multi.lastPingReceived = Tools.timeNow(true); + + if (!self.multi.worker) self.goworker(); + + // need to recheck this + self.checkmanagerEligibility(); + } // secret_key + else { + // web client to server connection + self.storage.get( 'sessions/' + params.token, function(err, data) { + if (err) { + self.logError('socket', "Socket client " + socket.id + " failed to authenticate (IP: "+ip+")"); + socket.emit( 'auth_failure', { description: "Session not found." } ); + } + else { + self.logDebug(4, "Socket client " + socket.id + " has authenticated via user session (IP: "+ip+")"); + socket._pixl_auth = true; + } + } ); + } + } ); + + socket.on('launch_job', function(job) { + // launch job (server-to-server comm) + if (socket._pixl_auth) self.launchLocalJob( job ); + } ); + + socket.on('abort_job', function(stub) { + // abort job (server-to-server comm) + if (socket._pixl_auth) self.abortLocalJob( stub ); + } ); + + socket.on('update_job', function(stub) { + // update job (server-to-server comm) + if (socket._pixl_auth) self.updateLocalJob( stub ); + } ); + + socket.on('restart_server', function(args) { + // restart server (server-to-server comm) + if (socket._pixl_auth) self.restartLocalServer(args); + } ); + + socket.on('shutdown_server', function(args) { + // shut down server (server-to-server comm) + if (socket._pixl_auth) self.shutdownLocalServer(args); + } ); + + socket.on('watch_job_log', function(args) { + // tail -f job log + self.watchJobLog(args, socket); + } ); + + socket.on('groups_changed', function(args) { + // recheck manager server eligibility + self.logDebug(4, "Server groups have changed, rechecking manager eligibility"); + self.checkmanagerEligibility(); + } ); + + socket.on('logout', function(args) { + // user wants out? okay then + socket._pixl_auth = false; + socket._pixl_manager = false; + } ); + + socket.on('manager_ping', function(args) { + // manager has given dobby a ping! + self.logDebug(10, "Received ping from manager server"); + self.multi.lastPingReceived = Tools.timeNow(true); + } ); + + socket.on('error', function(err) { + self.logError('socket', "Client socket error: " + socket.id + ": " + err); + } ); + + socket.on('disconnect', function() { + // client disconnected + socket._pixl_disconnected = true; + self.numSocketClients--; + delete self.sockets[ socket.id ]; + self.logDebug(5, "Socket.io client disconnected: " + socket.id + " (IP: " + ip + ")"); + } ); + }, + + sendmanagerPings: function() { + // send a ping to all workers + this.workerBroadcastAll('manager_ping'); + }, + + workerNotifyGroupChange: function() { + // notify all workers that server groups have changed + this.workerBroadcastAll('groups_changed'); + }, + + workerBroadcastAll: function(key, data) { + // broadcast message to all workers + if (!this.multi.manager) return; + + for (var hostname in this.workers) { + var worker = this.workers[hostname]; + if (worker.socket) { + worker.socket.emit(key, data || {}); + } + } + }, + + getAllServers: function() { + // get combo hash of all UDP-managed servers, and any manually added workers + if (!this.multi.manager) return null; + var servers = {}; + var now = Tools.timeNow(true); + + // add us first (the manager) + servers[ this.server.hostname ] = { + hostname: this.server.hostname, + ip: this.server.ip, + manager: 1, + uptime: now - (this.server.started || now), + data: this.multi.data || {}, + disabled: 0 + }; + + // then add all workers + for (var hostname in this.workers) { + var worker = this.workers[hostname]; + if (!servers[hostname]) { + servers[hostname] = { + hostname: hostname, + ip: worker.ip || '', + manager: 0, + uptime: worker.uptime || 0, + data: worker.data || {}, + disabled: worker.disabled || 0 + }; + } // unique hostname + } // foreach worker + + return servers; + }, + + shutdownLocalServer: function(args) { + // shut down local server + if (this.server.debug) { + this.logDebug(5, "Skipping shutdown command, as we're in debug mode."); + return; + } + + this.logDebug(1, "Shutting down server: " + (args.reason || 'Unknown reason')); + + // issue shutdown command + this.server.shutdown(); + }, + + restartLocalServer: function(args) { + // restart server, but only if in daemon mode + if (this.server.debug) { + this.logDebug(5, "Skipping restart command, as we're in debug mode."); + return; + } + + this.logDebug(1, "Restarting server: " + (args.reason || 'Unknown reason')); + + // issue a restart command by shelling out to our control script in a detached child + child = cp.spawn( "bin/control.sh", ["restart"], { + detached: true, + stdio: ['ignore', 'ignore', 'ignore'] + } ); + child.unref(); + }, + + shutdownCluster: function() { + // shut down all server connections + if (this.sockets) { + for (var id in this.sockets) { + var socket = this.sockets[id]; + this.logDebug(9, "Closing client socket: " + socket.id); + socket.disconnect(); + } + } + + if (this.multi.manager) { + for (var hostname in this.workers) { + var worker = this.workers[hostname]; + if (worker.socket) { + this.logDebug(9, "Closing worker connection: " + worker.hostname, worker.socket.id); + worker.socket._pixl_disconnected = true; + worker.socket.off('disconnect'); + worker.socket.disconnect(); + delete worker.socket; + } + if (worker.socketReconnectTimer) { + clearTimeout( worker.socketReconnectTimer ); + delete worker.socketReconnectTimer; + } + } + this.workers = {}; + } // manager + } + +}); diff --git a/lib/discovery.js b/lib/discovery.js new file mode 100644 index 0000000..b68d9f1 --- /dev/null +++ b/lib/discovery.js @@ -0,0 +1,180 @@ +// Cronicle Server Discovery Layer +// Copyright (c) 2015 Joseph Huckaby +// Released under the MIT License + +var dgram = require("dgram"); +var os = require('os'); +var Netmask = require('netmask').Netmask; + +var Class = require("pixl-class"); +var Tools = require("pixl-tools"); + +module.exports = Class.create({ + + nearbyServers: null, + lastDiscoveryBroadcast: 0, + + setupDiscovery: function(callback) { + // setup auto-discovery system + // listen for UDP pings, and broadcast our own ping + var self = this; + + this.nearbyServers = {}; + this.lastDiscoveryBroadcast = 0; + + // disable if port is unset + if (!this.server.config.get('udp_broadcast_port')) { + if (callback) callback(); + return; + } + + // guess best broadcast IP + this.broadcastIP = this.server.config.get('broadcast_ip') || this.calcBroadcastIP(); + this.logDebug(4, "Using broadcast IP: " + this.broadcastIP ); + + // start UDP socket listener + this.logDebug(4, "Starting UDP server on port: " + this.server.config.get('udp_broadcast_port')); + var listener = this.discoveryListener = dgram.createSocket("udp4"); + + listener.on("message", function (msg, rinfo) { + self.discoveryReceive( msg, rinfo ); + } ); + + listener.on("error", function (err) { + self.logError('udp', "UDP socket listener error: " + err); + self.discoveryListener = null; + } ); + + listener.bind( this.server.config.get('udp_broadcast_port'), function() { + if (callback) callback(); + } ); + }, + + discoveryTick: function() { + // broadcast pings every N + if (!this.discoveryListener) return; + var now = Tools.timeNow(true); + + if (now - this.lastDiscoveryBroadcast >= this.server.config.get('manager_ping_freq')) { + this.lastDiscoveryBroadcast = now; + + // only broadcast if not part of a cluster + if (!this.multi.cluster) { + this.discoveryBroadcast( 'heartbeat', { + hostname: this.server.hostname, + ip: this.server.ip + } ); + } + + // prune servers who have stopped broadcasting + for (var hostname in this.nearbyServers) { + var server = this.nearbyServers[hostname]; + if (now - server.now >= this.server.config.get('manager_ping_timeout')) { + delete this.nearbyServers[hostname]; + if (this.multi.manager) { + this.authSocketEmit( 'update', { nearby: this.nearbyServers } ); + } + } + } + } + }, + + discoveryBroadcast: function(type, message, callback) { + // broadcast message via UDP + var self = this; + + message.action = type; + this.logDebug(10, "Broadcasting message: " + type, message); + + var client = dgram.createSocket('udp4'); + var message = Buffer.from( JSON.stringify(message) + "\n" ); + client.bind( 0, function() { + client.setBroadcast( true ); + client.send(message, 0, message.length, self.server.config.get('udp_broadcast_port'), self.broadcastIP, function(err) { + if (err) self.logDebug(9, "UDP broadcast failed: " + err); + client.close(); + if (callback) callback(); + } ); + } ); + }, + + discoveryReceive: function(msg, rinfo) { + // receive UDP message from another server + this.logDebug(10, "Received UDP message: " + msg + " from " + rinfo.address + ":" + rinfo.port); + + var text = msg.toString(); + if (text.match(/^\{/)) { + // appears to be JSON + var json = null; + try { json = JSON.parse(text); } + catch (e) { + this.logError(9, "Failed to parse UDP JSON message: " + e); + } + if (json && json.action) { + switch (json.action) { + + case 'heartbeat': + if (json.hostname && (json.hostname != this.server.hostname)) { + json.now = Tools.timeNow(); + delete json.action; + + if (!this.nearbyServers[ json.hostname ]) { + // first time we've seen this server + this.nearbyServers[ json.hostname ] = json; + if (this.multi.manager) { + this.logDebug(6, "Discovered nearby server: " + json.hostname, json); + this.authSocketEmit( 'update', { nearby: this.nearbyServers } ); + } + } + else { + // update from existing server + this.nearbyServers[ json.hostname ] = json; + } + this.logDebug(10, "Received heartbeat from: " + json.hostname, json); + } + break; + + } // switch action + } // got json + } // appears to be json + }, + + calcBroadcastIP: function() { + // Attempt to determine server's Broadcast IP, using the first LAN IP and Netmask + // https://en.wikipedia.org/wiki/Broadcast_address + var ifaces = os.networkInterfaces(); + var addrs = []; + for (var key in ifaces) { + if (ifaces[key] && ifaces[key].length) { + Array.from(ifaces[key]).forEach( function(item) { addrs.push(item); } ); + } + } + var addr = Tools.findObject( addrs, { family: 'IPv4', internal: false } ); + if (addr && addr.address && addr.address.match(/^\d+\.\d+\.\d+\.\d+$/) && addr.netmask && addr.netmask.match(/^\d+\.\d+\.\d+\.\d+$/)) { + // well that was easy + var ip = addr.address; + var mask = addr.netmask; + + var block = null; + try { block = new Netmask( ip + '/' + mask ); } + catch (err) {;} + + if (block && block.broadcast && block.broadcast.match(/^\d+\.\d+\.\d+\.\d+$/)) { + return block.broadcast; + } + } + return '255.255.255.255'; + }, + + shutdownDiscovery: function() { + // shutdown + var self = this; + + // shutdown UDP listener + if (this.discoveryListener) { + this.logDebug(3, "Shutting down UDP server"); + this.discoveryListener.close(); + } + } + +}); diff --git a/lib/engine.js b/lib/engine.js new file mode 100644 index 0000000..2627e59 --- /dev/null +++ b/lib/engine.js @@ -0,0 +1,1514 @@ +// Cronicle Server Component +// Copyright (c) 2015 Joseph Huckaby +// Released under the MIT License + +var assert = require("assert"); +var fs = require("fs"); +var mkdirp = require('mkdirp'); +var async = require('async'); +var glob = require('glob'); +var jstz = require('jstimezonedetect'); + +var Class = require("pixl-class"); +var Component = require("pixl-server/component"); +var Tools = require("pixl-tools"); +var Request = require("pixl-request"); +var ld = require("lodash"); +const simpleGit = require('simple-git'); + + +module.exports = Class.create({ + + __name: 'Cronicle', + __parent: Component, + __mixins: [ + require('./api.js'), // API Layer Mixin + require('./comm.js'), // Communication Layer Mixin + require('./scheduler.js'), // Scheduler Mixin + require('./job.js'), // Job Management Layer Mixin + require('./queue.js'), // Queue Layer Mixin + require('./discovery.js') // Discovery Layer Mixin + ], + + activeJobs: null, + deadJobs: null, + eventQueue: null, + kids: null, + state: null, + + defaultWebHookTextTemplates: { + "job_start": "Job started on [hostname]: [event_title] [job_details_url]", + "job_complete": "Job completed successfully on [hostname]: [event_title] [job_details_url]", + "job_failure": "Job failed on [hostname]: [event_title]: Error [code]: [description] [job_details_url]", + "job_launch_failure": "Failed to launch scheduled event: [event_title]: [description] [edit_event_url]" + }, + + startup: function (callback) { + // start cronicle service + var self = this; + this.logDebug(3, "Cronicle engine starting up"); + + // create a few extra dirs we'll need + try { mkdirp.sync(this.server.config.get('log_dir') + '/jobs'); } + catch (e) { + throw new Error("FATAL ERROR: Log directory could not be created: " + this.server.config.get('log_dir') + "/jobs: " + e); + } + try { mkdirp.sync(this.server.config.get('queue_dir')); } + catch (e) { + throw new Error("FATAL ERROR: Queue directory could not be created: " + this.server.config.get('queue_dir') + ": " + e); + } + + // dirs should be writable by all users + fs.chmodSync(this.server.config.get('log_dir') + '/jobs', "777"); + fs.chmodSync(this.server.config.get('queue_dir'), "777"); + + // keep track of jobs + this.activeJobs = {}; + this.deadJobs = {}; + this.eventQueue = {}; + this.kids = {}; + this.state = { enabled: true, cursors: {}, stats: {}, flagged_jobs: {} }; + this.temp_keys = {}; + this.normalShutdown = false; + + // we'll need these components frequently + this.storage = this.server.Storage; + this.web = this.server.WebServer; + this.api = this.server.API; + this.usermgr = this.server.User; + + // register custom storage type for dual-metadata-log delete + this.storage.addRecordType('cronicle_job', { + 'delete': this.deleteExpiredJobData.bind(this) + }); + + // multi-server cluster / failover system + this.multi = { + cluster: false, + manager: false, + worker: false, + managerHostname: '', + eligible: false, + lastPingReceived: 0, + lastPingSent: 0, + data: {} + }; + + // construct http client for web hooks and uploading logs + this.request = new Request("Cronicle " + this.server.__version); + + // optionally bypass all ssl cert validation + if (this.server.config.get('ssl_cert_bypass') || this.server.config.get('web_hook_ssl_cert_bypass')) { + process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0"; + } + + // register our class as an API namespace + this.api.addNamespace("app", "api_", this); + + // intercept API requests to inject server groups + this.web.addURIFilter(/^\/api\/app\/\w+/, "API Filter", function (args, callback) { + // load server groups for all API requests (these are cached in RAM) + self.storage.listGet('global/server_groups', 0, 0, function (err, items) { + args.server_groups = items; + callback(false); // passthru + }); + }); + + // custom routes/pages (config viewer, job dashboard, etc) + this.web.addURIHandler(/^\/conf$/i, 'Reports', __dirname + "/../htdocs/custom/config.html"); + this.web.addURIHandler(/^\/db$/i, 'Reports', __dirname + "/../htdocs/custom/dashboard.html"); + this.web.addURIHandler(/^\/console$/i, 'Console', __dirname + "/../htdocs/custom/console.html"); + + // git setup , only for Filesystem storage engine + if (self.server.config.get('Storage').Filesystem) { + let gitConf = self.server.config.get('git') + this.git = simpleGit(self.server.config.get('Storage').Filesystem.base_dir) + this.gitlock = 0; + + } + + // register a handler for HTTP OPTIONS (for CORS AJAX preflight) + this.web.addMethodHandler("OPTIONS", "CORS Preflight", this.corsPreflight.bind(this)); + + // start socket.io server, attach to http/https + this.startSocketListener(); + + // start auto-discovery listener (UDP) + this.setupDiscovery(); + + // add uncaught exception handler + require('uncatch').on('uncaughtException', this.emergencyShutdown.bind(this)); + + // listen for ticks so we can broadcast status + this.server.on('tick', this.tick.bind(this)); + + // register hooks for when users are created / deleted + this.usermgr.registerHook('after_create', this.afterUserChange.bind(this, 'user_create')); + this.usermgr.registerHook('after_update', this.afterUserChange.bind(this, 'user_update')); + this.usermgr.registerHook('after_delete', this.afterUserChange.bind(this, 'user_delete')); + this.usermgr.registerHook('after_login', this.afterUserLogin.bind(this)); + + // intercept user login and session resume, to merge in extra data + this.usermgr.registerHook('before_login', this.beforeUserLogin.bind(this)); + this.usermgr.registerHook('before_resume_session', this.beforeUserLogin.bind(this)); + + // monitor active jobs (for timeouts, etc.) + this.server.on('minute', function () { + // force gc 10s after the minute + // (only if global.gc is exposed by node CLI arg) + if (global.gc) { + self.gcTimer = setTimeout(function () { + delete self.gcTimer; + self.logDebug(10, "Forcing garbage collection now", process.memoryUsage()); + global.gc(); + self.logDebug(10, "Garbage collection complete", process.memoryUsage()); + }, 10 * 1000); + } + }); + + // archive logs daily at midnight + this.server.on('day', function () { + self.archiveLogs(); + }); + + // determine manager server eligibility + this.checkmanagerEligibility(function () { + // manager mode (CLI option) -- force us to become manager right away + if (self.server.config.get('manager') && self.multi.eligible) self.gomanager(); + + // reset the failover counter + self.multi.lastPingReceived = Tools.timeNow(true); + + // startup complete + callback(); + }); + + // load configs from storage (master only) + config = self.server.config.get() + this.storage.listGet('global/conf_keys', 0, 0, function (err, items, list) { + if (err) { + // no keys found, do nothing + } + if (items) { // items only would exist on master + for (i = 0; i < items.length; i++) { + if (items[i].key === 'false') items[i].key = false + if (items[i].title) ld.set(config, items[i].title, items[i].key); + //console.log(items[i]); + } + } + }); + + }, + + checkmanagerEligibility: function (callback) { + // determine manager server eligibility + var self = this; + + this.storage.listGet('global/servers', 0, 0, function (err, servers) { + if (err) { + // this may happen on worker servers that have no access to storage -- silently fail + servers = []; + } + + if (!Tools.findObject(servers, { hostname: self.server.hostname }) && !self.multi.cluster) { + // we were not found in server list + self.multi.eligible = false; + if (!self.multi.worker) { + self.logDebug(4, "Server not found in cluster -- waiting for a manager server to contact us"); + } + if (callback) callback(); + return; + } + + // found server in cluster, good + self.multi.cluster = true; + + // now check server groups + self.storage.listGet('global/server_groups', 0, 0, function (err, groups) { + if (err) { + // this may happen on worker servers that have no access to storage -- silently fail + groups = []; + } + + // scan all manager groups for our hostname + var eligible = false; + var group_title = ''; + + for (var idx = 0, len = groups.length; idx < len; idx++) { + var group = groups[idx]; + + var regexp = null; + try { regexp = new RegExp(group.regexp); } + catch (e) { + self.logError('manager', "Invalid group regular expression: " + group.regexp + ": " + e); + regexp = null; + } + + if (group.manager && regexp && self.server.hostname.match(regexp)) { + eligible = true; + group_title = group.title; + idx = len; + } + } + + if (eligible) { + self.logDebug(4, "Server is eligible to become manager (" + group_title + ")"); + self.multi.eligible = true; + } + else { + self.logDebug(4, "Server is not eligible for manager -- it will be a worker only"); + self.multi.eligible = false; + } + + if (callback) callback(); + + }); // global/server_groups + }); // global/servers + }, + + tick: function () { + // called every second + var self = this; + this.lastTick = Tools.timeNow(); + var now = Math.floor(this.lastTick); + + if (this.numSocketClients) { + var status = { + epoch: Tools.timeNow(), + manager: this.multi.manager, + manager_hostname: this.multi.managerHostname + }; + if (this.multi.manager) { + // web client connection to manager + // send additional information only needed by UI + + // remove .params property from active job (it may contain key info) + let activeJobs = JSON.parse(JSON.stringify(this.getAllActiveJobs(true))); + for (let id in activeJobs) { delete activeJobs[id].params } + status.active_jobs = activeJobs; + status.servers = this.getAllServers(); + } + else { + // we are a worker, so just send our own jobs and misc server health stats + status.active_jobs = this.activeJobs; + status.queue = this.internalQueue || {}; + status.data = this.multi.data; + status.uptime = now - (this.server.started || now); + } + + // this.io.emit( 'status', status ); + this.authSocketEmit('status', status); + } + + // monitor manager health + if (!this.multi.manager) { + var delta = now - this.multi.lastPingReceived; + if (delta >= this.server.config.get('manager_ping_timeout')) { + if (this.multi.eligible) this.managerFailover(); + else if (this.multi.worker) this.workerFailover(); + } + } + + // as manager, broadcast pings every N seconds + if (this.multi.manager) { + var delta = now - this.multi.lastPingSent; + if (delta >= this.server.config.get('manager_ping_freq')) { + this.sendmanagerPings(); + this.multi.lastPingSent = now; + } + } + + // monitor server resources every N seconds (or 1 min if no local jobs) + var msr_freq = Tools.numKeys(this.activeJobs) ? (this.server.config.get('monitor_res_freq') || 10) : 60; + if (!this.lastMSR || (now - this.lastMSR >= msr_freq)) { + this.lastMSR = now; + + this.monitorServerResources(function (err) { + // nicer to do this after gathering server resources + self.monitorAllActiveJobs(); + }); + } + + // auto-discovery broadcast pings + this.discoveryTick(); + }, + + authSocketEmit: function (key, data) { + // Only emit to authenticated clients + for (var id in this.sockets) { + var socket = this.sockets[id]; + if (socket._pixl_auth) socket.emit(key, data); + } + }, + + managerSocketEmit: function () { + // Only emit to manager server -- and make sure this succeeds + // Internally queue upon failure + var count = 0; + var key = ''; + var data = null; + + if (arguments.length == 2) { + key = arguments[0]; + data = arguments[1]; + } + else if (arguments.length == 1) { + key = arguments[0].key; + data = arguments[0].data; + } + + for (var id in this.sockets) { + var socket = this.sockets[id]; + if (socket._pixl_manager) { + socket.emit(key, data); + count++; + } + } + + if (!count) { + // enqueue this for retry + this.logDebug(8, "No manager server socket connection available, will retry"); + this.enqueueInternal({ + action: 'managerSocketEmit', + key: key, + data: data, + when: Tools.timeNow(true) + 10 + }); + } + }, + + updateClientData: function (name) { + // broadcast global list update to all clients + // name should be one of: plugins, categories, server_groups, schedule + assert(name != 'users'); + + var self = this; + this.storage.listGet('global/' + name, 0, 0, function (err, items) { + if (err) { + self.logError('storage', "Failed to fetch list: global/" + name + ": " + err); + return; + } + + let itemCopy = JSON.parse(JSON.stringify(items)) // make a copy to avoid side effects + if (name == 'plugins') { // clear out secrets + if (Array.isArray(itemCopy)) itemCopy.forEach(plug => delete plug.secret) + } + + var data = {}; + data[name] = itemCopy; + // self.io.emit( 'update', data ); + self.authSocketEmit('update', data); + }); + }, + + beforeUserLogin: function (args, callback) { + // infuse data into user login client response + var self = this; + + // remove .params property from active job (it may contain key info) + let activeJobs = JSON.parse(JSON.stringify(this.getAllActiveJobs(true))); + for (let id in activeJobs) { delete activeJobs[id].params } + + args.resp = { + epoch: Tools.timeNow(), + servers: this.getAllServers(), + nearby: this.nearbyServers, + activeJobs: activeJobs, + eventQueue: this.eventQueue, + state: this.state + }; + + // load essential data lists in parallel (these are, or will be, cached in RAM) + async.each(['schedule', 'categories', 'plugins', 'server_groups'], + function (name, callback) { + self.storage.listGet('global/' + name, 0, 0, function (err, items) { + + let itemCopy = JSON.parse(JSON.stringify(items)) // make a copy to avoid side effects + if (name == 'plugins') { // clear out secrets + if (Array.isArray(itemCopy)) itemCopy.forEach(plug => delete plug.secret) + } + args.resp[name] = itemCopy || []; + callback(); + }); + }, + callback + ); // each + }, + + afterUserLogin: function (args) { + // log the login + this.logActivity('user_login', { + user: Tools.copyHashRemoveKeys(args.user, { password: 1, salt: 1 }) + }, args); + }, + + afterUserChange: function (action, args) { + // user data has changed, notify all connected clients + // Note: user data is not actually sent here -- this just triggers a client redraw if on the user list page + // this.io.emit( 'update', { users: {} } ); + this.authSocketEmit('update', { users: {} }); + + // add to activity log in the background + this.logActivity(action, { + user: Tools.copyHashRemoveKeys(args.user, { password: 1, salt: 1 }) + }, args); + }, + + gitSync: async function (message, callback) { + + let self = this; + + if (self.gitlock) return callback(new Error("Git Sync Failed: sync is locked by another process"), null); + self.gitlock = 1; + + let gitConf = self.server.config.get('git'); + let isRepo = await self.git.checkIsRepo(); + + if (!self.git || !gitConf.enabled || !isRepo) return callback(new Error("Git Sync: git is not set or enabled"), null); + + let gitAdd = (gitConf.add || 'global,users').split(/[,;|]/).map(e => e.trim()).filter(e => e.match(/^[\w.]+$/g)); + if (gitAdd.length === 0) gitAdd = ["global", "users"]; + + if (gitConf.add) gitAdd = gitConf.add.toString().split(',') + + self.git + .env("GIT_AUTHOR_NAME", gitConf.user || "cronicle") + .env("GIT_COMMITER_NAME", gitConf.user || "cronicle") + .env("EMAIL", gitConf.email || "cronicle@cronicle.com") + .add(gitAdd) + .commit(message || `update as of ${(new Date).toLocaleString()}`) + .push(gitConf.remote || 'origin', gitConf.branch || 'master') + .exec(result => { + self.gitlock = 0; + callback(null, result); + }) + .catch(err => { + self.gitlock = 0; + callback(err, null) + }) + + }, + + fireInfoHook: function (web_hook, data, logMessage) { + + let self = this; + let wh_config; + + logMessage = logMessage || 'Firing Info Web Hook' + + if (typeof web_hook === 'string') { + wh_config = { url: web_hook } + } + else if (typeof web_hook === 'object') { + wh_config = Tools.mergeHashes(web_hook, {}); + if (typeof wh_config.url !== 'string') wh_config.url = ""; + + } else { + return self.logDebug(9, "Web Hook Error: Invaid data type (string or object expected"); + } + // combine global and hook specific options (if specified) + let wh_options = Tools.mergeHashes(self.server.config.get('web_hook_custom_opts') || {}, wh_config.options || {}) + + let wh_data = Tools.mergeHashes(data || {}, wh_config.data || {}) // + + // oauth helper + let wh_headers = wh_config.headers || {} + if (wh_config.token) wh_headers['Authorization'] = 'Bearer ' + wh_config.token; + + // if specified, copy text property to some other property + if (wh_config.textkey) ld.set(wh_data, wh_config.textkey, wh_data.text ) + + //wh_options.data = wh_data; + wh_options.headers = wh_headers; + + self.logDebug(9, logMessage); + self.request.json(wh_config.url, wh_data, wh_options, function (err, resp, data) { + // log response + if (err) self.logDebug(9, "Web Hook Error: " + wh_config.url + ": " + err); + else self.logDebug(9, "Web Hook Response: " + wh_config.url + ": HTTP " + resp.statusCode + " " + resp.statusMessage); + }); + + }, + + logActivity: function (action, orig_data, args) { + // add event to activity logs async + var self = this; + if (!args) args = {}; + + assert(Tools.isaHash(orig_data), "Must pass a data object to logActivity"); + var data = Tools.copyHash(orig_data, true); + + // sanity check: make sure we are still manager + if (!this.multi.manager) return; + + data.action = action; + data.epoch = Tools.timeNow(true); + + if (args.ip) data.ip = args.ip; + if (args.request) data.headers = args.request.headers; + + if (args.admin_user) data.username = args.admin_user.username; + else if (args.user) { + if (args.user.key) { + // API Key + data.api_key = args.user.key; + data.api_title = args.user.title; + } + else { + data.username = args.user.username; + } + } + + this.storage.enqueue(function (task, callback) { + self.storage.listUnshift('logs/activity', data, callback); + }); + + let adminHook = this.server.config.get('admin_web_hook'); + let onUpdateHook = this.server.config.get('onupdate_web_hook'); + let onInfoHook = this.server.config.get('oninfo_web_hook'); + + let gitConf = self.server.config.get('git') + + let msg = 'ℹ️ ' + action + let baseUrl = this.server.config.get('base_app_url'); + let actionType = '' + let item = ''; + + let modBy = ''; + if (data.username || data.ip) modBy = `\n _${data.username}@${data.ip} - ${(new Date()).toLocaleTimeString()}_` + + try { + + switch (action) { + + // categories + case 'cat_create': + msg = `📁 New category created: *${data.cat.title}* `; + actionType = 'update'; + item = data.cat.title; + break; + case 'cat_update': + msg = `📁 New category updated: *${data.cat.title}* `; + actionType = 'update'; + item = data.cat.title; + break; + case 'cat_delete': + msg = `📁 New category deleted: *${data.cat.title}* `; + actionType = 'update'; + item = data.cat.title; + break; + + // groups + case 'group_create': + msg = `🖥️ Server group created: *${data.group.title}* ` + actionType = 'update'; + item = data.group.title; + break; + case 'group_update': + msg = `🖥️ Server group updated: *${data.group.title}* `; + actionType = 'update'; + item = data.group.title; + break; + case 'group_delete': + msg = `🖥️ Server group deleted: *${data.group.title}* `; + actionType = 'update'; + item = data.group.title; + break; + + // plugins + case 'plugin_create': + msg = `🔌 Plugin created: *${data.plugin.title}* `; + actionType = 'update'; + item = data.plugin.title; + break; + case 'plugin_update': + msg = `🔌 Plugin updated: *${data.plugin.title}* `; + actionType = 'update'; + item = data.plugin.title; + break; + case 'plugin_delete': + msg = `🔌 Plugin deleted: *${data.plugin.title}* `; + actionType = 'update'; + item = data.plugin.title; + break; + + // api keys + case 'apikey_create': + msg = `🔑 New API Key created: *${data.api_key.title}* ${('' + data.api_key.key).substr(0, 4) + '******'} `; + actionType = 'update'; + item = data.api_key.title; + break; + case 'apikey_update': + msg = `🔑 API Key updated: *${data.api_key.title}* ${('' + data.api_key.key).substr(0, 4) + '******'} `; + actionType = 'update'; + item = data.api_key.title; + break; + case 'apikey_delete': + msg = `🔑 API Key deleted: *${data.api_key.title}* ${('' + data.api_key.key).substr(0, 4) + '******'} `; + actionType = 'update'; + item = data.api_key.title; + break; + + // conf keys + case 'confkey_create': + msg = `🔧 Config Key created: *${data.conf_key.title}* : ${data.conf_key.key} `; + actionType = 'update'; + item = data.conf_key.title; + break; + case 'confkey_update': + msg = `🔧 Config Key updated: *${data.conf_key.title}* : ${data.conf_key.key} `; + actionType = 'update'; + item = data.conf_key.title; + break; + case 'confkey_delete': + msg = `🔧 Config Key deleted: *${data.conf_key.title}* : ${data.conf_key.key} `; + actionType = 'update'; + item = data.conf_key.title; + break; + + + // events + case 'event_create': + msg = `🕘 New event added: *${data.event.title}* `; + msg += `<${baseUrl}/#Schedule?sub=edit_event&id=${data.event.id} | Edit Event > `; + item = data.event.title; + actionType = 'update'; + break; + case 'event_update': + msg = `🕘 Event updated: *${data.event.title}* `; + msg += `<${baseUrl}/#Schedule?sub=edit_event&id=${data.event.id} | Edit Event > `; + actionType = 'update'; + item = data.event.title; + break; + case 'event_delete': + msg = `🕘 Event deleted: *${data.event.title}* `; + actionType = 'update'; + item = data.event.title; + break; + case 'event_enabled': + msg = `ℹ️ Event *${data.event.title}* was enabled `; + msg += `<${baseUrl}/#Schedule?sub=edit_event&id=${data.event.id} | Edit Event > `; + actionType = 'update'; + item = data.event.title; + break; + case 'event_disabled': + msg = `ℹ️ Event *${data.event.title}* was disabled `; + msg += `<${baseUrl}/#Schedule?sub=edit_event&id=${data.event.id} | Edit Event > `; + actionType = 'update'; + item = data.event.title; + break; + + // users + case 'user_create': + msg = `👤 New user account created: *${data.user.username}* (${data.user.full_name}) `; + msg += `<${baseUrl}/#Admin?sub=edit_user&username=${data.user.username} | Edit User> `; + actionType = 'update'; + item = data.user.username; + break; + case 'user_update': + msg = `👤 User account updated: *${data.user.username}* (${data.user.full_name}) `; + msg += `<${baseUrl}/#Admin?sub=edit_user&username=${data.user.username} | Edit User> `; + actionType = 'update'; + item = data.user.username; + break; + case 'user_delete': + msg = `👤 User account deleted: *${data.user.username}* (${data.user.full_name}) `; + actionType = 'update'; + item = data.user.username; + break; + case 'user_login': + msg = "👤 User logged in: *" + data.user.username + "* (" + data.user.full_name + ") "; + actionType = 'info'; + break; + + // servers + + case 'server_add': // current + msg = '🖥️ Server ' + (data.manual ? 'manually ' : '') + 'added to cluster: *' + data.hostname + '* '; + actionType = 'update'; + item = data.hostname; + break; + + case 'server_remove': // current + msg = '🖥️ Server ' + (data.manual ? 'manually ' : '') + 'removed from cluster: *' + data.hostname + '* '; + actionType = 'update'; + item = data.hostname; + break; + + case 'server_manager': // current + msg = '🖥️ Server has become manager: *' + data.hostname + '*' + actionType = 'info'; + item = data.hostname; + break; + + case 'server_restart': + msg = '🖥️ Server restarted: *' + data.hostname + '* '; + actionType = 'info'; + item = data.hostname; + break; + case 'server_shutdown': + msg = '🖥️ Server shut down: *' + data.hostname + '* '; + actionType = 'info'; + item = data.hostname; + break; + case 'server_sigterm': + msg = '🖥️ Server shut down (sigterm): *' + data.hostname + '* '; + actionType = 'info'; + item = data.hostname; + break; + + case 'server_disable': + msg = '🖥️ Lost connectivity to server: *' + data.hostname + '*'; + actionType = 'info'; + item = data.hostname; + break; + + case 'server_enable': + msg = '🖥️ Reconnected to server: *' + data.hostname + '*'; + actionType = 'info'; + item = data.hostname; + break; + + // jobs + case 'job_run': + msg = '📊 Job *#' + data.id + '* (' + data.title + ') manually started '; + msg += ` <${baseUrl}/#JobDetails?id=${data.id} | Job Details> `; + actionType = 'job'; + item = data.id; + break; + case 'job_complete': + if (!data.code) { + msg = '📊 Job *#' + data.id + '* (' + data.title + ') on server *' + data.hostname.replace(/\.[\w\-]+\.\w+$/, '') + '* completed successfully'; + } + else { + msg = '📊 Job *#' + data.id + '* (' + data.title + ') on server *' + data.hostname.replace(/\.[\w\-]+\.\w+$/, '') + '* failed with error: ' + encode_entities(data.description || 'Unknown Error').replace(/\x1B\[[0-?]*[ -/]*[@-~]/g, ""); + } + msg += ` <${baseUrl}/#JobDetails?id=${data.id} | Job Details> _- ${(new Date()).toLocaleTimeString()}_ `; + actionType = 'job'; + item = data.id; + break; + case 'job_delete': + msg = '📊 Job *#' + data.id + '* (' + data.title + ') manually deleted'; + actionType = 'update'; + item = data.id; + break; + + case 'job_failure': // xxx + msg = `❌ Job *${data.job.id} 📊 (${data.job.event_title})* failed:\n ${data.job.description} \n`; + msg += `<${this.server.config.get('base_app_url')}/#JobDetails?id=${data.job.id} | More details> _- ${(new Date()).toLocaleTimeString()}_ `; + actionType = 'job'; + item = data.job.id; + break; + + // scheduler + case 'state_update': + msg = 'ℹ️ Scheduler manager switch was *' + (data.enabled ? 'enabled' : 'disabled') + '* '; + actionType = 'info'; + item = (data.enabled ? 'enabled' : 'disabled'); + break; + + // errors + case 'error': + msg = '❌ Error: ' + data.description; + actionType = 'info'; + break; + + // warnings + case 'warning': + msg = '⚠️ ' + data.description; + actionType = 'info'; + break; + + case 'backup': + msg = 'ℹ️ Backup completed'; + actionType = 'info'; + break; + + case 'restore': + msg = 'ℹ️ Restore completed: ' + JSON.stringify(data.info, null, 2); + actionType = 'info'; + break; + + } + } catch (err) { + self.logDebug(9, "Admin Web Hook Message failed: " + err); + } + + let hookData = { text: (msg + modBy), action: action, item: item, type: actionType }; + + // fire onupdate hook (on create/update/delete) + if (onUpdateHook && actionType == 'update') { + this.fireInfoHook(onUpdateHook, hookData, "Firing onupdate Hook"); + } + + // fire oninfo hook (on startup/shutdown/error/etc) + if (onInfoHook && actionType == 'info') { + this.fireInfoHook(onInfoHook, hookData, "Firing oninfo Hook"); + } + + // fire admin webhook (all action) + if (adminHook) { + this.fireInfoHook(adminHook, hookData, "Firing Admin Hook"); + } + + + // auto git sync (on change or shutdown) + if (gitConf.enabled && gitConf.auto && (actionType == 'update' || action == 'server_shutdown' || action == 'server_restart')) { + let gitMsg = `${action} ${item}`.substring(0, 45) + this.gitSync(gitMsg, (err, data) => { + if (err) { + return self.logDebug(9, "Git Sync Error: " + err); + } + self.logDebug(9, "Git Sync Success: "); + }); + } + + // manual git sync (only if click on backup button) + if (gitConf.enabled && action == 'backup') { + this.gitSync('manual backup', (err, data) => { + if (err) { + if (adminHook) { // for debugging purposes send git error to admin webhook + self.request.json(adminHook, { text: "Git Sync Failed: " + err.message }, (err, resp, data) => { + if (err) self.logDebug(9, "Admin Web Hook Error: " + err); + }); + } + return self.logDebug(9, "Git Sync Error: " + err); + } + self.logDebug(9, "Git Sync Success: "); + }); + } + + + + //} + }, + + gomanager: function () { + // we just became the manager server + var self = this; + this.logDebug(3, "We are becoming the manager server"); + + this.multi.manager = true; + this.multi.worker = false; + this.multi.cluster = true; + this.multi.managerHostname = this.server.hostname; + this.multi.lastPingSent = Tools.timeNow(true); + + // we need to know the server timezone at this point + this.tz = jstz.determine().name(); + + // only the manager server should enable storage maintenance + this.server.on(this.server.config.get('maintenance'), function () { + self.storage.runMaintenance(new Date(), self.runMaintenance.bind(self)); + }); + + // only the manager server should enable storage ram caching + this.storage.config.set('cache_key_match', '^global/'); + this.storage.prepConfig(); + + // start server cluster management + this.setupCluster(); + + // start scheduler + this.setupScheduler(); + + // start queue monitor + this.setupQueue(); + + // clear daemon stats every day at midnight + this.server.on('day', function () { + self.state.stats = {}; + }); + + // easter egg, let's see if anyone notices + this.server.on('year', function () { + self.logDebug(1, "Happy New Year!"); + }); + + // log this event + if (!this.server.debug) { + this.logActivity('server_manager', { hostname: this.server.hostname }); + } + + // recover any leftover logs + this.recoverJobLogs(); + }, + + goworker: function () { + // we just became a worker server + // recover any leftover logs + this.logDebug(3, "We are becoming a worker server"); + + this.multi.manager = false; + this.multi.worker = true; + this.multi.cluster = true; + + // start queue monitor + this.setupQueue(); + + // recover any leftover logs + this.recoverJobLogs(); + }, + + managerFailover: function () { + // No manager ping recieved in N seconds, so we need to choose a new manager + var self = this; + var servers = []; + var groups = []; + + this.logDebug(3, "No manager ping received within " + this.server.config.get('manager_ping_timeout') + " seconds, choosing new manager"); + + // make sure tick() doesn't keep calling us + this.multi.lastPingReceived = Tools.timeNow(true); + + async.series([ + function (callback) { + self.storage.listGet('global/servers', 0, 0, function (err, items) { + servers = items || []; + callback(err); + }); + }, + function (callback) { + self.storage.listGet('global/server_groups', 0, 0, function (err, items) { + groups = items || []; + callback(err); + }); + } + ], + function (err) { + // all resources loaded + if (err || !servers.length || !groups.length) { + // should never happen + self.logDebug(4, "No servers found, going into idle mode"); + self.multi.managerHostname = ''; + self.multi.manager = self.multi.worker = self.multi.cluster = false; + return; + } + + // compile list of manager server candidates + var candidates = {}; + + for (var idx = 0, len = groups.length; idx < len; idx++) { + var group = groups[idx]; + if (group.manager) { + + var regexp = null; + try { regexp = new RegExp(group.regexp); } + catch (e) { + self.logError('manager', "Invalid group regular expression: " + group.regexp + ": " + e); + regexp = null; + } + + if (regexp) { + for (var idy = 0, ley = servers.length; idy < ley; idy++) { + var server = servers[idy]; + if (server.hostname.match(regexp)) { + candidates[server.hostname] = server; + } + } // foreach server + } + } // manager group + } // foreach group + + // sanity check: we better be in the list + if (!candidates[self.server.hostname]) { + self.logDebug(4, "We are no longer eligible for manager, going into idle mode"); + self.multi.managerHostname = ''; + self.multi.manager = self.multi.worker = self.multi.cluster = false; + return; + } + + // sort hostnames alphabetically to determine rank + var hostnames = Object.keys(candidates).sort(); + + if (!hostnames.length) { + // should never happen + self.logDebug(4, "No eligible servers found, going into idle mode"); + self.multi.managerHostname = ''; + self.multi.manager = self.multi.worker = self.multi.cluster = false; + return; + } + + // see if any servers are 'above' us in rank + var rank = hostnames.indexOf(self.server.hostname); + if (rank == 0) { + // we are the top candidate, so become manager immediately + self.logDebug(5, "We are the top candidate for manager"); + self.gomanager(); + return; + } + + // ping all servers higher than us to see if any of them are alive + var superiors = hostnames.splice(0, rank); + var alive = []; + self.logDebug(6, "We are rank #" + rank + " for manager, pinging superiors", superiors); + + async.each(superiors, + function (hostname, callback) { + var server = candidates[hostname]; + + var api_url = self.getServerBaseAPIURL(hostname, server.ip) + '/app/status'; + self.logDebug(10, "Sending API request to remote server: " + hostname + ": " + api_url); + + // send request + self.request.json(api_url, {}, { timeout: 5 * 1000 }, function (err, resp, data) { + if (err) { + self.logDebug(10, "Failed to contact server: " + hostname + ": " + err); + return callback(); + } + if (resp.statusCode != 200) { + self.logDebug(10, "Failed to contact server: " + hostname + ": HTTP " + resp.statusCode + " " + resp.statusMessage); + return callback(); + } + if (data.code != 0) { + self.logDebug(10, "Failed to ping server: " + hostname + ": " + data.description); + return callback(); + } + if (data.tick_age > 60) { + self.logDebug(1, "WARNING: Failed to ping server: " + hostname + ": Tick age is " + Math.floor(data.tick_age) + "s", data); + return callback(); + } + + // success, at least one superior ponged our ping + // relinquish command to them + self.logDebug(10, "Successfully pinged server: " + hostname); + alive.push(hostname); + callback(); + }); + }, + function () { + if (alive.length) { + self.logDebug(6, alive.length + " servers ranked above us are alive, so we will become a worker", alive); + } + else { + self.logDebug(5, "No other servers ranked above us are alive, so we are the top candidate for manager"); + self.gomanager(); + } + } // pings complete + ); // async.each + }); // got servers and groups + }, + + workerFailover: function () { + // remove ourselves from worker duty, and go into idle mode + if (this.multi.worker) { + this.logDebug(3, "No manager ping received within " + this.server.config.get('manager_ping_timeout') + " seconds, going idle"); + this.multi.managerHostname = ''; + this.multi.manager = this.multi.worker = this.multi.cluster = false; + this.lastDiscoveryBroadcast = 0; + } + }, + + managerConflict: function (worker) { + // should never happen: a worker has become manager + // we must shut down right away if this happens + var err_msg = "manager CONFLICT: The server '" + worker.hostname + "' is also a manager server. Shutting down immediately."; + this.logDebug(1, err_msg); + this.logError('multi', err_msg); + this.server.shutdown(); + }, + + isProcessRunning: function (pid) { + // check if process is running or not + var ping = false; + try { ping = process.kill(pid, 0); } + catch (e) { ; } + return ping; + }, + + recoverJobLogs: function () { + // upload any leftover job logs (after unclean shutdown) + var self = this; + + // don't run this if shutting down + if (this.server.shut) return; + + // make sure this ONLY runs once + if (this.recoveredJobLogs) return; + this.recoveredJobLogs = true; + + // look for any leftover job JSON files (manager server shutdown) + var job_spec = this.server.config.get('log_dir') + '/jobs/*.json'; + this.logDebug(4, "Looking for leftover job JSON files: " + job_spec); + + glob(job_spec, {}, function (err, files) { + // got job json files + if (!files) files = []; + async.eachSeries(files, function (file, callback) { + // foreach job file + if (file.match(/\/(\w+)(\-detached)?\.json$/)) { + var job_id = RegExp.$1; + + fs.readFile(file, { encoding: 'utf8' }, function (err, data) { + var job = null; + try { job = JSON.parse(data); } catch (e) { ; } + if (job) { + self.logDebug(5, "Recovering job data: " + job_id + ": " + file, job); + + if (job.detached && job.pid && self.isProcessRunning(job.pid)) { + // detached job is still running, resume it! + self.logDebug(5, "Detached PID " + job.pid + " is still alive, resuming job as if nothing happened"); + self.activeJobs[job_id] = job; + self.kids[job.pid] = { pid: job.pid }; + return callback(); + } + else if (job.detached && fs.existsSync(self.server.config.get('queue_dir') + '/' + job_id + '-complete.json')) { + // detached job completed while service was stopped + // Note: Bit of a possible race condition here, as setupQueue() runs in parallel, and 'minute' event may fire + self.logDebug(5, "Detached job " + job_id + " completed on its own, skipping recovery (queue will pick it up)"); + + // disable job timeout, to prevent race condition with monitorAllActiveJobs() + job.timeout = 0; + + self.activeJobs[job_id] = job; + self.kids[job.pid] = { pid: job.pid }; + return callback(); + } + else { + // job died when server went down + job.complete = 1; + job.code = 1; + job.description = "Aborted Job: Server '" + self.server.hostname + "' shut down unexpectedly."; + self.logDebug(6, job.description); + + if (self.multi.manager) { + // we're manager, finish the job locally + self.finishJob(job); + } // manager + else { + // we're a worker, signal manager to finish job via websockets + self.io.emit('finish_job', job); + } // worker + } // standard job + } + + fs.unlink(file, function (err) { + // ignore error, file may not exist + callback(); + }); + }); // fs.readFile + } // found id + else callback(); + }, + function (err) { + // done with glob eachSeries + self.logDebug(9, "Job recovery complete"); + + var log_spec = self.server.config.get('log_dir') + '/jobs/*.log'; + self.logDebug(4, "Looking for leftover job logs: " + log_spec); + + // look for leftover log files + glob(log_spec, {}, function (err, files) { + // got log files + if (!files) files = []; + async.eachSeries(files, function (file, callback) { + // foreach log file + if (file.match(/\/(\w+)(\-detached)?\.log$/)) { + var job_id = RegExp.$1; + + // only recover logs for dead jobs + if (!self.activeJobs[job_id]) { + self.logDebug(5, "Recovering job log: " + job_id + ": " + file); + self.uploadJobLog({ id: job_id, log_file: file, hostname: self.server.hostname }, callback); + } + else callback(); + } // found id + else callback(); + }, + function (err) { + // done with glob eachSeries + self.logDebug(9, "Log recovery complete"); + + // cleanup old log .tmp files, which may have failed during old archive/rotation + glob(self.server.config.get('log_dir') + '/jobs/*.tmp', {}, function (err, files) { + if (!files || !files.length) return; + async.eachSeries(files, function (file, callback) { fs.unlink(file, callback); }); + }); // glob + }); + }); // glob + }); // eachSeries + }); // glob + }, + + runMaintenance: function (callback) { + // run routine daily tasks, called after storage maint completes. + // make sure our activity logs haven't grown beyond the max limit + var self = this; + var max_rows = this.server.config.get('list_row_max') || 0; + if (!max_rows) return; + + // sanity check: make sure we are still manager + if (!this.multi.manager) return; + + // don't run this if shutting down + if (this.server.shut) return; + + var list_paths = ['logs/activity', 'logs/completed']; + + this.storage.listGet('global/schedule', 0, 0, function (err, items) { + if (err) { + self.logError('maint', "Failed to fetch list: global/schedule: " + err); + return; + } + + for (var idx = 0, len = items.length; idx < len; idx++) { + list_paths.push('logs/events/' + items[idx].id); + } + + async.eachSeries(list_paths, + function (list_path, callback) { + // iterator function, work on single list + self.storage.listGetInfo(list_path, function (err, info) { + // list may not exist, skip if so + if (err) return callback(); + + // check list length + if (info.length > max_rows) { + // list has grown too long, needs a trim + self.logDebug(3, "List " + list_path + " has grown too long, trimming to max: " + max_rows, info); + // self.storage.listSplice( list_path, max_rows, info.length - max_rows, null, callback ); + self.listRoughChop(list_path, max_rows, callback); + } + else { + // no trim needed, proceed to next list + callback(); + } + }); // get list info + }, // iterator + function (err) { + if (err) { + self.logError('maint', "Failed to trim lists: " + err); + } + + // done with maint + if (callback) callback(); + + } // complete + ); // eachSeries + }); // schedule loaded + + // sanity state cleanup: flagged jobs + if (this.state.flagged_jobs && Tools.numKeys(this.state.flagged_jobs)) { + var all_jobs = this.getAllActiveJobs(); + for (var id in this.state.flagged_jobs) { + if (!all_jobs[id]) delete this.state.flagged_jobs[id]; + } + } + }, + + listRoughChop: function (list_path, max_rows, callback) { + // Roughly chop the end of a list to get the size down to under specified ceiling. + // This is not exact, and will likely be off by up to one 'page_size' of items. + // The idea here is to chop without using tons of memory like listSplice does. + var self = this; + this.logDebug(4, "Roughly chopping list: " + list_path + " down to max: " + max_rows); + + self.storage._listLock(list_path, true, function () { + // list is now locked, we can work on it safely + + self.storage.listGetInfo(list_path, function (err, list) { + // check for error + if (err) { + self.logError('maint', "Failed to load list header: " + list_path + ": " + err); + self.storage._listUnlock(list_path); + return callback(); + } + + // begin chop loop + async.whilst( + function () { + // keep chopping while list is too long + return (list.length > max_rows); + }, + function (callback) { + // load last page + self.storage._listLoadPage(list_path, list.last_page, false, function (err, page) { + if (err) { + // this should never happen, and is bad + // (list is probably corrupted, and should be deleted and recreated) + return callback(new Error("Failed to load list page: " + list_path + "/" + list.last_page + ": " + err)); + } + if (!page) page = { items: [] }; + if (!page.items) page.items = []; + + list.length -= page.items.length; + + self.logDebug(5, "Deleting list page: " + list_path + "/" + list.last_page + " (" + page.items.length + " items)"); + + self.storage.delete(list_path + "/" + list.last_page, function (err) { + if (err) { + // log error but don't fail entire op + self.logError('maint', "Failed to delete list page: " + list_path + "/" + list.last_page + ": " + err); + } + + list.last_page--; + callback(); + + }); // delete page + }); // _listLoadPage + }, + function (err) { + // all done + if (err) { + self.logError('maint', err.toString()); + self.storage._listUnlock(list_path); + return callback(); + } + + // update list header with new length and last_page + self.storage.put(list_path, list, function (err) { + if (err) { + self.logError('maint', "Failed to update list header: " + list_path + ": " + err); + } + + // unlock list + self.storage._listUnlock(list_path); + + self.logDebug(4, "List chop complete: " + list_path + ", new size: " + list.length, list); + + // and we're done + callback(); + + }); // put + } // done + ); // whilst + }); // listGetInfo + }); // listLock + }, + + archiveLogs: function () { + // archive all logs (called once daily) + var self = this; + var src_spec = this.server.config.get('log_dir') + '/*.log'; + var dest_path = this.server.config.get('log_archive_path'); + + if (dest_path) { + this.logDebug(4, "Archiving logs: " + src_spec + " to: " + dest_path); + // generate time label from previous day, so just subtracting 30 minutes to be safe + var epoch = Tools.timeNow(true) - 1800; + + this.logger.archive(src_spec, dest_path, epoch, function (err) { + if (err) self.logError('maint', "Failed to archive logs: " + err); + else self.logDebug(4, "Log archival complete"); + }); + } + }, + + deleteExpiredJobData: function (key, data, callback) { + // delete both job data and job log + // called from storage maintenance system for 'cronicle_job' record types + var self = this; + var log_key = key + '/log.txt.gz'; + + this.logDebug(6, "Deleting expired job data: " + key); + this.storage.delete(key, function (err) { + if (err) self.logError('maint', "Failed to delete expired job data: " + key + ": " + err); + + self.logDebug(6, "Deleting expired job log: " + log_key); + self.storage.delete(log_key, function (err) { + if (err) self.logError('maint', "Failed to delete expired job log: " + log_key + ": " + err); + + callback(); + }); // delete + }); // delete + }, + + _uniqueIDCounter: 0, + getUniqueID: function (prefix) { + // generate unique id using high-res server time, and a static counter, + // both converted to alphanumeric lower-case (base-36), ends up being ~10 chars. + // allows for *up to* 1,296 unique ids per millisecond (sort of). + this._uniqueIDCounter++; + if (this._uniqueIDCounter >= Math.pow(36, 2)) this._uniqueIDCounter = 0; + + return [ + prefix, + Tools.zeroPad((new Date()).getTime().toString(36), 8), + Tools.zeroPad(this._uniqueIDCounter.toString(36), 2) + ].join(''); + }, + + corsPreflight: function (args, callback) { + // handler for HTTP OPTIONS calls (CORS AJAX preflight) + callback("200 OK", + { + 'Access-Control-Allow-Origin': args.request.headers['origin'] || "*", + 'Access-Control-Allow-Methods': "POST, GET, HEAD, OPTIONS", + 'Access-Control-Allow-Headers': args.request.headers['access-control-request-headers'] || "*", + 'Access-Control-Max-Age': "1728000", + 'Content-Length': "0" + }, + null + ); + }, + + logError: function (code, msg, data) { + // proxy request to system logger with correct component + this.logger.set('component', 'Error'); + this.logger.error(code, msg, data); + }, + + logTransaction: function (code, msg, data) { + // proxy request to system logger with correct component + this.logger.set('component', 'Transaction'); + this.logger.transaction(code, msg, data); + }, + + shutdown: function (callback) { + // shutdown api service + var self = this; + if(!self.normalShutdown) this.logActivity('server_sigterm', { hostname: this.server.hostname }); + + this.logDebug(2, "Shutting down Cronicle"); + this.abortAllLocalJobs(); + this.shutdownCluster(); + this.shutdownScheduler(function () { + + self.shutdownQueue(); + self.shutdownDiscovery(); + + if (self.gcTimer) { + clearTimeout(self.gcTimer); + delete self.gcTimer; + } + + // wait for all non-detached local jobs to complete before continuing shutdown + var count = 0; + var ids = []; + var first = true; + + async.whilst( + function () { + count = 0; + ids = []; + for (var id in self.activeJobs) { + var job = self.activeJobs[id]; + if (!job.detached) { count++; ids.push(id); } + } + return (count > 0); + }, + function (callback) { + if (first) { + self.logDebug(3, "Waiting for " + count + " active jobs to complete", ids); + first = false; + } + setTimeout(function () { callback(); }, 250); + }, + function () { + // all non-detached local jobs complete + callback(); + } + ); // whilst + + }); // shutdownScheduler + }, + + emergencyShutdown: function (err) { + // perform emergency shutdown due to uncaught exception + this.logger.set('sync', true); + this.logError('crash', "Emergency Shutdown: " + err); + this.logDebug(1, "Emergency Shutdown: " + err); + this.abortAllLocalJobs(); + } + +}); diff --git a/lib/job.js b/lib/job.js new file mode 100644 index 0000000..bbb89e7 --- /dev/null +++ b/lib/job.js @@ -0,0 +1,2025 @@ +// Cronicle Server Job Manager +// Copyright (c) 2015 Joseph Huckaby +// Released under the MIT License + +var async = require('async'); +var cp = require('child_process'); +var fs = require('fs'); +var os = require('os'); +var path = require('path'); +var sqparse = require('shell-quote').parse; +var zlib = require('zlib'); + +var Class = require("pixl-class"); +var Tools = require("pixl-tools"); +var JSONStream = require("pixl-json-stream"); +var PixlMail = require('pixl-mail'); + +module.exports = Class.create({ + + launchOrQueueJob: function (event, callback) { + // launch job, or queue upon failure (if event desires) + var self = this; + + // must be manager to do this + if (!this.multi.manager) return callback(new Error("Only a manager server can launch jobs.")); + + this.launchJob(event, function (err, jobs) { + if (err && event.queue) { + // event supports queuing + var queue_max = event.queue_max || 0; + if (!self.eventQueue[event.id]) self.eventQueue[event.id] = 0; + + if (!queue_max || (self.eventQueue[event.id] < queue_max)) { + // queue has room for one more + self.eventQueue[event.id]++; + self.authSocketEmit('update', { eventQueue: self.eventQueue }); + + // special 0-job response denotes an enqueue occurred + err = null; + jobs = []; + + // add now time if not already set + if (!event.now) event.now = Tools.timeNow(true); + + // add job to actual queue in storage, async + self.storage.listPush('global/event_queue/' + event.id, event, function (err) { + if (err) { + self.logError('queue', "Failed to push job onto event queue: " + err); + } + }); + } + else { + // queue is full, change error message + err = new Error("Job could not be queued: Event queue reached max of " + queue_max + " items"); + } + } + callback(err, jobs); + }); + }, + + launchJob: function (event, callback) { + // locate suitable server and launch job + var self = this; + var orig_event = null; + var server_group = null; + var plugin = null; + var category = null; + var servers = []; + + // must be manager to do this + if (!this.multi.manager) return callback(new Error("Only a manager server can launch jobs.")); + + async.series([ + function (callback) { + // event target may refer to server group OR hostname + var worker = self.workers[event.target] || null; + if (worker && !worker.disabled) { + servers.push(worker); + return callback(); + } + + self.storage.listFind('global/server_groups', { id: event.target }, function (err, item) { + server_group = item; + callback(err); + }); + }, + function (callback) { + self.storage.listFind('global/plugins', { id: event.plugin }, function (err, item) { + plugin = item; + callback(err); + }); + }, + function (callback) { + self.storage.listFind('global/categories', { id: event.category }, function (err, item) { + category = item; + callback(err); + }); + }, + function (callback) { + self.storage.listFind('global/schedule', { id: event.id }, function (err, item) { + orig_event = item; + callback(err); + }); + } + ], + function (err) { + // all resources loaded + if (err) return callback(err); + if (!server_group && !servers.length) return callback(new Error("Server or Server Group not found: " + event.target)); + if (!plugin) return callback(new Error("Plugin not found: " + event.plugin)); + if (!category) return callback(new Error("Category not found: " + event.category)); + if (!orig_event) return callback(new Error("Event not found: " + event.id)); + + var all_jobs = self.getAllActiveJobs(true); // include pending jobs + var job_list = Tools.hashValuesToArray(all_jobs); + + // check running jobs vs. max children + if (orig_event.max_children) { + var event_jobs = Tools.findObjectsIdx(job_list, { 'event': event.id }); + if (event_jobs.length >= orig_event.max_children) { + // too many event children running + return callback(new Error("Maximum of " + orig_event.max_children + " " + Tools.pluralize("job", orig_event.max_children) + " already running for event: " + event.title)); + } + } + + if (category.max_children) { + var cat_jobs = Tools.findObjectsIdx(job_list, { 'category': event.category }); + if (cat_jobs.length >= category.max_children) { + // too many category children running + return callback(new Error("Maximum of " + category.max_children + " " + Tools.pluralize("job", category.max_children) + " already running for category: " + category.title)); + } + } + + if (!category.enabled) { + return callback(new Error("Category '" + category.title + "' is disabled.")); + } + if (!plugin.enabled) { + return callback(new Error("Plugin '" + plugin.title + "' is disabled.")); + } + + // automatically pick server if needed + if (!servers.length && server_group) { + var candidates = []; + var regex = new RegExp(server_group.regexp); + + for (var hostname in self.workers) { + var worker = self.workers[hostname]; + + // only consider workers that match the group hostname pattern, and are not disabled + if (hostname.match(regex) && !worker.disabled) { + candidates.push(self.workers[hostname]); + } + } + + if (!candidates.length) { + return callback(new Error("Could not find any servers for group: " + server_group.title)); + } + + // sort the candidates by hostname ascending + candidates = candidates.sort(function (a, b) { + return a.hostname.localeCompare(b.hostname); + }); + + if (event.multiplex) { + // run on ALL servers in group simultaneously (multiplex) + servers = candidates; + } + else { + // run on one server in group, chosen by custom algo + servers.push(self.chooseServer(candidates, event)); + } + } // find worker + + if (!servers.length) { + // event was targetting server that is no longer with us + return callback(new Error("Target server is not available: " + event.target)); + } + + var jobs = []; + + // loop through each matched server, launching job on each + for (var idx = 0, len = servers.length; idx < len; idx++) { + var worker = servers[idx]; + + // construct job object based on event + var job = Tools.copyHash(event, true); + + delete job.id; + delete job.title; + delete job.timing; + delete job.ticks; + delete job.enabled; + delete job.max_children; + // delete job.target; + delete job.username; + delete job.api_key; + delete job.session_id; + delete job.modified; + delete job.created; + delete job.salt; + delete job.token; + + job.id = self.getUniqueID('j'); + job.time_start = Tools.timeNow(); + job.hostname = worker.hostname; + job.event = event.id; + job.params = event.params || {}; + job.now = event.now || Tools.timeNow(true); + job.event_title = event.title; + job.plugin_title = plugin.title; + job.category_title = category.title; + job.nice_target = server_group ? server_group.title : event.target; + + // pull in properties from plugin + job.command = plugin.command; + if (plugin.cwd) job.cwd = plugin.cwd; + if (plugin.uid) job.uid = plugin.uid; + if (plugin.gid) job.gid = plugin.gid; + if (plugin.env) job.env = plugin.env; + if (plugin.secret) job.params['PLUGIN_SECRET'] = plugin.secret; + if (plugin.id == 'workflow') { // todo - change to flag + let temp_key = Tools.digestHex(job.id + (new Date).toDateString()) + self.temp_keys[temp_key] = true + job.params['TEMP_KEY'] = temp_key + job.params['BASE_URL'] = 'http://' + self.server.hostname + ':' + self.server.config.get('WebServer').http_port + } + + // for shell plug - resolve parameter placeholders using params object on config + let xparams = JSON.parse(JSON.stringify(self.server.config.get('params') || {})); + + // inject xparams as env var (e.g. JOB_PARAM1) + // if (job.params.sub_params) { + // for (let id in xparams) { + // if (!job[id]) job[id] = xparams[id] + // } + // } + + // add arguments (shold appear as ARG1, ARG2, ..., env var or param ) + if (job.args) { + job.args.split(",") + .map(e => e.trim()) + .filter(e => e.match(/^[\w\.\@\-\,\s]+$/g)) + .slice(0, 9) + .forEach((e, i) => { + xparams[`ARG${i + 1}`] = e; + job.params[`ARG${i + 1}`] = e; + }); + delete job.args; + } + + // shell plug - substitute params in script + if (job.params.script && job.params.sub_params) job.params.script = Tools.sub(job.params.script, xparams); + + // plugin params may have changed outside of event, + // so recopy missing / hidden ones + if (plugin.params) plugin.params.forEach(function (param) { + if (!(param.id in job.params) || (param.type == 'hidden')) { + job.params[param.id] = param.value; + } + }); + + // pull in defaults from category + if (!job.notify_success && category.notify_success) job.notify_success = category.notify_success; + if (!job.notify_fail && category.notify_fail) job.notify_fail = category.notify_fail; + if (!job.web_hook && category.web_hook) job.web_hook = category.web_hook; + if (!job.memory_limit && category.memory_limit) { + job.memory_limit = category.memory_limit; + job.memory_sustain = category.memory_sustain || 0; + } + if (!job.cpu_limit && category.cpu_limit) { + job.cpu_limit = category.cpu_limit; + job.cpu_sustain = category.cpu_sustain || 0; + } + + // multiplex stagger if desired + if (event.multiplex && event.stagger && (idx > 0)) { + // delay job by N seconds, based on stagger and host position in group + job.when = Tools.timeNow() + (event.stagger * idx); + job.time_start = job.when; + } + + // send remote or run local + if (worker.manager) { + // run the event here + self.launchLocalJob(job); + } + else if (worker.socket) { + // send the job to remote worker server + self.logDebug(6, "Sending remote job to: " + worker.hostname, job); + worker.socket.emit('launch_job', job); + + // Pre-insert job into worker's active_jobs, so something will show in getAllActiveJobs() right away. + // Important for when the scheduler is catching up, and may try to launch a bunch of jobs in a row. + if (!worker.active_jobs) worker.active_jobs = {}; + worker.active_jobs[job.id] = job; + } + + // fire web hook + var hook_data = Tools.mergeHashes(job, { action: 'job_start' }); + + // prepare nice text summary (compatible with Slack Incoming WebHooks) + hook_data.base_app_url = self.server.config.get('base_app_url'); + hook_data.job_details_url = self.server.config.get('base_app_url') + '/#JobDetails?id=' + job.id; + hook_data.edit_event_url = self.server.config.get('base_app_url') + '/#Schedule?sub=edit_event&id=' + job.event; + + var hook_text_templates = self.server.config.get('web_hook_text_templates') || self.defaultWebHookTextTemplates; + + if (hook_text_templates[hook_data.action]) { + hook_data.text = Tools.sub(hook_text_templates[hook_data.action], hook_data); + + // include web_hook_config_keys if configured + if (self.server.config.get('web_hook_config_keys')) { + var web_hook_config_keys = self.server.config.get('web_hook_config_keys'); + for (var idy = 0, ley = web_hook_config_keys.length; idy < ley; idy++) { + var key = web_hook_config_keys[idy]; + hook_data[key] = self.server.config.get(key); + } + } + + // include web_hook_custom_data if configured + if (self.server.config.get('web_hook_custom_data')) { + var web_hook_custom_data = self.server.config.get('web_hook_custom_data'); + for (var key in web_hook_custom_data) hook_data[key] = web_hook_custom_data[key]; + } + + if (job.web_hook_start) { + let wh_data = Tools.mergeHashes(hook_data, {}) // copy hook_data + delete wh_data.html // to avoid conflicts + + let wh_map = self.server.config.get('web_hooks') || {}; + let wh_config = wh_map[job.web_hook_start] || { url: job.web_hook_start } + + if (wh_config.compact) wh_data = { + action: 'job_started', + text: hook_data.text, + job_id: job.id, + event_title: job.event_title, + job_code: job.code + } + + self.fireInfoHook(wh_config, wh_data, "Firing web hook for job start: " + job.id + ": " + job.web_hook_start); + + } + + if (self.server.config.get('universal_web_hook')) { + self.fireInfoHook(self.server.config.get('universal_web_hook'), hook_data, "Firing Universal web hook for job start") + } + + } // yes fire hook + + jobs.push(job); + } // foreach worker + + // no error + callback(null, jobs); + }); + }, + + chooseServer: function (candidates, event) { + // choose server for event, based on algo + var server = null; + + var hostnames = []; + for (var idx = 0, len = candidates.length; idx < len; idx++) { + hostnames.push(candidates[idx].hostname); + } + this.logDebug(9, "Choosing server for event using algo: " + event.algo || 'random', hostnames); + + switch (event.algo || 'random') { + case "random": + // random server from group + server = Tools.randArray(candidates); + break; + + case "round_robin": + // pick each server in sequence, repeat + if (!this.state.robins) this.state.robins = {}; + var robin = this.state.robins[event.id] || 0; + if (robin >= candidates.length) robin = 0; + server = candidates[robin]; + this.state.robins[event.id] = robin + 1; + break; + + case "least_cpu": + // pick server with least CPU in use + var cpus = {}; + var servers = this.getAllServers(); + for (var hostname in servers) { + cpus[hostname] = 0; + if (servers[hostname] && servers[hostname].data && servers[hostname].data.cpu) { + cpus[hostname] = servers[hostname].data.cpu; + } + } + var jobs = this.getAllActiveJobs(); + for (var job_id in jobs) { + var job = jobs[job_id]; + if (job.cpu && job.cpu.current) { + if (!cpus[job.hostname]) cpus[job.hostname] = 0; + cpus[job.hostname] += job.cpu.current; + } + } + var least_value = -1; + var least_hostname = ''; + for (var idx = 0, len = candidates.length; idx < len; idx++) { + var hostname = candidates[idx].hostname; + if ((least_value == -1) || (cpus[hostname] < least_value)) { + least_value = cpus[hostname]; + least_hostname = hostname; + } + } + this.logDebug(9, "CPU Snapshot:", cpus); + server = Tools.findObject(candidates, { hostname: least_hostname }); + break; + + case "least_mem": + // pick server with least memory in use + var mems = {}; + var servers = this.getAllServers(); + for (var hostname in servers) { + mems[hostname] = 0; + if (servers[hostname] && servers[hostname].data && servers[hostname].data.mem) { + mems[hostname] = servers[hostname].data.mem; + } + } + var jobs = this.getAllActiveJobs(); + for (var job_id in jobs) { + var job = jobs[job_id]; + if (job.mem && job.mem.current) { + if (!mems[job.hostname]) mems[job.hostname] = 0; + mems[job.hostname] += job.mem.current; + } + } + var least_value = -1; + var least_hostname = ''; + for (var idx = 0, len = candidates.length; idx < len; idx++) { + var hostname = candidates[idx].hostname; + if ((least_value == -1) || (mems[hostname] < least_value)) { + least_value = mems[hostname]; + least_hostname = hostname; + } + } + this.logDebug(9, "Mem Snapshot:", mems); + server = Tools.findObject(candidates, { hostname: least_hostname }); + break; + + case "prefer_first": + // pick server towards top of sorted list + server = candidates[0]; + break; + + case "prefer_last": + // pick server towards bottom of sorted list + server = candidates[candidates.length - 1]; + break; + } // switch event.algo + + this.logDebug(9, "Chose server: " + server.hostname + " via algo: " + (event.algo || "random")); + return server; + }, + + launchLocalJob: function (job) { + // launch job as a local child process + var self = this; + var child = null; + var worker = null; + + // check for job delay request (multiplex stagger) + if (job.when && (job.when > Tools.timeNow())) { + this.logDebug(6, "Job " + job.id + " will be delayed for " + + Tools.getTextFromSeconds(job.when - Tools.timeNow())); + + job.action = 'launchLocalJob'; + this.enqueueInternal(job); + return; + } + + // construct fully qualified path to job log file + job.log_file = path.resolve(path.join( + this.server.config.get('log_dir'), 'jobs', + job.id + (job.detached ? '-detached' : '') + '.log' + )); + + this.logDebug(6, "Launching local job", job); + + // if we are the manager server or job is detached, + // save copy of job file to disk next to log (for crash recovery) + if (this.multi.manager || job.detached) { + fs.writeFile(job.log_file.replace(/\.log$/, '.json'), JSON.stringify(job), function (err) { + if (err) self.logError('job', "Failed to write JSON job file: " + job.log_file.replace(/\.log$/, '.json') + ": " + err); + }); + } + + // setup environment for child + var child_opts = { + cwd: job.cwd || process.cwd(), + uid: job.uid || process.getuid(), + gid: process.getgid(), + env: Tools.mergeHashes( + this.server.config.get('job_env') || {}, + Tools.mergeHashes(process.env, job.env || {}) + ) + }; + + child_opts.env['CRONICLE'] = this.server.__version; + child_opts.env['JOB_ID'] = job.id; + child_opts.env['JOB_LOG'] = job.log_file; + child_opts.env['JOB_NOW'] = job.now; + child_opts.env['PWD'] = child_opts.cwd; + + // copy all top-level job keys into child env, if number/string/boolean + for (var key in job) { + switch (typeof (job[key])) { + case 'string': + case 'number': + child_opts.env['JOB_' + key.toUpperCase()] = '' + job[key]; + break; + + case 'boolean': + child_opts.env['JOB_' + key.toUpperCase()] = job[key] ? 1 : 0; + break; + } + } + + // get uid / gid info for child env vars + var user_info = Tools.getpwnam(child_opts.uid, true); + if (user_info) { + child_opts.uid = user_info.uid; + child_opts.gid = user_info.gid; + child_opts.env.USER = child_opts.env.USERNAME = user_info.username; + child_opts.env.HOME = user_info.dir; + child_opts.env.SHELL = user_info.shell; + } + else if (child_opts.uid != process.getuid()) { + // user not found + job.pid = 0; + job.code = 1; + job.description = "Plugin Error: User does not exist: " + child_opts.uid; + this.logError("child", job.description); + this.activeJobs[job.id] = job; + this.finishLocalJob(job); + return; + } + + child_opts.uid = parseInt(child_opts.uid); + child_opts.gid = parseInt(child_opts.gid); + + // add plugin params as env vars, expand $INLINE vars + if (job.params) { + for (var key in job.params) { + child_opts.env[key.toUpperCase()] = + ('' + job.params[key]).replace(/\$(\w+)/g, function (m_all, m_g1) { + return (m_g1 in child_opts.env) ? child_opts.env[m_g1] : ''; + }); + } + } + + this.logDebug(9, "Child spawn options:", child_opts); + + // create log file, write header to it + var dargs = Tools.getDateArgs(new Date()); + + fs.appendFileSync(job.log_file, [ + "# Job ID: " + job.id, + "# Event Title: " + job.event_title, + "# Hostname: " + this.server.hostname, + "# Date/Time: " + dargs.yyyy_mm_dd + ' ' + dargs.hh_mi_ss + ' (' + dargs.tz + ')' + ].join("\n") + "\n\n"); + + // make sure child can write to log file + fs.chmodSync(job.log_file, "777"); + + if (job.detached) { + // spawn detached child + var temp_file = path.join(os.tmpdir(), 'cronicle-job-temp-' + job.id + '.json'); + + // tell child where the queue dir is + job.queue_dir = path.resolve(this.server.config.get('queue_dir')); + + // write job file + fs.writeFileSync(temp_file, JSON.stringify(job)); + fs.chmodSync(temp_file, "777"); + this.logDebug(9, "Job temp file: " + temp_file); + + // spawn child + child_opts.detached = true; + child_opts.stdio = ['ignore', 'ignore', 'ignore']; + + try { + child = cp.spawn(path.resolve("bin/run-detached.js"), ["detached", temp_file], child_opts); + } + catch (err) { + job.pid = 0; + job.code = 1; + job.description = "Child process error: " + Tools.getErrorDescription(err); + this.logError("child", job.description); + this.activeJobs[job.id] = job; + this.finishLocalJob(job); + return; + } + + job.pid = child.pid || 0; + + this.logDebug(3, "Spawned detached process: " + job.pid + " for job: " + job.id, job.command); + + worker = { + pid: job.pid + }; + + child.unref(); + } + else { + // spawn child normally + var child_cmd = job.command; + var child_args = []; + + // if command has cli args, parse using shell-quote + if (child_cmd.match(/\s+(.+)$/)) { + var cargs_raw = RegExp.$1; + child_cmd = child_cmd.replace(/\s+(.+)$/, ''); + child_args = sqparse(cargs_raw, child_opts.env); + } + + worker = {}; + + // attach streams + worker.log_fd = fs.openSync(job.log_file, 'a'); + child_opts.stdio = ['pipe', 'pipe', worker.log_fd]; + + // spawn child + try { + child = cp.spawn(child_cmd, child_args, child_opts); + if (!child || !child.pid || !child.stdin || !child.stdout) { + throw new Error("Child process failed to spawn (Check executable location and permissions?)"); + } + } + catch (err) { + if (child) child.on('error', function () { }); // prevent crash + if (worker.log_fd) { fs.closeSync(worker.log_fd); worker.log_fd = null; } + job.pid = 0; + job.code = 1; + job.description = "Child spawn error: " + child_cmd + ": " + Tools.getErrorDescription(err); + this.logError("child", job.description); + + this.activeJobs[job.id] = job; + this.finishLocalJob(job); + return; + } + job.pid = child.pid || 0; + + this.logDebug(3, "Spawned child process: " + job.pid + " for job: " + job.id, child_cmd); + + // connect json stream to child's stdio + // order reversed deliberately (out, in) + var stream = new JSONStream(child.stdout, child.stdin); + stream.recordRegExp = /^\s*\{.+\}\s*$/; + + worker.pid = job.pid; + worker.child = child; + worker.stream = stream; + + stream.on('json', function (data) { + // received data from child + self.handleChildResponse(job, worker, data); + }); + + stream.on('text', function (line) { + // received non-json text from child, log it + fs.appendFileSync(job.log_file, line); + }); + + stream.on('error', function (err, text) { + // Probably a JSON parse error (child emitting garbage) + self.logError('job', "Child stream error: Job ID " + job.id + ": PID " + job.pid + ": " + err); + if (text) fs.appendFileSync(job.log_file, text + "\n"); + }); + + child.on('error', function (err) { + // child error + if (worker.log_fd) { fs.closeSync(worker.log_fd); worker.log_fd = null; } + job.code = 1; + job.description = "Child process error: " + Tools.getErrorDescription(err); + worker.child_exited = true; + self.logError("child", job.description); + self.finishLocalJob(job); + }); + + child.on('exit', function (code, signal) { + // child exited + self.logDebug(3, "Child " + job.pid + " exited with code: " + (code || signal || 0)); + worker.child_exited = true; + + if (job.complete) { + // child already reported completion, so finish job now + if (worker.log_fd) { fs.closeSync(worker.log_fd); worker.log_fd = null; } + self.finishLocalJob(job); + } + else { + // job is not complete but process exited (could be coming in next tick) + // set timeout just in case something went wrong + worker.complete_timer = setTimeout(function () { + if (worker.log_fd) { fs.closeSync(worker.log_fd); worker.log_fd = null; } + job.code = code || 1; + job.description = code ? + ("Child " + job.pid + " crashed with code: " + (code || signal)) : + ("Process exited without reporting job completion."); + if (!code) job.unknown = 1; + self.finishLocalJob(job); + }, 1000); + } + }); // on exit + + // send initial job + params + stream.write(job); + + // we're done writing to the child -- don't hold open its stdin + worker.child.stdin.end(); + } // spawn normally + + // track job in our own hash + this.activeJobs[job.id] = job; + this.kids[job.pid] = worker; + }, + + handleChildResponse: function (job, worker, data) { + // child sent us some datas (progress or completion) + this.logDebug(10, "Got job update from child: " + job.pid, data); + + // assume success if complete but no code specified + if (data.complete && !data.code) data.code = 0; + + // merge in data + Tools.mergeHashInto(job, data); + + if (job.complete && worker.child_exited) { + // in case this update came in after child exited + this.finishLocalJob(job); + } + }, + + detachedJobUpdate: function (data) { + // receive update from detached child via queue system + var id = data.id; + delete data.id; + + var in_progress = data.in_progress || false; + delete data.in_progress; + + this.logDebug(9, "Received update from detached job: " + id, data); + + var job = this.activeJobs[id]; + if (!job) { + // if this is an in-progress update, we can just silently skip (queue files arrived out of order) + if (in_progress) return; + + // service may have restarted - try to recover job from temp file + var job_file = this.server.config.get('log_dir') + '/jobs/' + id + '-detached' + '.json'; + this.logDebug(6, "Detached job is not in memory: " + id + ": Attempting to recover from disk", job_file); + + // okay to use sync here, as this should be a very rare event + if (fs.existsSync(job_file)) { + var json_raw = fs.readFileSync(job_file, { encoding: 'utf8' }); + try { job = JSON.parse(json_raw); } + catch (err) { + this.logError('job', "Failed to read detached job file: " + job_file + ": " + err); + } + } + else { + this.logError('job', "Could not locate detached job file: " + job_file); + } + + if (job) { + this.logDebug(6, "Recovered job data from disk: " + job_file, job); + this.activeJobs[id] = job; + this.kids[job.pid] = { pid: job.pid }; + } + else { + this.logError('job', "Failed to locate active job for update: " + id, data); + return; + } + } // no job in memory + + // assume success if complete but no code specified + if (data.complete && !data.code) data.code = 0; + + // merge in data + Tools.mergeHashInto(job, data); + + if (job.complete) { + // detached job is complete + this.finishLocalJob(job); + } + }, + + rewindJob: function (job) { + // reset cursor state to minute before job started (use 'now' property in case start was delayed) + // only do this if job has catch_up, was launched via the scheduler, and is not multiplexed + if (!this.multi.manager) return; + + if (job.catch_up && !job.source && !job.multiplex) { + var new_start = Tools.normalizeTime(job.now - 60, { sec: 0 }); + this.state.cursors[job.event] = new_start; + + var dargs = Tools.getDateArgs(new_start); + this.logDebug(5, "Reset event " + job.event + " cursor to: " + dargs.yyyy_mm_dd + " " + dargs.hh + ":" + dargs.mi + ":00"); + } + }, + + findJob: function (stub) { + // find active or pending job + // stub should have: id + if (!this.multi.manager) return false; + if (typeof (stub) == 'string') stub = { id: stub }; + + // check all jobs, local, remote and pending + var all_jobs = this.getAllActiveJobs(true); + var job = all_jobs[stub.id]; + if (!job) { + // check pending jobs (they have separate IDs) + for (var key in all_jobs) { + if (all_jobs[key].id == stub.id) { + job = all_jobs[key]; + break; + } + } + } + + return job || false; + }, + + updateJob: function (stub) { + // update active job + // stub should have: id + if (!this.multi.manager) return false; + var job = this.findJob(stub); + + if (!job) { + // should never happen + this.logDebug(1, "Could not locate job: " + stub.id); + return false; + } + + if (job.hostname == this.server.hostname) { + // local job + this.updateLocalJob(stub); + } + else { + // remote job + var worker = this.workers[job.hostname]; + if (!worker) { + // should never happen + this.logDebug(1, "Could not locate worker: " + job.hostname); + return false; + } + + this.logDebug(6, "Sending job update command to: " + worker.hostname, stub); + worker.socket.emit('update_job', stub); + } + + return true; + }, + + updateLocalJob: function (stub) { + // update local job properties + var job = this.activeJobs[stub.id]; + if (!job) { + // must be a pending job + if (this.internalQueue) { + for (var key in this.internalQueue) { + var task = this.internalQueue[key]; + if ((task.action = 'launchLocalJob') && (task.id == stub.id)) { + job = task; + break; + } + } + } + if (!job) { + // should never happen + this.logDebug(1, "Could not locate job: " + stub.id); + return false; + } + } + + this.logDebug(4, "Updating local job: " + stub.id, stub); + + // update properties + for (var key in stub) { + if (key != 'id') job[key] = stub[key]; + } + + return true; + }, + + abortJob: function (stub) { + // abort active job + // stub should have: id, reason + if (!this.multi.manager) return false; + + // check all jobs, local, remote and pending + var all_jobs = this.getAllActiveJobs(true); + var job = all_jobs[stub.id]; + if (!job) { + // check pending jobs (they have separate IDs) + for (var key in all_jobs) { + if (all_jobs[key].id == stub.id) { + job = all_jobs[key]; + break; + } + } + } + if (!job) { + // should never happen + this.logDebug(1, "Could not locate job: " + stub.id); + return false; + } + + // remove temp key if exist + // if(job.params['TEMP_KEY']) delete this.temp_keys[job.params['TEMP_KEY']]; + // no need to remove temp key from abort api - it will be cleared by finishJob or server crash + + if (job.hostname == this.server.hostname) { + // local job + this.abortLocalJob(stub); + } + else { + // remote job + var worker = this.workers[job.hostname]; + if (!worker) { + // should never happen + this.logDebug(1, "Could not locate worker: " + job.hostname); + return false; + } + + this.logDebug(6, "Sending job abort command to: " + worker.hostname, stub); + worker.socket.emit('abort_job', stub); + } + + // rewind cursor if needed + if (!stub.no_rewind) this.rewindJob(job); + + if (job.pending && !job.log_file) { + // job is pre-launch, so log activity + this.logActivity('error', { description: "Pending job #" + stub.id + " (" + (job.event_title || 'Unknown') + ") was aborted pre-launch: " + stub.reason }); + } + + return true; + }, + + abortLocalPendingJob: function (stub) { + // abort job currently in pending queue + var job = null; + + if (this.internalQueue) { + for (var key in this.internalQueue) { + var task = this.internalQueue[key]; + if ((task.action = 'launchLocalJob') && (task.id == stub.id)) { + job = task; + delete this.internalQueue[key]; + break; + } + } + } + + if (!job) { + // should never happen + this.logDebug(1, "Could not locate pending job to abort: " + stub.id); + return; + } + + this.logDebug(4, "Aborting local pending job: " + stub.id + ": " + stub.reason, job); + job.abort_reason = stub.reason; + + // determine if job needs to be 'finished' (i.e. aborted in retry delay) + // or hasn't actually launched yet (i.e. multiplex stagger) + if (job.log_file) { + this.activeJobs[job.id] = job; // trick it into acceptance + this.finishLocalJob(job); + } + }, + + abortLocalJob: function (stub) { + // abort locally running job on this server + // stub should have: id, reason + var self = this; + var job = this.activeJobs[stub.id]; + if (!job) { + // must be a pending job + this.abortLocalPendingJob(stub); + return; + } + + var worker = this.kids[job.pid] || {}; + + this.logDebug(4, "Aborting local job: " + stub.id + ": " + stub.reason, job); + job.abort_reason = stub.reason; + + if (worker.child) { + // owned process + if (worker.log_fd) { fs.closeSync(worker.log_fd); worker.log_fd = null; } + + worker.kill_timer = setTimeout(function () { + // child didn't die, kill with prejudice + self.logDebug(3, "Child did not exit, killing harder: " + job.pid); + worker.child.kill('SIGKILL'); + }, this.server.config.get('child_kill_timeout') * 1000); + + // try killing nicely first + worker.child.kill('SIGTERM'); + } + else { + // detached process + if (job.pid) { + try { process.kill(job.pid, 'SIGTERM'); } + catch (e) { + this.logDebug(5, "Could not term process: " + job.pid + ", killing it."); + try { process.kill(job.pid, 'SIGKILL'); } catch (e) { ; } + } + + // make sure process actually exits + setTimeout(function () { + var ping = false; + try { ping = process.kill(job.pid, 0); } + catch (e) { ; } + if (ping) { + self.logDebug(3, "Child did not exit, killing: " + job.pid); + try { process.kill(job.pid, 'SIGKILL'); } catch (e) { ; } + } + }, this.server.config.get('child_kill_timeout') * 1000); + } // job.pid + + // assume job is finished at this point + this.finishLocalJob(job); + } + }, + + finishLocalJob: function (job) { + // complete job, remove from tracking, update history + var self = this; + + // job may already be removed + if (!this.activeJobs[job.id]) return; + + // if aborted, copy in those params + if (job.abort_reason) { + job.code = 1; + job.description = "Job Aborted: " + job.abort_reason; + job.retries = 0; + } + + job.complete = 1; + + this.logDebug(5, "Job completed " + (job.code ? "with error" : "successfully"), job); + + // kill completion timer, if set + var worker = this.kids[job.pid] || {}; + if (worker.complete_timer) { + clearTimeout(worker.complete_timer); + delete worker.complete_timer; + } + if (worker.kill_timer) { + clearTimeout(worker.kill_timer); + delete worker.kill_timer; + } + if (worker.log_fd) { + fs.closeSync(worker.log_fd); + delete worker.log_fd; + } + + // retry on failure, ignore warning (exit code 255) + if (job.code > 0 && job.code < 255 && job.retries && !this.server.shut) { + this.logError('job', "Job failed: " + job.id + " (" + job.retries + " retries remain)"); + + // add blurb to job log + var blurb = "\n# Job failed with error"; + if (job.code != 1) blurb += ' ' + job.code; + blurb += ": " + (job.description || 'Unknown Error') + "\n"; + blurb += "# " + job.retries + " retries remain"; + if (job.retry_delay) blurb += " (" + Tools.getTextFromSeconds(job.retry_delay, true, false) + " delay)"; + blurb += "\n\n"; + + fs.appendFileSync(job.log_file, blurb); + + job.retries--; + + delete job.complete; + delete job.pid; + delete job.code; + delete job.description; + delete job.perf; + delete job.progress; + delete job.cpu; + delete job.mem; + + delete this.activeJobs[job.id]; + delete this.kids[job.pid]; + + // optional retry delay + if (job.retry_delay) { + job.when = Tools.timeNow() + job.retry_delay; + } + + this.launchLocalJob(job); + return; + } // retry + + // if non-zero code, we expect a string description + if (job.code != 0) { + if (!job.description) job.description = "Unknown Error (no description provided)"; + } + if (job.description) { + job.description = '' + job.description; + } + + // upload job debug log and finish job + var dargs = Tools.getDateArgs(new Date()); + var nice_date_time = dargs.yyyy_mm_dd + ' ' + dargs.hh_mi_ss + ' (' + dargs.tz + ')'; + + var footer = "\n"; + if (job.code) { + footer += "# Job failed at " + nice_date_time + ".\n"; + footer += "# Error"; + if (job.code != 1) footer += " " + job.code; + footer += ": " + job.description.trim() + "\n"; + } + else { + footer += "# Job completed successfully at " + nice_date_time + ".\n"; + if (job.description) footer += "# Description: " + job.description.trim() + "\n"; + } + footer += "# End of log.\n"; + + // append footer to log + try { fs.appendFileSync(job.log_file, footer); } + catch (err) { + self.logError('job', "Failed to append to job log file: " + job.log_file + ": " + err); + } + + // next, get job log file size + var stats = null; + try { stats = fs.statSync(job.log_file); } + catch (err) { + self.logError('job', "Failed to stat job log file: " + job.log_file + ": " + err); + } + + // grab job log size, for e-mail + job.log_file_size = stats.size; + + // only proceed if server isn't shutting down + if (!self.server.shut) { + // upload job log file async + self.uploadJobLog(job); + + if (self.multi.manager) { + // we're manager, finish the job locally + self.finishJob(job); + } // manager + else { + // we're a worker, signal manager to finish job via websockets + // (this can happen parallel to job log upload) + // self.io.emit('finish_job', job); + self.managerSocketEmit('finish_job', job); + } // worker + + // delete job json file (only created on manager or for detached jobs) + fs.unlink(job.log_file.replace(/\.log$/, '.json'), function (err) { ; }); + } + else if (self.multi.manager) { + // server is shutting down and is manager + // rewrite job json for recovery (so it gets pid and log_file_size) + fs.writeFileSync(job.log_file.replace(/\.log$/, '.json'), JSON.stringify(job)); + } + + delete self.activeJobs[job.id]; + if (job.pid) delete self.kids[job.pid]; + }, + + uploadJobLog: function (job, callback) { + // upload local job log file + // or send to storage directly if we're manager + var self = this; + var path = 'jobs/' + job.id + '/log.txt.gz'; + + // if we're manager, upload directly to storage + if (this.multi.manager) { + // call storage directly + + this.logDebug(6, "Storing job log: " + job.log_file + ": " + path); + + fs.stat(job.log_file, function (err, stats) { + // data will be a stream + if (err) { + var data = Buffer.from("(Empty log file)\n"); + fs.writeFileSync(job.log_file, data); + } + + // get read stream and prepare to compress it + var stream = fs.createReadStream(job.log_file); + var gzip = zlib.createGzip(self.server.config.get('gzip_opts') || {}); + stream.pipe(gzip); + + self.storage.putStream(path, gzip, function (err) { + if (err) { + self.logError('storage', "Failed to store job log: " + path + ": " + err); + if (callback) callback(err); + return; + } + + self.logDebug(9, "Job log stored successfully: " + path); + + // delete or move local log file + if (self.server.config.get('copy_job_logs_to')) { + var dargs = Tools.getDateArgs(Tools.timeNow()); + var dest_path = self.server.config.get('copy_job_logs_to').replace(/\/$/, '') + '/'; + if (job.event_title) dest_path += job.event_title.replace(/\W+/g, '') + '.'; + dest_path += job.id + '.' + (dargs.yyyy_mm_dd + '-' + dargs.hh_mi_ss).replace(/\W+/g, '-'); + dest_path += '.log'; + + self.logDebug(9, "Moving local file: " + job.log_file + " to: " + dest_path); + + self.logger.rotate(job.log_file, dest_path, function (err) { + if (err) { + self.logError('file', "Failed to move local job log file: " + job.log_file + ": " + err); + fs.unlink(job.log_file, function (err) { ; }); + } + else { + self.logDebug(9, "Successfully moved local job log file: " + job.log_file + ": " + dest_path); + } + if (callback) callback(); + }); + } + else { + self.logDebug(9, "Deleting local file: " + job.log_file); + fs.unlink(job.log_file, function (err) { + // all done + if (err) { + self.logError('file', "Failed to delete local job log file: " + job.log_file + ": " + err); + } + else { + self.logDebug(9, "Successfully deleted local job log file: " + job.log_file); + } + if (callback) callback(); + + }); // fs.unlink + } // delete + }); // storage put + }); // read file + } // manager + else { + // we're a worker, so tell manager via websockets to come get log + // this.io.emit('fetch_job_log', job); + if (!this.server.shut) this.managerSocketEmit('fetch_job_log', job); + if (callback) callback(); + } // worker + }, + + fetchStoreJobLog: function (job) { + // fetch remote job log from worker, and then store in storage + var self = this; + if (!this.multi.manager) return; + + var worker = this.workers[job.hostname]; + if (!worker) { + this.logError('job', "Failed to locate worker: " + job.hostname + " for job: " + job.id); + worker = { hostname: job.hostname }; // hail mary + } + + // construct url to API on remote server w/auth key + var api_url = this.getServerBaseAPIURL(worker.hostname, worker.ip) + '/app/fetch_delete_job_log'; + + api_url += Tools.composeQueryString({ + path: job.log_file, + auth: Tools.digestHex(job.log_file + this.server.config.get('secret_key')) + }); + + this.logDebug(6, "Fetching remote job log via HTTP GET: " + api_url); + + // just in case remote server has different base dir than manager + job.log_file = this.server.config.get('log_dir') + '/jobs/' + path.basename(job.log_file); + + this.request.get(api_url, { download: job.log_file }, function (err, resp) { + // check for error + if (err) { + var err_msg = "Failed to fetch job log file: " + api_url + ": " + err; + self.logError('job', err_msg); + } + else if (resp.statusCode != 200) { + var err_msg = "Failed to fetch job log file: " + api_url + ": HTTP " + resp.statusCode + " " + resp.statusMessage; + self.logError('job', err_msg); + } + else { + // success + self.logDebug(5, "Job log was fetched successfully", api_url); + + // then call uploadJobLog again to store it (this also deletes the file) + self.uploadJobLog(job); + } // http success + }); // request.get + }, + + finishJob: function (job) { + // finish cleaning up job + var self = this; + + // remove temp api key if exist + if (job.params['TEMP_KEY']) delete this.temp_keys[job.params['TEMP_KEY']]; + + if (!job.time_end) job.time_end = Tools.timeNow(); + job.elapsed = Math.max(0, job.time_end - job.time_start); + + var dargs = Tools.getDateArgs(job.time_end); + + // log success or failure + if (job.code == 0) { + this.logTransaction('job', "Job completed successfully: " + job.id, job); + } + else { + this.logError('job', "Job failed: " + job.id, job); + } + + // add to global activity, event log, and completed events + var data = Tools.copyHash(job); + + // add special 'type' property for storage custom maint delete + data.type = 'cronicle_job'; + + // store job in its own record + this.storage.enqueue(function (task, callback) { + self.storage.put('jobs/' + job.id, data, callback); + }); + this.storage.expire('jobs/' + job.id, Tools.timeNow(true) + (86400 * (job.log_expire_days || this.server.config.get('job_data_expire_days')))); + + // create stub containing a small subset of the job data, for lists + if (job.abort_reason) { job.code = 130 } // adding indicator for aborted job + var stub = { + id: job.id, + code: job.code, + event: job.event, + category: job.category, + plugin: job.plugin, + hostname: job.hostname, + time_start: job.time_start, + elapsed: job.elapsed, + perf: job.perf || '', + cpu: job.cpu || {}, + mem: job.mem || {}, + log_file_size: job.log_file_size || 0, + action: 'job_complete', + epoch: Tools.timeNow(true), + + event_title: job.event_title, + category_title: job.category_title, + plugin_title: job.plugin_title + }; + if (job.code) stub.description = job.description || 'Unknown Error'; + + // only store in activity log if job failed + // if (job.code != 0) { + // this.storage.enqueue( function(task, callback) { + // self.storage.listUnshift( 'logs/activity', stub, callback ); + // }); + // } + // log failures via logActivity, rather than above method, ignore silent job + if (job.code > 0 && job.code != 255 && !job.silent) self.logActivity('job_failure', { job: stub }); + + // store stub in log storage + this.storage.enqueue(function (task, callback) { + self.storage.listUnshift('logs/events/' + job.event, stub, callback); + }); + + if (!job.silent) { // put non-silent jobs under logs/completed (used for history reports) + this.storage.enqueue(function (task, callback) { + self.storage.listUnshift('logs/completed', stub, callback); + }); + } + + // notify people + var email_template = ''; + var to = ''; + if (job.notify_success && (job.code == 0)) { + email_template = "conf/emails/job_success.txt"; + to = job.notify_success; + } + else if (job.notify_fail && (job.code != 0)) { + email_template = "conf/emails/job_fail.txt"; + to = job.notify_fail; + } + + if (email_template) { + // Populate e-mail data with strings for template placeholders + var email_data = Tools.copyHash(data); + + email_data.env = process.env; + email_data.config = this.server.config.get(); + email_data.job_log_url = this.server.config.get('base_app_url') + this.api.config.get('base_uri') + '/app/get_job_log?id=' + job.id; + email_data.edit_event_url = this.server.config.get('base_app_url') + '/#Schedule?sub=edit_event&id=' + job.event; + email_data.job_details_url = this.server.config.get('base_app_url') + '/#JobDetails?id=' + job.id; + email_data.nice_date_time = dargs.yyyy_mm_dd + ' ' + dargs.hh_mi_ss + ' (' + dargs.tz + ')'; + email_data.nice_elapsed = Tools.getTextFromSeconds(data.elapsed, false, false); + email_data.perf = data.perf || '(No metrics provided)'; + email_data.description = (data.description || '(No description provided)').trim(); + email_data.notes = (data.notes || '(None)').trim(); + email_data.nice_log_size = Tools.getTextFromBytes(data.log_file_size || 0); + email_data.pid = data.pid || '(Unknown)'; + email_data.status = "Success" + if (job.code > 0) { email_data.status = "Error" } + if (job.code == 255) { email_data.status = "Warning" } + + // compose nice mem/cpu usage info + email_data.nice_mem = '(Unknown)'; + if (data.mem && data.mem.count) { + var mem_avg = Math.floor(data.mem.total / data.mem.count); + email_data.nice_mem = Tools.getTextFromBytes(mem_avg); + email_data.nice_mem += ' (Peak: ' + Tools.getTextFromBytes(data.mem.max) + ')'; + } + email_data.nice_cpu = '(Unknown)'; + if (data.cpu && data.cpu.count) { + var cpu_avg = Tools.shortFloat(data.cpu.total / data.cpu.count); + email_data.nice_cpu = '' + cpu_avg + '%'; + email_data.nice_cpu += ' (Peak: ' + Tools.shortFloat(data.cpu.max) + '%)'; + } + + // perf may be an object + if (Tools.isaHash(email_data.perf)) email_data.perf = JSON.stringify(email_data.perf); + + // have link download log if too big + if (data.log_file_size > 1024 * 1024 * 10) email_data.job_log_url += '&download=1'; + + // construct mailer + var mail = new PixlMail(this.server.config.get('smtp_hostname'), this.server.config.get('smtp_port') || 25); + mail.setOptions(this.server.config.get('mail_options') || {}); + + // send it + mail.send(email_template, email_data, function (err, raw_email) { + if (err) { + var err_msg = "Failed to send e-mail for job: " + job.id + ": " + to + ": " + err; + self.logError('mail', err_msg, { text: raw_email }); + self.logActivity('error', { description: err_msg }); + } + else { + self.logDebug(5, "Email sent successfully for job: " + job.id, { text: raw_email }); + } + }); + } // mail + + // fire web hook + var hook_data = Tools.mergeHashes(data, { action: 'job_complete' }); + + // prepare nice text summary (compatible with Slack Incoming WebHooks) + hook_data.base_app_url = this.server.config.get('base_app_url'); + hook_data.job_details_url = this.server.config.get('base_app_url') + '/#JobDetails?id=' + job.id; + hook_data.edit_event_url = this.server.config.get('base_app_url') + '/#Schedule?sub=edit_event&id=' + job.event; + + var hook_text_templates = this.server.config.get('web_hook_text_templates') || this.defaultWebHookTextTemplates; + var hook_action = hook_data.action; + if (job.code != 0) hook_action = 'job_failure'; + if (job.code == 255) hook_action = 'job_warning'; + + if (hook_text_templates[hook_action]) { + hook_data.text = Tools.sub(hook_text_templates[hook_action], hook_data); + + // include web_hook_config_keys if configured + if (this.server.config.get('web_hook_config_keys')) { + var web_hook_config_keys = this.server.config.get('web_hook_config_keys'); + for (var idy = 0, ley = web_hook_config_keys.length; idy < ley; idy++) { + var key = web_hook_config_keys[idy]; + hook_data[key] = this.server.config.get(key); + } + } + + // include web_hook_custom_data if configured + if (this.server.config.get('web_hook_custom_data')) { + var web_hook_custom_data = this.server.config.get('web_hook_custom_data'); + for (var key in web_hook_custom_data) hook_data[key] = web_hook_custom_data[key]; + } + + // web hook on complete + if (job.web_hook) { + + let wh_data = Tools.mergeHashes(hook_data, {}) // copy hook_data + delete wh_data.html // to avoid conflicts + + let wh_map = self.server.config.get('web_hooks') || {}; + let wh_config = wh_map[job.web_hook] || { url: job.web_hook } + + if (wh_config.compact) wh_data = { + action: hook_action, + text: hook_data.text, + job_id: job.id, + event_title: job.event_title, + job_code: job.code + } + + // check if user needs to fire webhook on error only + if ( !(parseInt(job.code) % 255 == 0 && job.web_hook_error) ) { + self.fireInfoHook(wh_config, wh_data, "Firing web hook for job complete: " + job.id + ": " + job.web_hook); + } + } // job.webhook on completion + + + if (self.server.config.get('universal_web_hook')) { + self.fireInfoHook(self.server.config.get('universal_web_hook'), hook_data, "Firing Universal web hook for job complete") + } + + } // yes fire hook + + // delete from worker job hash, if applicable + var worker = this.workers[job.hostname]; + if (worker && worker.active_jobs && worker.active_jobs[job.id]) { + delete worker.active_jobs[job.id]; + } + + // just in case job was in limbo, we can remove it now + delete this.deadJobs[job.id]; + + // we can clear high mem/cpu flags too, if applicable + if (this.state.flagged_jobs) { + delete this.state.flagged_jobs[job.id]; + } + + // update daemon stats (reset every day) + var stats = this.state.stats; + + if (!stats.jobs_completed) stats.jobs_completed = 1; + else stats.jobs_completed++; + + if (job.code != 0) { + if (!stats.jobs_failed) stats.jobs_failed = 1; + else stats.jobs_failed++; + } + + if (!stats.jobs_elapsed) stats.jobs_elapsed = job.elapsed; + else stats.jobs_elapsed += job.elapsed; + + if (!stats.jobs_log_size) stats.jobs_log_size = job.log_file_size || 0; + else stats.jobs_log_size += (job.log_file_size || 0); + + // send updated stats to clients + this.authSocketEmit('update', { state: this.state }); + + // if event is catch_up, tickle scheduler (after some safety checks) + // (in case it needs to launch another job right away) + if (job.catch_up && !this.schedulerGraceTimer && !this.schedulerTicking && (dargs.sec != 59) && !job.update_event) { + this.schedulerMinuteTick(null, true); + } + + // chain reaction (success or error) + if (job.chain && job.chain.length && (job.code == 0)) { + this.chainReaction(job, job.chain); + } + else if (job.chain_error && job.chain_error.length && (job.code != 0) && !job.abort_reason) { + // only fire error chain reaction if job was not manually aborted (job.abort_reason) + this.chainReaction(job, job.chain_error); + } + + // job can optionally update event + if (job.update_event) { + this.storage.listFindUpdate('global/schedule', { id: job.event }, job.update_event, function (err) { + if (err) { + self.logError('event', "Failed to update event: " + job.event + ": " + err); + return; + } + + var event_stub = Tools.mergeHashes(job.update_event, { id: job.event, title: job.event_title }); + + self.logDebug(6, "Successfully updated event: " + job.event + " (" + job.event_title + ")", job.update_event); + self.logTransaction('event_update', job.event_title, event_stub); + self.logActivity('event_update', { event: event_stub }); + + // broadcast update to all websocket clients + self.updateClientData('schedule'); + }); // listFindUpdate + } // job.update_event + + // check event queue if applicable + if (job.queue) this.checkEventQueues(job.event); + }, + + getAllActiveJobs: function (inc_pending) { + // gather all active jobs, local and remote + var jobs = Tools.copyHash(this.activeJobs); + + // include pending jobs (i.e. stagger or retry delay) from internal queues + if (inc_pending && this.internalQueue) { + for (var key in this.internalQueue) { + var task = this.internalQueue[key]; + if ((task.action == 'launchLocalJob') && task.id && !jobs[task.id]) { + jobs[key] = Tools.mergeHashes(task, { pending: 1 }); + } // is pending job + } // foreach queue item + } // internalQueue + + for (var hostname in this.workers) { + var worker = this.workers[hostname]; + if (worker.active_jobs) { + Tools.mergeHashInto(jobs, worker.active_jobs); + } + + if (inc_pending && worker.queue) { + for (var key in worker.queue) { + var task = worker.queue[key]; + if ((task.action == 'launchLocalJob') && task.id && !jobs[task.id]) { + jobs[key] = Tools.mergeHashes(task, { pending: 1 }); + } // is pending job + } // foreach queue item + } // has queue + } // foreach worker + + return jobs; + }, + + abortAllLocalJobs: function () { + // abort all locally running jobs for server shutdown + // omit detached jobs + for (var id in this.activeJobs) { + var job = this.activeJobs[id]; + if (!job.detached) { + this.abortLocalJob({ id: id, reason: "Shutting down server" }); + + // Rewind event cursor here + this.rewindJob(job); + } + else { + // detached job, update JSON job file on disk for recovery (now with PID) + this.logDebug(5, "Detached job is still running in the background: " + job.id + ": PID " + job.pid); + try { + fs.writeFileSync(job.log_file.replace(/\.log$/, '.json'), JSON.stringify(job)); + } + catch (err) { + this.logError('job', "Failed to write JSON job file: " + job.log_file.replace(/\.log$/, '.json') + ": " + err); + } + } + } + }, + + monitorAllActiveJobs: function () { + // monitor all active jobs, local and remote (called once per minute) + // only a manager server should do this + if (!this.multi.manager) return; + + var all_jobs = this.getAllActiveJobs(); + var now = Tools.timeNow(); + + // keep flagged jobs in state, so will be saved periodically + if (!this.state.flagged_jobs) this.state.flagged_jobs = {}; + var flagged_jobs = this.state.flagged_jobs; + + // iterate over all jobs + for (var id in all_jobs) { + var job = all_jobs[id]; + + var job_memory_max = job.memory_limit || this.server.config.get('job_memory_max'); + var job_memory_sustain = job.memory_sustain || this.server.config.get('job_memory_sustain'); + var job_cpu_max = job.cpu_limit || this.server.config.get('job_cpu_max'); + var job_cpu_sustain = job.cpu_sustain || this.server.config.get('job_cpu_sustain'); + var job_log_max_size = job.log_max_size || this.server.config.get('job_log_max_size'); + + // check for max run time + if (job.timeout && (now - job.time_start >= job.timeout)) { + this.logDebug(4, "Job has exceeded max run time and will be aborted: " + id + " (" + job.timeout + " sec)"); + + var nice_timeout = Tools.getTextFromSeconds(job.timeout, false, true); + this.abortJob({ id: id, reason: "Exceeded maximum run time (" + nice_timeout + ")", no_rewind: 1 }); + continue; + } // timed out + + // monitor mem for threshold limits + if (job_memory_max && job.mem) { + var current = job.mem.current || 0; + if (current > job_memory_max) { + // job is currently exceeding memory limits + if (!flagged_jobs[id]) flagged_jobs[id] = {}; + if (!flagged_jobs[id].mem) { + this.logDebug(6, "Job has exceeded memory usage limit: " + id, job.mem); + flagged_jobs[id].mem = now; + } + if ((now - flagged_jobs[id].mem) >= job_memory_sustain) { + // job has exceeded memory for too long -- abort it + var msg = "Exceeded memory limit of " + Tools.getTextFromBytes(job_memory_max); + if (job_memory_sustain) msg += " for over " + Tools.getTextFromSeconds(job_memory_sustain, false, true); + + this.logDebug(4, "Job " + id + " is being aborted: " + msg); + this.abortJob({ id: id, reason: msg }); + continue; + } + } + else { + // job mem is within limits - remove flag, if applicable + if (flagged_jobs[id] && flagged_jobs[id].mem) { + this.logDebug(6, "Job is now under the memory usage limit: " + id, job.mem); + delete flagged_jobs[id].mem; + } + if (!Tools.numKeys(flagged_jobs[id])) delete flagged_jobs[id]; + } + } // mem check + + // monitor cpu for threshold limits + if (job_cpu_max && job.cpu) { + var current = job.cpu.current || 0; + if (current > job_cpu_max) { + // job is currently exceeding cpu limits + if (!flagged_jobs[id]) flagged_jobs[id] = {}; + if (!flagged_jobs[id].cpu) { + this.logDebug(6, "Job has exceeded CPU usage limit: " + id, job.cpu); + flagged_jobs[id].cpu = now; + } + if ((now - flagged_jobs[id].cpu) >= job_cpu_sustain) { + // job has exceeded cpu for too long -- abort it + var msg = "Exceeded CPU limit of " + job_cpu_max + "%"; + if (job_cpu_sustain) msg += " for over " + Tools.getTextFromSeconds(job_cpu_sustain, false, true); + + this.logDebug(4, "Job " + id + " is being aborted: " + msg); + this.abortJob({ id: id, reason: msg }); + continue; + } + } + else { + // job cpu is within limits - remove flag, if applicable + if (flagged_jobs[id] && flagged_jobs[id].cpu) { + this.logDebug(6, "Job is now under the CPU usage limit: " + id, job.cpu); + delete flagged_jobs[id].cpu; + } + if (!Tools.numKeys(flagged_jobs[id])) delete flagged_jobs[id]; + } + } // cpu check + + // monitor job log file sizes + if (job_log_max_size && job.log_file_size && (job.log_file_size > job_log_max_size)) { + // job has exceeded log file size limit -- abort it + var msg = "Exceeded log file size limit of " + Tools.getTextFromBytes(job_log_max_size); + this.logDebug(4, "Job " + id + " is being aborted: " + msg); + this.abortJob({ id: id, reason: msg }); + continue; + } + } // foreach job + + // monitor jobs in limbo (i.e. caused by dead servers) + // jobs stuck in limbo for N seconds are auto-aborted + var dead_job_timeout = this.server.config.get('dead_job_timeout'); + for (var id in this.deadJobs) { + var job = this.deadJobs[id]; + if (now - job.time_dead >= dead_job_timeout) { + job.complete = 1; + job.code = 1; + job.description = "Aborted Job: Server '" + job.hostname + "' shut down unexpectedly."; + this.finishJob(job); + + // Rewind cursor here too + this.rewindJob(job); + } + } // foreach dead job + }, + + monitorServerResources: function (callback) { + // monitor local CPU and memory for all active jobs (once per minute) + // shell exec to get running process cpu and memory usage + // this works on at least: OS X, Fedora, Ubuntu and CentOS + var self = this; + var now = Tools.timeNow(); + + var cmd = this.server.config.get('ps_monitor_cmd') || '/bin/ps -eo "ppid pid %cpu rss"'; + var job_startup_grace = this.server.config.get('job_startup_grace') || 5; + + this.logDebug(10, "Checking server resources: " + cmd); + + var finish = function (err, stdout, stderr) { + if (err) { + self.logError('job', "Failed to exec ps: " + err); + if (callback) { callback(); callback = null; } + return; + } + var lines = stdout.split(/\n/); + var pids = {}; + + // process each line from ps response + for (var idx = 0, len = lines.length; idx < len; idx++) { + var line = lines[idx]; + if (line.match(/(\d+)\s+(\d+)\s+([\d\.]+)\s+(\d+)/)) { + var ppid = parseInt(RegExp.$1); + var pid = parseInt(RegExp.$2); + var cpu = parseFloat(RegExp.$3); + var mem = parseInt(RegExp.$4) * 1024; // k to bytes + pids[pid] = { ppid: ppid, cpu: cpu, mem: mem }; + } // good line + } // foreach line + + self.logDebug(10, "Raw process data:", pids); + + // match up pids with jobs + for (var id in self.activeJobs) { + var job = self.activeJobs[id]; + + // only match jobs that have been running for more than N seconds + // this way we don't record cpu/mem for a process that is just starting up + if (pids[job.pid] && (now - job.time_start >= job_startup_grace)) { + var info = pids[job.pid]; + var cpu = info.cpu; + var mem = info.mem; + + // also consider children of the child (up to 100 generations deep) + var levels = 0; + var family = {}; + family[job.pid] = 1; + + while (Tools.numKeys(family) && (++levels <= 100)) { + for (var fpid in family) { + for (var cpid in pids) { + if (pids[cpid].ppid == fpid) { + family[cpid] = 1; + cpu += pids[cpid].cpu; + mem += pids[cpid].mem; + } // matched + } // cpid loop + delete family[fpid]; + } // fpid loop + } // while + + if (job.cpu) { + if (cpu < job.cpu.min) job.cpu.min = cpu; + if (cpu > job.cpu.max) job.cpu.max = cpu; + job.cpu.total += cpu; + job.cpu.count++; + job.cpu.current = cpu; + } + else { + job.cpu = { min: cpu, max: cpu, total: cpu, count: 1, current: cpu }; + } + + if (job.mem) { + if (mem < job.mem.min) job.mem.min = mem; + if (mem > job.mem.max) job.mem.max = mem; + job.mem.total += mem; + job.mem.count++; + job.mem.current = mem; + } + else { + job.mem = { min: mem, max: mem, total: mem, count: 1, current: mem }; + } + + if (self.debugLevel(10)) { + self.logDebug(10, "Active Job: " + job.pid + ": CPU: " + cpu + "%, Mem: " + Tools.getTextFromBytes(mem)); + } + } // matched job with pid + } // foreach job + + // grab stats for daemon pid as well + // store in multi.data to be shared with cluster + if (pids[process.pid]) { + var info = pids[process.pid]; + self.multi.data.cpu = info.cpu; + self.multi.data.mem = info.mem; + } + + // monitor all active job log sizes + async.eachOfSeries(self.activeJobs, + function (job, id, callback) { + if (job && job.log_file) { + fs.stat(job.log_file, function (err, stats) { + if (stats && stats.size) job.log_file_size = stats.size; + callback(); + }); + } + else callback(); + }, + function () { + if (callback) { callback(); callback = null; } + } + ); // eachOfSeries + }; // finish + + var child = null; + try { + child = cp.exec(cmd, { timeout: 5 * 1000 }, finish); + } + catch (err) { + self.logError('job', "Failed to exec ps: " + err); + if (callback) { callback(); callback = null; } + } + if (child && child.pid && child.on) child.on('error', function (err) { + self.logError('job', "Failed to exec ps: " + err); + if (callback) { callback(); callback = null; } + }); + }, + + watchJobLog: function (args, socket) { + // websocket request to watch live job log + var self = this; + var ip = socket.request.connection.remoteAddress || 'Unknown'; + + // allow active or pending jobs (retry delay) + var job = this.activeJobs[args.id]; + if (!job && this.internalQueue) { + for (var key in this.internalQueue) { + var task = this.internalQueue[key]; + if ((task.action = 'launchLocalJob') && (task.id == args.id)) { + job = task; + break; + } + } + } + + if (!job) { + // logging this as a debug (non-error) because it can happen naturally + // if #JobDetails page is loaded just as job is completing + self.logDebug(2, "watchJobLog: Could not locate active job: " + args.id + ", canceling watch"); + return; + } + if (!args.token) { + self.logError('watchJobLog', "Missing authentication token"); + return; + } + + // prepare to log watch + var log_file = job.log_file; + var log_fd = null; + var log_stats = null; + var log_chunk_size = 32678; + var log_buffer = Buffer.alloc(log_chunk_size); + var log_pos = 0; + + self.logDebug(5, "Socket client " + socket.id + " (IP: " + ip + ") now watching job log file: " + log_file); + + // open log file and locate ideal position to start from + // (~32K from end, aligned to line boundary) + async.series([ + function (callback) { + // validate auth token + var correct_token = Tools.digestHex(args.id + self.server.config.get('secret_key')); + if (args.token != correct_token) { + var err = new Error("Invalid authentication token (mismatched secret keys between servers?)"); + self.logError('watchJobLog', "Socket client " + socket.id + " failed to authenticate (IP: " + ip + ")"); + return callback(err); + } + + self.logDebug(4, "watchJobLog: Socket client " + socket.id + " has authenticated via user session (IP: " + ip + ")"); + socket._pixl_auth = true; + callback(); + }, + function (callback) { + fs.open(log_file, 'r', function (err, fd) { + log_fd = fd; + callback(err); + }); + }, + function (callback) { + fs.fstat(log_fd, function (err, stats) { + log_stats = stats; + callback(err); + }); + }, + function (callback) { + log_pos = Math.max(0, log_stats.size - log_chunk_size); + fs.read(log_fd, log_buffer, 0, log_chunk_size, log_pos, function (err, bytesRead, buffer) { + if (err) return callback(err); + + if (bytesRead > 0) { + var slice = buffer.slice(0, bytesRead); + var text = slice.toString(); + var lines = text.split(/\n/); + if (bytesRead == log_chunk_size) { + // remove first line, as it is likely partial + var line = lines.shift(); + log_pos += line.length + 1; + } + } + + callback(); + }); + } + ], + function (err) { + if (err) { + self.logError('socket', "Could not watch job log file: " + log_file + ": " + err); + return; + } + + socket._pixl_log_watcher = setInterval(function () { + // monitor log size + if (socket._pixl_disconnected) { + fs.close(log_fd, function () { }); + clearTimeout(socket._pixl_log_watcher); + return; + } + + fs.fstat(log_fd, function (err, stats) { + if (stats && (stats.size > log_pos)) { + // log grew, read new chunk + fs.read(log_fd, log_buffer, 0, log_chunk_size, log_pos, function (err, bytesRead, buffer) { + if (err) { + self.logError('socket', "Could not read job log file: " + log_file + ": " + err); + fs.close(log_fd, function () { }); + clearTimeout(socket._pixl_log_watcher); + return; + } + + if (bytesRead > 0) { + var slice = buffer.slice(0, bytesRead); + var text = slice.toString(); + var lines = text.split(/\n/); + log_pos += text.length; + + if (!text.match(/\n$/)) { + // last line is partial, must compensate + var line = lines.pop(); + log_pos -= line.length; + + // tricky situation: single log line longer than 32K + // in this case we gotta split it up + if (!lines.length && (bytesRead == log_chunk_size)) { + lines.push(line); + log_pos += line.length; + } + } + + // emit lines to client + if (lines.length && !socket._pixl_disconnected) { + socket.emit('log_data', lines); + //self.authSocketEmit('log_data', lines); // experiment 2 + } + } // bytesRead + }); // fs.read + } // log grew + }); // fs.fstat + }, 250); // setInterval + }); // async.series + } + +}); diff --git a/lib/main.js b/lib/main.js new file mode 100644 index 0000000..c771cea --- /dev/null +++ b/lib/main.js @@ -0,0 +1,40 @@ +#!/usr/bin/env node + +// Cronicle Server - Main entry point +// Copyright (c) 2015 - 2018 Joseph Huckaby +// Released under the MIT License + +// Emit warning for broken versions of node v10 +// See: https://github.com/jhuckaby/Cronicle/issues/108 +if (process.version.match(/^v10\.[012345678]\.\d+$/)) { + console.error("\nWARNING: You are using an incompatible version of Node.js (" + process.version + ") with a known timer bug.\nCronicle will stop working after approximately 25 days under these conditions.\nIt is highly recommended that you upgrade to Node.js v10.9.0 or later, or downgrade to Node LTS (v8.x).\nSee https://github.com/jhuckaby/Cronicle/issues/108 for details.\n"); +} + +try { require('dotenv').config({ path: "conf/config.env" }) } catch { } + +var PixlServer = require("pixl-server"); + +// chdir to the proper server root dir +process.chdir( require('path').dirname( __dirname ) ); + +var server = new PixlServer({ + + __name: 'Cronicle', + __version: require('../package.json').version, + + configFile: "conf/config.json", + + components: [ + require('pixl-server-storage'), + require('pixl-server-web'), + require('pixl-server-api'), + require('./user.js'), + require('./engine.js') + ] + +}); + +server.startup( function() { + // server startup complete + process.title = server.__name + ' Server'; +} ); diff --git a/lib/queue.js b/lib/queue.js new file mode 100644 index 0000000..b1e70c5 --- /dev/null +++ b/lib/queue.js @@ -0,0 +1,168 @@ +// Cronicle Server Queue Layer +// Copyright (c) 2015 Joseph Huckaby +// Released under the MIT License + +// Note: Special queue task properties are 'action' and 'when'. +// These are meta properties, and are DELETED when the task is executed. + +var fs = require("fs"); +var async = require('async'); +var glob = require('glob'); + +var Class = require("pixl-class"); +var Tools = require("pixl-tools"); + +module.exports = Class.create({ + + internalQueue: null, + + setupQueue: function() { + // setup queue system + if (!this.internalQueue) { + this.internalQueue = {}; + + // check in-memory queue every second + this.server.on('tick', this.monitorInternalQueue.bind(this)); + + // check external queue every minute + this.server.on('minute', this.monitorExternalQueue.bind(this)); + } + }, + + monitorInternalQueue: function() { + // monitor in-memory queue for tasks ready to execute + // these may be future-scheduled via 'when' property + // (this is called once per second) + var now = Tools.timeNow(); + + // don't run this if shutting down + if (this.server.shut) return; + + for (var key in this.internalQueue) { + var task = this.internalQueue[key]; + if (!task.when || (now >= task.when)) { + // invoke method from 'action' property + this.logDebug(5, "Processing internal queue task", task); + var action = task.action || 'UNKNOWN'; + delete task.action; + delete task.when; + + if (this[action]) { + this[action](task); + } + else { + this.logError('queue', "Unsupported action: " + action, task); + } + delete this.internalQueue[key]; + } // execute + } // foreach item + }, + + enqueueInternal: function(task) { + // enqueue task into internal queue + var key = Tools.generateUniqueID(32); + this.logDebug(9, "Enqueuing internal task: " + key, task); + this.internalQueue[key] = task; + }, + + monitorExternalQueue: function() { + // monitor queue dir for files (called once per minute) + var self = this; + var file_spec = this.server.config.get('queue_dir') + '/*.json'; + + // don't run this if shutting down + if (this.server.shut) return; + + glob(file_spec, {}, function (err, files) { + // got task files + if (files && files.length) { + async.eachSeries( files, function(file, callback) { + // foreach task file + fs.readFile( file, { encoding: 'utf8' }, function(err, data) { + // delete right away, regardless of outcome + fs.unlink( file, function(err) {;} ); + + // parse json + var task = null; + try { task = JSON.parse( data ); } + catch (err) { + self.logError('queue', "Failed to parse queued JSON file: " + file + ": " + err); + } + + if (task) { + self.logDebug(5, "Processing external queue task: " + file, task); + + if (task.when) { + // task is set for a future time, add to internal memory queue + self.enqueueInternal(task); + } + else { + // run now, invoke method from 'action' property + var action = task.action || 'UNKNOWN'; + delete task.action; + delete task.when; + + if (self[action]) { + self[action](task); + } + else { + self.logError('queue', "Unsupported action: " + action, task); + } + } // run now + } // good task + + callback(); + } ); + }, + function(err) { + // done with glob eachSeries + self.logDebug(9, "No more queue files to process"); + } ); + } // got files + } ); // glob + }, + + enqueueExternal: function(task, callback) { + // enqueue a task for later (up to 1 minute delay) + // these may be future-scheduled via 'when' property + var self = this; + var task_file = this.server.config.get('queue_dir') + '/' + Tools.generateUniqueID(32) + '.json'; + var temp_file = task_file + '.tmp'; + + this.logDebug(9, "Enqueuing external task", task); + + fs.writeFile( temp_file, JSON.stringify(task), function(err) { + if (err) { + self.logError('queue', "Failed to write queue file: " + temp_file + ": " + err); + if (callback) callback(); + return; + } + + fs.rename( temp_file, task_file, function(err) { + if (err) { + self.logError('queue', "Failed to rename queue file: " + temp_file + ": " + task_file + ": " + err); + if (callback) callback(); + return; + } + + if (callback) callback(); + } ); // rename + } ); // writeFile + }, + + shutdownQueue: function() { + // shut down queues + // move internal pending queue items to external queue + // to be picked up on next startup + var self = this; + + if (this.internalQueue) { + for (var key in this.internalQueue) { + var task = this.internalQueue[key]; + this.enqueueExternal( task ); + } + this.internalQueue = null; + } + } + +}); diff --git a/lib/scheduler.js b/lib/scheduler.js new file mode 100644 index 0000000..aa64ad7 --- /dev/null +++ b/lib/scheduler.js @@ -0,0 +1,524 @@ +// Cronicle Server Scheduler +// Copyright (c) 2015 Joseph Huckaby +// Released under the MIT License + +var async = require('async'); +var fs = require('fs'); +var moment = require('moment-timezone'); + +var Class = require("pixl-class"); +var Tools = require("pixl-tools"); +var PixlMail = require('pixl-mail'); + +module.exports = Class.create({ + + setupScheduler: function () { + // load previous event cursors + var self = this; + var now = Tools.normalizeTime(Tools.timeNow(), { sec: 0 }); + + this.storage.get('global/state', function (err, state) { + if (!err && state) self.state = state; + var cursors = self.state.cursors; + + // if running in debug mode, clear stats + if (self.server.debug) self.state.stats = {}; + + self.storage.listGet('global/schedule', 0, 0, function (err, items) { + // got all schedule items + var queue_event_ids = []; + + for (var idx = 0, len = items.length; idx < len; idx++) { + var item = items[idx]; + + // reset cursor to now if running in debug mode, or event is NOT set to catch up + if (self.server.debug || !item.catch_up) { + cursors[item.id] = now; + } + + // if event has queue, add to load list + if (item.queue) queue_event_ids.push(item.id); + } // foreach item + + // load event queue counts + if (queue_event_ids.length) async.eachSeries(queue_event_ids, + function (event_id, callback) { + self.storage.listGetInfo('global/event_queue/' + event_id, function (err, list) { + if (!list) list = { length: 0 }; + self.eventQueue[event_id] = list.length || 0; + callback(); + }); + } + ); // eachSeries + + // set a grace period to allow all workers to check-in before we start launching jobs + // (important for calculating max concurrents -- manager may have inherited a mess) + self.schedulerGraceTimer = setTimeout(function () { + delete self.schedulerGraceTimer; + + self.server.on('minute', function (dargs) { + self.schedulerMinuteTick(dargs); + self.checkAllEventQueues(); + }); + + // fire up queues if applicable + if (queue_event_ids.length) self.checkEventQueues(queue_event_ids); + + }, self.server.config.get('scheduler_startup_grace') * 1000); + + }); // loaded schedule + }); // loaded state + }, + + schedulerMinuteTick: function (dargs, catch_up_only) { + // a new minute has started, see if jobs need to run + var self = this; + var cursors = this.state.cursors; + var launches = {}; + + // don't run this if shutting down + if (this.server.shut) return; + + if (this.state.enabled) { + // scheduler is enabled, advance time + this.schedulerTicking = true; + if (!dargs) dargs = Tools.getDateArgs(Tools.timeNow(true)); + + dargs.sec = 0; // normalize seconds + var now = Tools.getTimeFromArgs(dargs); + + if (catch_up_only) { + self.logDebug(4, "Scheduler catching events up to: " + dargs.yyyy_mm_dd + " " + dargs.hh + ":" + dargs.mi + ":00"); + } + else { + self.logDebug(4, "Scheduler Minute Tick: Advancing time up to: " + dargs.yyyy_mm_dd + " " + dargs.hh + ":" + dargs.mi + ":00"); + } + + self.storage.listGet('global/schedule', 0, 0, function (err, items) { + // got all schedule items, step through them in series + if (err) { + self.logError('storage', "Failed to fetch schedule: " + err); + items = []; + } + + async.eachSeries(items, async.ensureAsync(function (item, callback) { + + // make a copy to avoid caching issues + item = JSON.parse(JSON.stringify(item)); + + if (!item.enabled) { + // item is disabled, skip over entirely + // for catch_up events, this means jobs will 'accumulate' + return callback(); + } + if (!item.catch_up) { + // no catch up needed, so only process current minute + if (catch_up_only) { + return callback(); + } + cursors[item.id] = now - 60; + } + var cursor = cursors[item.id]; + + // now step over each minute we missed + async.whilst( + function () { return cursor < now; }, + + async.ensureAsync(function (callback) { + cursor += 60; + let tz = item.timezone || self.tz; + let timing = item.timing; + + // var cargs = Tools.getDateArgs(cursor); + var margs = moment.tz(cursor * 1000, tz); + + if (self.checkEventTicks(item.ticks, cursor, tz) || timing && self.checkEventTimingMoment(timing, margs)) { + // item needs to run! + self.logDebug(4, "Auto-launching scheduled item: " + item.id + " (" + item.title + ") for timestamp: " + margs.format('llll z')); + self.launchOrQueueJob(Tools.mergeHashes(item, { now: cursor }), callback); + } + else callback(); + }), + + function (err) { + if (err) { + var err_msg = "Failed to launch scheduled event: " + item.title + ": " + (err.message || err); + self.logError('scheduler', err_msg); + + // only log visible error if not in catch_up_only mode, and cursor is near current time + if (!catch_up_only && (Tools.timeNow(true) - cursor <= 30) && !err_msg.match(/(Category|Plugin).+\s+is\s+disabled\b/) && !launches[item.id]) { + self.logActivity('warning', { description: err_msg }); + if (item.notify_fail) { + self.sendEventErrorEmail(item, { description: err_msg }); + } + + var hook_data = Tools.mergeHashes(item, { + action: 'job_launch_failure', + code: 1, + description: (err.message || err), + event: item.id, + event_title: item.title + }); + + // prepare nice text summary (compatible with Slack Incoming WebHooks) + hook_data.base_app_url = self.server.config.get('base_app_url'); + hook_data.edit_event_url = self.server.config.get('base_app_url') + '/#Schedule?sub=edit_event&id=' + item.id; + + var hook_text_templates = self.server.config.get('web_hook_text_templates') || self.defaultWebHookTextTemplates; + + if (hook_text_templates[hook_data.action]) { + hook_data.text = Tools.sub(hook_text_templates[hook_data.action], hook_data); + + // include web_hook_config_keys if configured + if (self.server.config.get('web_hook_config_keys')) { + var web_hook_config_keys = self.server.config.get('web_hook_config_keys'); + for (var idy = 0, ley = web_hook_config_keys.length; idy < ley; idy++) { + var key = web_hook_config_keys[idy]; + hook_data[key] = self.server.config.get(key); + } + } + + // include web_hook_custom_data if configured + if (self.server.config.get('web_hook_custom_data')) { + var web_hook_custom_data = self.server.config.get('web_hook_custom_data'); + for (var key in web_hook_custom_data) hook_data[key] = web_hook_custom_data[key]; + } + + if (item.web_hook) { + + let wh_data = Tools.mergeHashes(hook_data, {}) // copy hook_data + + let wh_map = self.server.config.get('web_hooks') || {}; + let wh_config = wh_map[item.web_hook] || { url: item.web_hook } + + if (wh_config.compact) wh_data = { + action: 'job_launch_failure', + text: hook_data.text || `failed to launch event ${item.title}`, + description: (err.message || err), + event: item.id, + event_title: item.title, + code: 1 + } + + self.fireInfoHook(wh_config, wh_data, "Firing web hook for job launch failure: " + item.id + ": " + item.web_hook); + + } + + // universal_web_hook + if (self.server.config.get('universal_web_hook')) { + self.fireInfoHook(self.server.config.get('universal_web_hook'), hook_data, "Firing Universal web hook for job launch failure"); + } + } // yes fire hook + + // update failed job count for the day + var stats = self.state.stats; + if (!stats.jobs_failed) stats.jobs_failed = 1; + else stats.jobs_failed++; + } // notify for error + + cursor -= 60; // backtrack if we misfired + } // error + else { + launches[item.id] = 1; + } + + cursors[item.id] = cursor; + callback(); + } + ); // whilst + }), + function (err) { + // error should never occur here, but just in case + if (err) self.logError('scheduler', "Failed to iterate schedule: " + err); + + // all items complete, save new cursor positions back to storage + self.storage.put('global/state', self.state, function (err) { + if (err) self.logError('state', "Failed to update state: " + err); + }); + + // send state data to all web clients + self.authSocketEmit('update', { state: self.state }); + + // remove in-use flag + self.schedulerTicking = false; + }); // foreach item + }); // loaded schedule + } // scheduler enabled + else { + // scheduler disabled, but still send state event every minute + self.authSocketEmit('update', { state: self.state }); + } + }, + + checkEventTiming: function (timing, cursor, tz) { + // check if event needs to run + if (!timing) return false; + var margs = moment.tz(cursor * 1000, tz || this.tz); + return this.checkEventTimingMoment(timing, margs); + }, + + checkEventTicks: function checkTicks(tickString, cursor, tz) { + if(!tickString) return false + return tickString.toString().trim().replace(/\s+/g, ' ').split(/[\,\|]/) + .map(e => moment.tz(e, e.trim().length > 8 ? 'YYYY-MM-DD HH:mm A' : 'HH:mm A', tz || this.tz).unix()) + .includes(cursor) + }, + + checkEventTimingMoment: function (timing, margs) { + // check if event needs to run using Moment.js API + if (!timing) return false; + if (timing.minutes && timing.minutes.length && (timing.minutes.indexOf(margs.minute()) == -1)) return false; + if (timing.hours && timing.hours.length && (timing.hours.indexOf(margs.hour()) == -1)) return false; + if (timing.weekdays && timing.weekdays.length && (timing.weekdays.indexOf(margs.day()) == -1)) return false; + if (timing.days && timing.days.length && (timing.days.indexOf(margs.date()) == -1)) return false; + if (timing.months && timing.months.length && (timing.months.indexOf(margs.month() + 1) == -1)) return false; + if (timing.years && timing.years.length && (timing.years.indexOf(margs.year()) == -1)) return false; + return true; + }, + + sendEventErrorEmail: function (event, overrides) { + // send general error e-mail for event (i.e. failed to launch) + var self = this; + var email_template = "conf/emails/event_error.txt"; + var to = event.notify_fail; + var dargs = Tools.getDateArgs(Tools.timeNow()); + var email_data = Tools.mergeHashes(event, overrides || {}); + + email_data.env = process.env; + email_data.config = this.server.config.get(); + email_data.edit_event_url = this.server.config.get('base_app_url') + '/#Schedule?sub=edit_event&id=' + event.id; + email_data.nice_date_time = dargs.yyyy_mm_dd + ' ' + dargs.hh_mi_ss + ' (' + dargs.tz + ')'; + email_data.description = (email_data.description || '(No description provided)').trim(); + email_data.notes = (email_data.notes || '(None)').trim(); + email_data.hostname = this.server.hostname; + + // construct mailer + var mail = new PixlMail(this.server.config.get('smtp_hostname'), this.server.config.get('smtp_port') || 25); + mail.setOptions(this.server.config.get('mail_options') || {}); + + // send it + mail.send(email_template, email_data, function (err, raw_email) { + if (err) { + var err_msg = "Failed to send e-mail for event: " + event.id + ": " + to + ": " + err; + self.logError('mail', err_msg, { text: raw_email }); + self.logActivity('error', { description: err_msg }); + } + else { + self.logDebug(5, "Email sent successfully for event: " + event.id, { text: raw_email }); + } + }); + }, + + chainReaction: function (old_job, chain_event_id) { + // launch custom new job from completed one + var self = this; + + this.storage.listFind('global/schedule', { id: chain_event_id }, function (err, event) { + if (err || !event) { + var err_msg = "Failed to launch chain reaction: Event ID not found: " + chain_event_id; + self.logError('scheduler', err_msg); + self.logActivity('warning', { description: err_msg }); + if (old_job.notify_fail) { + self.sendEventErrorEmail(old_job, { description: err_msg }); + } + return; + } + + var job = Tools.mergeHashes(Tools.copyHash(event, true), { + chain_data: old_job.chain_data || {}, + chain_code: old_job.code || 0, + chain_description: old_job.description || '', + source: "Chain Reaction (" + old_job.event_title + ")", + source_event: old_job.event + }); + + self.logDebug(6, "Running event via chain reaction: " + job.title, job); + + self.launchOrQueueJob(job, function (err, jobs_launched) { + if (err) { + var err_msg = "Failed to launch chain reaction: " + job.title + ": " + err.message; + self.logError('scheduler', err_msg); + self.logActivity('warning', { description: err_msg }); + if (job.notify_fail) { + self.sendEventErrorEmail(job, { description: err_msg }); + } + else if (old_job.notify_fail) { + self.sendEventErrorEmail(old_job, { description: err_msg }); + } + return; + } + + // multiple jobs may have been launched (multiplex) + for (var idx = 0, len = jobs_launched.length; idx < len; idx++) { + var job_temp = jobs_launched[idx]; + var stub = { id: job_temp.id, event: job_temp.event, chain_reaction: 1, source_event: old_job.event }; + self.logTransaction('job_run', job_temp.event_title, stub); + } + + }); // launch job + }); // find event + }, + + checkAllEventQueues: function (callback) { + // check event queues for ALL events + var self = this; + + // don't run this if shutting down + if (this.server.shut) { + if (callback) callback(); + return; + } + + // must be manager to do this + if (!this.multi.manager) { + if (callback) callback(); + return; + } + + this.storage.listGet('global/schedule', 0, 0, function (err, items) { + if (err || !items) { + if (callback) callback(); + return; + } + var queue_event_ids = []; + + for (var idx = 0, len = items.length; idx < len; idx++) { + var item = items[idx]; + if (item.queue) queue_event_ids.push(item.id); + } // foreach item + + if (queue_event_ids.length) { + self.checkEventQueues(queue_event_ids, callback); + } + else { + if (callback) callback(); + } + }); + }, + + checkEventQueues: function (event_ids, callback) { + // check event queues for specific list of event IDs, + // and run events if possible + var self = this; + this.logDebug(9, "Checking event queues", event_ids); + + // don't run this if shutting down + if (this.server.shut) { + if (callback) callback(); + return; + } + + // must be manager to do this + if (!this.multi.manager) { + if (callback) callback(); + return; + } + + if (!Array.isArray(event_ids)) event_ids = [event_ids]; + var hot_event_ids = []; + + // only consider events with items in the queue + event_ids.forEach(function (event_id) { + if (self.eventQueue[event_id]) hot_event_ids.push(event_id); + }); // forEach + + async.eachSeries(hot_event_ids, + function (event_id, callback) { + // load first item from queue + var list_path = 'global/event_queue/' + event_id; + self.logDebug(9, "Attempting to dequeue job from event queue: " + event_id); + + self.storage.lock(list_path, true, function () { + // locked + self.storage.listGet(list_path, 0, 1, function (err, events) { + if (err || !events || !events[0]) { + self.storage.unlock(list_path); + return callback(); + } + var event = events[0]; + + // try to launch (without auto-queue), and catch error before anything is logged + self.launchJob(event, function (err, jobs_launched) { + if (err) { + // no problem, job cannot launch at this time + self.logDebug(9, "Job dequeue launch failed, item will remain in queue", { + err: '' + err, + event: event_id + }); + self.storage.unlock(list_path); + return callback(); + } + + self.logDebug(9, "Queue launch successful!", { event: event_id }); + + // we queue-launched! decrement counter and shift from list + if (self.eventQueue[event_id]) self.eventQueue[event_id]--; + self.authSocketEmit('update', { eventQueue: self.eventQueue }); + + self.storage.listShift(list_path, function (err) { + if (err) self.logDebug(3, "Failed to shift queue: " + err); + self.storage.unlock(list_path); + callback(); + }); + + // multiple jobs may have been launched (multiplex) + for (var idx = 0, len = jobs_launched.length; idx < len; idx++) { + var job_temp = jobs_launched[idx]; + var stub = { id: job_temp.id, event: job_temp.event, dequeued: 1 }; + self.logTransaction('job_run', job_temp.event_title, stub); + } + }); // launchJob + }); // listGet + }); // lock + }, + function () { + if (callback) callback(); + } + ); // eachSeries + }, + + deleteEventQueues: function (event_ids, callback) { + // delete one or more event queues + var self = this; + if (!Array.isArray(event_ids)) event_ids = [event_ids]; + + async.eachSeries(event_ids, + function (event_id, callback) { + // remove count from RAM, then delete storage list + self.logDebug(4, "Deleting event queue: " + event_id); + + delete self.eventQueue[event_id]; + + self.storage.listDelete('global/event_queue/' + event_id, true, function (err) { + // ignore error, as list may not exist + callback(); + }); + }, + function () { + // send eventQueue update to connected clients + self.authSocketEmit('update', { eventQueue: self.eventQueue }); + if (callback) callback(); + } + ); // eachSeries + }, + + shutdownScheduler: function (callback) { + // persist state to storage + var self = this; + if (!this.multi.manager) { + if (callback) callback(); + return; + } + + if (this.schedulerGraceTimer) { + clearTimeout(this.schedulerGraceTimer); + delete this.schedulerGraceTimer; + } + + this.storage.put('global/state', this.state, function (err) { + if (err) self.logError('state', "Failed to update state: " + err); + if (callback) callback(); + }); + } + +}); diff --git a/lib/test.js b/lib/test.js new file mode 100644 index 0000000..a874fc0 --- /dev/null +++ b/lib/test.js @@ -0,0 +1,1559 @@ +// Unit tests for Cronicle (run using `npm test`) +// Copyright (c) 2016 - 2017 Joseph Huckaby +// Released under the MIT License + +var cp = require('child_process'); +var fs = require('fs'); +var async = require('async'); +var glob = require('glob'); +var moment = require('moment-timezone'); + +var Tools = require('pixl-tools'); +var PixlServer = require("pixl-server"); + +// we need a few config files +var config = require('../sample_conf/config.json'); +var setup = require('../sample_conf/setup.json'); + +// override things for the unit tests +config.debug = true; +config.echo = false; +config.color = false; +config.manager = false; + +config.WebServer.http_port = 4012; +config.base_app_url = "http://localhost:4012"; +config.udp_broadcast_port = 4014; + +config.email_from = "test@localhost"; +config.smtp_hostname = "localhost"; +config.secret_key = "UNIT_TEST"; +config.log_filename = "unit.log"; +config.pid_file = "logs/unit.pid"; +config.debug_level = 10; +config.scheduler_startup_grace = 0; +config.job_startup_grace = 1; +config.Storage.Filesystem.base_dir = "data/unittest"; +config.web_hook_config_keys = ["base_app_url", "something_custom"]; +config.something_custom = "nonstandard property"; +config.track_manual_jobs = true; + +// chdir to the proper server root dir +process.chdir( require('path').dirname( __dirname ) ); + +// global refs +var server = null; +var storage = null; +var cronicle = null; +var request = null; +var api_url = ''; +var session_id = ''; + +module.exports = { + logDebug: function(level, msg, data) { + // proxy request to system logger with correct component + if (cronicle && cronicle.logger) { + cronicle.logger.set( 'component', 'UnitTest' ); + cronicle.logger.debug( level, msg, data ); + } + }, + + setUp: function (callback) { + // always called before tests start + var self = this; + + // make sure another unit test isn't running + var pid = false; + try { pid = fs.readFileSync('logs/unit.pid', { encoding: 'utf8' }); } + catch (e) {;} + if (pid) { + var alive = true; + try { process.kill(parseInt(pid), 0); } + catch (e) { alive = false; } + if (alive) { + console.warn("Another unit test is already running (PID " + pid + "). Exiting."); + process.exit(1); + } + } + + // clean out data from last time + try { cp.execSync('rm -rf logs/unit.pid data/unittest'); } + catch (e) {;} + + // construct server object + server = new PixlServer({ + + __name: 'Cronicle', + __version: require('../package.json').version, + + config: config, + + components: [ + require('pixl-server-storage'), + require('pixl-server-web'), + require('pixl-server-api'), + require('pixl-server-user'), + require('./engine.js') + ] + + }); + + server.startup( function() { + // server startup complete + storage = server.Storage; + cronicle = server.Cronicle; + + // prepare to make api calls + request = cronicle.request; + api_url = server.config.get('base_app_url') + server.API.config.get('base_uri'); + + // cancel auto ticks, so we can send our own later + clearTimeout( server.tickTimer ); + delete server.tickTimer; + + // bootstrap storage with initial records + async.eachSeries( setup.storage, + function(params, callback) { + var func = params.shift(); + params.push( callback ); + + // massage a few params + if (typeof(params[1]) == 'object') { + var obj = params[1]; + if (obj.created) obj.created = Tools.timeNow(true); + if (obj.modified) obj.modified = Tools.timeNow(true); + if (obj.regexp && (obj.regexp == '_HOSTNAME_')) obj.regexp = '^(' + Tools.escapeRegExp( server.hostname ) + ')$'; + if (obj.hostname && (obj.hostname == '_HOSTNAME_')) obj.hostname = server.hostname; + if (obj.ip && (obj.ip == '_IP_')) obj.ip = server.ip; + } + + // call storage directly + storage[func].apply( storage, params ); + }, + function(err) { + if (err) throw err; + + // begin unit tests + callback(); + } + ); // async.eachSeries + } ); // server.startup + }, // setUp + + beforeEach: function(test) { + // called just before each test + this.logDebug(10, "Starting unit test: " + test.name ); + }, + + afterEach: function(test) { + // called after each test completes + this.logDebug(10, "Unit test complete: " + test.name ); + }, + + // + // Tests Array: + // + + tests: [ + + function testServerStarted(test) { + test.ok( server.started > 0, 'Cronicle started up successfully'); + test.done(); + }, + + function testStorage(test) { + storage.get( 'users/admin', function(err, user) { + test.ok( !err, "No error fetching admin user" ); + test.ok( !!user, "User record is non-null" ); + test.ok( user.username == "admin", "Username is correct" ); + test.ok( user.created > 0, "User creation date is non-zero" ); + + test.done(); + } ); + }, + + function testcheckmanagerEligibility(test) { + cronicle.checkmanagerEligibility( function() { + test.ok( cronicle.multi.cluster == true, "Server found in cluster" ); + test.ok( cronicle.multi.eligible == true, "Server is eligible for manager" ); + test.ok( cronicle.multi.manager == false, "Server is not yet manager" ); + test.ok( cronicle.multi.worker == false, "Server is not a worker" ); + + test.done(); + } ); + }, + + function testGomanager(test) { + cronicle.gomanager(); + + test.ok( cronicle.multi.manager == true, "Server became manager" ); + test.ok( cronicle.multi.worker == false, "Server is not a worker" ); + test.ok( cronicle.multi.cluster == true, "Server is still found in cluster" ); + test.ok( cronicle.multi.managerHostname == server.hostname, "Server managerHostname is self" ); + test.ok( !!cronicle.multi.lastPingSent, "Server lastPingSent is non-zero" ); + test.ok( !!cronicle.tz, "Server has a timezone set" ); + + // need a rest here, so async sub-components can start up + setTimeout( function() { test.done(); }, 500 ); + }, + + function testAPIPing(test) { + // make basic REST API call, check response + request.json( api_url + '/app/ping', {}, function(err, resp, data) { + + test.ok( !err, "No error requesting ping API" ); + test.ok( resp.statusCode == 200, "HTTP 200 from ping API" ); + test.ok( "code" in data, "Found code prop in JSON response" ); + test.ok( data.code == 0, "Code is zero (no error)" ); + + test.done(); + } ); + }, + + function testAPILoginBadUsername(test) { + // login with unknown username + var params = { + username: "nobody", + password: "foo" + }; + request.json( api_url + '/user/login', params, function(err, resp, data) { + + test.ok( !err, "No error requesting user/login API" ); + test.ok( resp.statusCode == 200, "HTTP 200 from user/login API" ); + test.ok( "code" in data, "Found code prop in JSON response" ); + test.ok( data.code != 0, "Code is non-zero (we expect an error)" ); + test.ok( !data.session_id, "No session_id in response" ); + + test.done(); + } ); + }, + + function testAPILoginBadPassword(test) { + // login with good user but bad password + var params = { + username: "admin", + password: "adminnnnnnn" + }; + request.json( api_url + '/user/login', params, function(err, resp, data) { + + test.ok( !err, "No error requesting user/login API" ); + test.ok( resp.statusCode == 200, "HTTP 200 from user/login API" ); + test.ok( "code" in data, "Found code prop in JSON response" ); + test.ok( data.code != 0, "Code is non-zero (we expect an error)" ); + test.ok( !data.session_id, "No session_id in response" ); + + test.done(); + } ); + }, + + function testAPIUserLogin(test) { + // login as admin (successfully), save session id for downstream tests + var params = { + username: "admin", + password: "admin" + }; + request.json( api_url + '/user/login', params, function(err, resp, data) { + + test.ok( !err, "No error requesting user/login API" ); + test.ok( resp.statusCode == 200, "HTTP 200 from user/login API" ); + test.ok( "code" in data, "Found code prop in JSON response" ); + test.ok( data.code == 0, "Code is zero (no error)" ); + test.ok( !!data.session_id, "Found session_id in response" ); + + // save session_id for later + session_id = data.session_id; + + test.done(); + } ); + }, + + function testAPIConfig(test) { + // test app/config api + var params = {}; + request.json( api_url + '/app/config', params, function(err, resp, data) { + + test.ok( !err, "No error requesting API" ); + test.ok( resp.statusCode == 200, "HTTP 200 from API" ); + test.ok( "code" in data, "Found code prop in JSON response" ); + test.ok( data.code == 0, "Code is zero (no error)" ); + test.ok( !!data.config, "Found config in response data" ); + + test.done(); + } ); + }, + + // app/create_plugin + + function testAPICreatePlugin(test) { + // test app/create_plugin api + var self = this; + var params = {"params":[{"type":"textarea","id":"script","title":"Script Source","rows":10,"value":"#!/bin/sh\n\n# Enter your shell script code here"}],"title":"Copy of Shell Script","command":"bin/shell-plugin.js","enabled":1,"session_id":session_id}; + + request.json( api_url + '/app/create_plugin', params, function(err, resp, data) { + + test.ok( !err, "No error requesting API" ); + test.ok( resp.statusCode == 200, "HTTP 200 from API" ); + test.ok( "code" in data, "Found code prop in JSON response" ); + test.ok( data.code == 0, "Code is zero (no error)" ); + test.ok( !!data.id, "Found new id in data" ); + + // save plugin id for later + self.plugin_id = data.id; + + // check to see that plugin actually got saved to storage + storage.listFind( 'global/plugins', { id: data.id }, function(err, plugin) { + test.ok( !err, "No error fetching data" ); + test.ok( !!plugin, "Data record record is non-null" ); + test.ok( plugin.username == "admin", "Username is correct" ); + test.ok( plugin.created > 0, "Record creation date is non-zero" ); + + test.done(); + } ); + } ); + }, + + // app/update_plugin + + function testAPIUpdatePlugin(test) { + // test app/update_plugin api + var self = this; + var params = {"id":this.plugin_id, "title":"Updated Plugin Title","session_id":session_id}; + + request.json( api_url + '/app/update_plugin', params, function(err, resp, data) { + + test.ok( !err, "No error requesting API" ); + test.ok( resp.statusCode == 200, "HTTP 200 from API" ); + test.ok( "code" in data, "Found code prop in JSON response" ); + test.ok( data.code == 0, "Code is zero (no error)" ); + + // check to see that plugin actually got saved to storage + storage.listFind( 'global/plugins', { id: self.plugin_id }, function(err, plugin) { + test.ok( !err, "No error fetching data" ); + test.ok( !!plugin, "Data record is non-null" ); + test.ok( plugin.username == "admin", "Username is correct" ); + test.ok( plugin.created > 0, "Record creation date is non-zero" ); + test.ok( plugin.title == "Updated Plugin Title", "Title was updated correctly" ); + + test.done(); + } ); + } ); + }, + + // app/delete_plugin + + function testAPIDeletePlugin(test) { + // test app/delete_plugin api + var self = this; + var params = {"id":this.plugin_id, "session_id":session_id}; + + request.json( api_url + '/app/delete_plugin', params, function(err, resp, data) { + + test.ok( !err, "No error requesting API" ); + test.ok( resp.statusCode == 200, "HTTP 200 from API" ); + test.ok( "code" in data, "Found code prop in JSON response" ); + test.ok( data.code == 0, "Code is zero (no error)" ); + + // check to see that plugin actually got deleted from storage + storage.listFind( 'global/plugins', { id: self.plugin_id }, function(err, plugin) { + test.ok( !err, "No error expected for missing data" ); + test.ok( !plugin, "Data record should be null (deleted)" ); + + delete self.plugin_id; + + test.done(); + } ); + } ); + }, + + // app/create_category + + function testAPICreateCategory(test) { + // test app/create_category api + var self = this; + var params = {"title":"test will del cat","description":"yo","max_children":0,"enabled":1,"notify_success":"","notify_fail":"","web_hook":"","cpu_limit":0,"cpu_sustain":0,"memory_limit":0,"memory_sustain":0,"log_max_size":0,"session_id":session_id}; + + request.json( api_url + '/app/create_category', params, function(err, resp, data) { + + test.ok( !err, "No error requesting API" ); + test.ok( resp.statusCode == 200, "HTTP 200 from API" ); + test.ok( "code" in data, "Found code prop in JSON response" ); + test.ok( data.code == 0, "Code is zero (no error)" ); + test.ok( !!data.id, "Found new id in data" ); + + // save cat id for later + self.cat_id = data.id; + + // check to see that cat actually got saved to storage + storage.listFind( 'global/categories', { id: data.id }, function(err, cat) { + test.ok( !err, "No error fetching data" ); + test.ok( !!cat, "Data record record is non-null" ); + test.ok( cat.username == "admin", "Username is correct" ); + test.ok( cat.created > 0, "Record creation date is non-zero" ); + + test.done(); + } ); + } ); + }, + + // app/update_category + + function testAPIUpdateCategory(test) { + // test app/update_category api + var self = this; + var params = {"id":this.cat_id, "title":"Updated Category Title","session_id":session_id}; + + request.json( api_url + '/app/update_category', params, function(err, resp, data) { + + test.ok( !err, "No error requesting API" ); + test.ok( resp.statusCode == 200, "HTTP 200 from API" ); + test.ok( "code" in data, "Found code prop in JSON response" ); + test.ok( data.code == 0, "Code is zero (no error)" ); + + // check to see that cat actually got saved to storage + storage.listFind( 'global/categories', { id: self.cat_id }, function(err, cat) { + test.ok( !err, "No error fetching data" ); + test.ok( !!cat, "Data record is non-null" ); + test.ok( cat.username == "admin", "Username is correct" ); + test.ok( cat.created > 0, "Record creation date is non-zero" ); + test.ok( cat.title == "Updated Category Title", "Title was updated correctly" ); + + test.done(); + } ); + } ); + }, + + // app/delete_category + + function testAPIDeleteCategory(test) { + // test app/delete_category api + var self = this; + var params = {"id":this.cat_id, "session_id":session_id}; + + request.json( api_url + '/app/delete_category', params, function(err, resp, data) { + + test.ok( !err, "No error requesting API" ); + test.ok( resp.statusCode == 200, "HTTP 200 from API" ); + test.ok( "code" in data, "Found code prop in JSON response" ); + test.ok( data.code == 0, "Code is zero (no error)" ); + + // check to see that cat actually got deleted from storage + storage.listFind( 'global/categories', { id: self.cat_id }, function(err, cat) { + test.ok( !err, "No error expected for missing data" ); + test.ok( !cat, "Data record should be null (deleted)" ); + + delete self.cat_id; + + test.done(); + } ); + } ); + }, + + // app/create_server_group + + function testAPICreateServerGroup(test) { + // test app/create_server_group api + var self = this; + var params = {"title":"del gap","regexp":"dasds","manager":0,"session_id":session_id}; + + request.json( api_url + '/app/create_server_group', params, function(err, resp, data) { + + test.ok( !err, "No error requesting API" ); + test.ok( resp.statusCode == 200, "HTTP 200 from API" ); + test.ok( "code" in data, "Found code prop in JSON response" ); + test.ok( data.code == 0, "Code is zero (no error)" ); + test.ok( !!data.id, "Found new id in data" ); + + // save group id for later + self.group_id = data.id; + + // check to see that group actually got saved to storage + storage.listFind( 'global/server_groups', { id: data.id }, function(err, group) { + test.ok( !err, "No error fetching data" ); + test.ok( !!group, "Data record record is non-null" ); + test.ok( group.title == "del gap", "Title is correct" ); + test.ok( group.regexp == "dasds", "Regexp is correct" ); + + test.done(); + } ); + } ); + }, + + // app/update_server_group + + function testAPIUpdateServerGroup(test) { + // test app/update_server_group api + var self = this; + var params = {"id":this.group_id, "title":"Updated Group Title","session_id":session_id}; + + request.json( api_url + '/app/update_server_group', params, function(err, resp, data) { + + test.ok( !err, "No error requesting API" ); + test.ok( resp.statusCode == 200, "HTTP 200 from API" ); + test.ok( "code" in data, "Found code prop in JSON response" ); + test.ok( data.code == 0, "Code is zero (no error)" ); + + // check to see that group actually got saved to storage + storage.listFind( 'global/server_groups', { id: self.group_id }, function(err, group) { + test.ok( !err, "No error fetching data" ); + test.ok( !!group, "Data record is non-null" ); + test.ok( group.title == "Updated Group Title", "Title was updated correctly" ); + + test.done(); + } ); + } ); + }, + + // app/delete_server_group + + function testAPIDeleteServerGroup(test) { + // test app/delete_server_group api + var self = this; + var params = {"id":this.group_id, "session_id":session_id}; + + request.json( api_url + '/app/delete_server_group', params, function(err, resp, data) { + + test.ok( !err, "No error requesting API" ); + test.ok( resp.statusCode == 200, "HTTP 200 from API" ); + test.ok( "code" in data, "Found code prop in JSON response" ); + test.ok( data.code == 0, "Code is zero (no error)" ); + + // check to see that group actually got deleted from storage + storage.listFind( 'global/server_groups', { id: self.group_id }, function(err, group) { + test.ok( !err, "No error expected for missing data" ); + test.ok( !group, "Data record should be null (deleted)" ); + + delete self.group_id; + + test.done(); + } ); + } ); + }, + + // app/create_api_key + + function testAPICreateAPIKey(test) { + // test app/create_api_key api + var self = this; + var params = {"key":"35b60c12892dd4503cf3a8dbf22d3354","privileges":{"admin":0,"create_events":0,"edit_events":0,"delete_events":1,"run_events":0,"abort_events":0,"state_update":0},"active":"1","title":"test will delete","description":"dshfdwsfs","session_id":session_id}; + + request.json( api_url + '/app/create_api_key', params, function(err, resp, data) { + + test.ok( !err, "No error requesting API" ); + test.ok( resp.statusCode == 200, "HTTP 200 from API" ); + test.ok( "code" in data, "Found code prop in JSON response" ); + test.ok( data.code == 0, "Code is zero (no error)" ); + test.ok( !!data.id, "Found new id in data" ); + test.ok( !!data.key, "Found new api key in data" ); + + // save api key id for later + self.apikey_id = data.id; + self.apikey_key = data.key; + + // check to see that api key actually got saved to storage + storage.listFind( 'global/api_keys', { id: data.id }, function(err, api_key) { + test.ok( !err, "No error fetching data" ); + test.ok( !!api_key, "Data record is non-null" ); + test.ok( api_key.username == "admin", "Username is correct" ); + test.ok( api_key.created > 0, "Record creation date is non-zero" ); + test.ok( !!api_key.key, "API Key record has key" ); + test.ok( api_key.key == "35b60c12892dd4503cf3a8dbf22d3354", "API Key is correct" ); + + test.done(); + } ); + } ); + }, + + function testAPIKeyUsage(test) { + // try to hit an API using the API Key as auth (not a user session id) + var self = this; + var params = { "api_key": this.apikey_key, offset: 0, limit: 100 }; + + request.json( api_url + '/app/get_schedule', params, function(err, resp, data) { + + test.ok( !err, "No error requesting API" ); + test.ok( resp.statusCode == 200, "HTTP 200 from API" ); + test.ok( "code" in data, "Found code prop in JSON response" ); + test.ok( data.code == 0, "Code is zero (no error)" ); + + test.done(); + } ); + }, + + function testAPIKeyUnauthorized(test) { + // try to access an API that is unauthorized for an API Key + // an error is expected here + var self = this; + var params = {"title":"this should fail","description":"yo key","max_children":0,"enabled":1,"notify_success":"","notify_fail":"","web_hook":"","cpu_limit":0,"cpu_sustain":0,"memory_limit":0,"memory_sustain":0,"api_key":this.apikey_key}; + + request.json( api_url + '/app/create_category', params, function(err, resp, data) { + + test.ok( !err, "No error requesting API", err ); + test.ok( resp.statusCode == 200, "HTTP 200 from API", resp.statusCode ); + test.ok( "code" in data, "Found code prop in JSON response", data ); + test.ok( data.code != 0, "Code is non-zero (error is expected)", data ); + + test.done(); + } ); + }, + + // app/update_api_key + + function testAPIUpdateAPIKey(test) { + // test app/update_api_key api + var self = this; + var params = {"id":this.apikey_id, "title":"Updated API Key Title","session_id":session_id}; + + request.json( api_url + '/app/update_api_key', params, function(err, resp, data) { + + test.ok( !err, "No error requesting API" ); + test.ok( resp.statusCode == 200, "HTTP 200 from API" ); + test.ok( "code" in data, "Found code prop in JSON response" ); + test.ok( data.code == 0, "Code is zero (no error)" ); + + // check to see that api key actually got saved to storage + storage.listFind( 'global/api_keys', { id: self.apikey_id }, function(err, api_key) { + test.ok( !err, "No error fetching data" ); + test.ok( !!api_key, "Data record is non-null" ); + test.ok( api_key.username == "admin", "Username is correct" ); + test.ok( api_key.created > 0, "Record creation date is non-zero" ); + test.ok( api_key.title == "Updated API Key Title", "Title was updated correctly" ); + + test.done(); + } ); + } ); + }, + + // app/get_api_keys + + function testAPIGetAPIKeys(test) { + // test app/get_api_keys api + var self = this; + var params = { "session_id": session_id, offset: 0, limit: 100 }; + + request.json( api_url + '/app/get_api_keys', params, function(err, resp, data) { + + test.ok( !err, "No error requesting API" ); + test.ok( resp.statusCode == 200, "HTTP 200 from API" ); + test.ok( "code" in data, "Found code prop in JSON response" ); + test.ok( data.code == 0, "Code is zero (no error)" ); + test.ok( !!data.rows, "Found rows in response" ); + test.ok( !!data.rows.length, "Rows has length" ); + + var api_key = Tools.findObject( data.rows, { id: self.apikey_id } ); + test.ok( !!api_key, "Found our API Key in rows" ); + test.ok( api_key.id == self.apikey_id, "API Key ID matches our query" ); + test.ok( api_key.username == "admin", "Username is correct" ); + test.ok( api_key.created > 0, "Record creation date is non-zero" ); + test.ok( !!api_key.key, "API Key record has key" ); + + test.done(); + + } ); + }, + + // app/get_api_key + + function testAPIGetAPIKey(test) { + // test app/get_api_key api + var self = this; + var params = { "session_id": session_id, id: this.apikey_id }; + + request.json( api_url + '/app/get_api_key', params, function(err, resp, data) { + + test.ok( !err, "No error requesting API" ); + test.ok( resp.statusCode == 200, "HTTP 200 from API" ); + test.ok( "code" in data, "Found code prop in JSON response" ); + test.ok( data.code == 0, "Code is zero (no error)" ); + + var api_key = data.api_key; + test.ok( !!api_key, "Found our API Key in data" ); + test.ok( api_key.id == self.apikey_id, "API Key ID matches our query" ); + test.ok( api_key.username == "admin", "Username is correct" ); + test.ok( api_key.created > 0, "Record creation date is non-zero" ); + test.ok( !!api_key.key, "API Key record has key" ); + + test.done(); + + } ); + }, + + // app/delete_api_key + + function testAPIDeleteAPIKey(test) { + // test app/delete_api_key api + var self = this; + var params = {"id":this.apikey_id, "session_id":session_id}; + + request.json( api_url + '/app/delete_api_key', params, function(err, resp, data) { + + test.ok( !err, "No error requesting API" ); + test.ok( resp.statusCode == 200, "HTTP 200 from API" ); + test.ok( "code" in data, "Found code prop in JSON response" ); + test.ok( data.code == 0, "Code is zero (no error)" ); + + // check to see that api key actually got deleted from storage + storage.listFind( 'global/api_keys', { id: self.apikey_id }, function(err, api_key) { + test.ok( !err, "No error expected for missing data" ); + test.ok( !api_key, "Data record should be null (deleted)" ); + + delete self.apikey_id; + + test.done(); + } ); + } ); + }, + + // app/create_event + + function testAPICreateEvent(test) { + // test app/create_event api + var self = this; + var params = { + "enabled": 1, + "params": { + "duration": "10", + "progress": 1, + "action": "Success", + "secret": "foo" + }, + "timing": { + "years": [2001], // we'll run it manually first + "minutes": [0] + }, + "max_children": 1, + "timeout": 300, + "catch_up": 0, + "timezone": cronicle.tz, + "plugin": "testplug", + "title": "Well Test!", + "category": "general", + "target": "maingrp", + "multiplex": 0, + "retries": 0, + "retry_delay": 0, + "detached": 0, + "notify_success": "", + "notify_fail": "", + "web_hook": "", + "cpu_limit": 0, + "cpu_sustain": 0, + "memory_limit": 0, + "memory_sustain": 0, + "notes": "", + "session_id": session_id + }; + + request.json( api_url + '/app/create_event', params, function(err, resp, data) { + + test.ok( !err, "No error requesting API" ); + test.ok( resp.statusCode == 200, "HTTP 200 from API" ); + test.ok( "code" in data, "Found code prop in JSON response" ); + test.ok( data.code == 0, "Code is zero (no error)" ); + test.ok( !!data.id, "Found new id in data" ); + + // save event id for later + self.event_id = data.id; + + // check to see that event actually got saved to storage + storage.listFind( 'global/schedule', { id: data.id }, function(err, event) { + test.ok( !err, "No error fetching data" ); + test.ok( !!event, "Data record record is non-null" ); + test.ok( event.username == "admin", "Username is correct" ); + test.ok( event.created > 0, "Record creation date is non-zero" ); + + test.done(); + } ); + } ); + }, + + // app/update_event + + function testAPIUpdateEvent(test) { + // test app/update_event api + var self = this; + var params = { + "id": this.event_id, + "title": "Updated Event Title", + "session_id": session_id + }; + + request.json( api_url + '/app/update_event', params, function(err, resp, data) { + + test.ok( !err, "No error requesting API" ); + test.ok( resp.statusCode == 200, "HTTP 200 from API" ); + test.ok( "code" in data, "Found code prop in JSON response" ); + test.ok( data.code == 0, "Code is zero (no error)" ); + + // check to see that event actually got saved to storage + storage.listFind( 'global/schedule', { id: self.event_id }, function(err, event) { + test.ok( !err, "No error fetching data" ); + test.ok( !!event, "Data record record is non-null" ); + test.ok( event.username == "admin", "Username is correct" ); + test.ok( event.created > 0, "Record creation date is non-zero" ); + test.ok( event.title == "Updated Event Title", "New title is correct" ); + + test.done(); + } ); + } ); + }, + + // app/get_schedule + + function testAPIGetSchedule(test) { + // test app/get_schedule api + var self = this; + var params = { "session_id": session_id, offset: 0, limit: 100 }; + + request.json( api_url + '/app/get_schedule', params, function(err, resp, data) { + + test.ok( !err, "No error requesting API" ); + test.ok( resp.statusCode == 200, "HTTP 200 from API" ); + test.ok( "code" in data, "Found code prop in JSON response" ); + test.ok( data.code == 0, "Code is zero (no error)" ); + test.ok( !!data.rows, "Found rows in response" ); + test.ok( !!data.rows.length, "Rows has length" ); + + var event = Tools.findObject( data.rows, { id: self.event_id } ); + test.ok( !!event, "Found our event in rows" ); + test.ok( event.id == self.event_id, "Event ID matches our query" ); + test.ok( event.username == "admin", "Username is correct" ); + test.ok( event.created > 0, "Record creation date is non-zero" ); + + test.done(); + + } ); + }, + + // app/get_event + + function testAPIGetEvent(test) { + // test app/get_event api + var self = this; + var params = { "session_id": session_id, id: this.event_id }; + + request.json( api_url + '/app/get_event', params, function(err, resp, data) { + + test.ok( !err, "No error requesting API" ); + test.ok( resp.statusCode == 200, "HTTP 200 from API" ); + test.ok( "code" in data, "Found code prop in JSON response" ); + test.ok( data.code == 0, "Code is zero (no error)" ); + + var event = data.event; + test.ok( !!event, "Found our event in data" ); + test.ok( event.id == self.event_id, "Event ID matches our query" ); + test.ok( event.username == "admin", "Username is correct" ); + test.ok( event.created > 0, "Record creation date is non-zero" ); + + test.done(); + + } ); + }, + + // app/run_event + + function testAPIRunEvent(test) { + // test app/run_event api + // run event manually, specify an override + var self = this; + var params = { + "session_id": session_id, + id: this.event_id, + notify_fail: 'test@test.com' + }; + + request.json( api_url + '/app/run_event', params, function(err, resp, data) { + + test.ok( !err, "No error requesting API" ); + test.ok( resp.statusCode == 200, "HTTP 200 from API" ); + test.ok( "code" in data, "Found code prop in JSON response" ); + test.ok( data.code == 0, "Code is zero (no error)" ); + test.ok( !!data.ids, "Found ids in response" ); + test.ok( data.ids.length == 1, "Data ids has length of 1" ); + test.ok( !!data.ids[0], "Found Job ID in response data" ); + + var job_id = data.ids[0]; + self.job_id = job_id; + + // wait a few seconds here for job to start and get to around 50% + setTimeout( function() { + test.done(); + }, 1000 * 5 ); + + } ); + }, + + function testJobInProgress(test) { + // make sure job is in progress + var self = this; + var all_jobs = cronicle.getAllActiveJobs(); + var job = all_jobs[ this.job_id ]; + + test.ok( !!job, "Found our job in active list" ); + test.ok( job.event == this.event_id, "Job has correct Event ID" ); + test.ok( job.progress > 0, "Job has positive progress" ); + test.ok( job.notify_fail == "test@test.com", "Our notify_fail override made it in" ); + test.ok( !!job.pid, "Job has a PID" ); + + // try to ping pid + var ping = false; + try { ping = process.kill( job.pid, 0 ); } + catch (e) {;} + test.ok( !!ping, "Job PID was successfully pinged" ); + + // force cronicle to measure mem/cpu + cronicle.monitorServerResources( function(err) { + test.ok( !err, "No error calling monitorServerResources", err ); + test.done(); + } ); + }, + + // app/get_live_job_log + + function testAPIGetLiveJobLog(test) { + // test get_live_job_log API (raw HTTP get, not a JSON API) + var self = this; + + request.get( api_url + '/app/get_live_job_log?id=' + this.job_id, function(err, resp, data) { + + test.ok( !err, "No error requesting API" ); + test.ok( resp.statusCode == 200, "HTTP 200 from API" ); + test.ok( !!data, "Got data buffer" ); + test.ok( data.length > 0, "Data buffer has length" ); + + test.done(); + + } ); + }, + + // app/get_job_status + + function testAPIGetJobStatus(test) { + // test app/get_job_status api + var self = this; + var params = { + "session_id": session_id, + id: this.job_id + }; + + request.json( api_url + '/app/get_job_status', params, function(err, resp, data) { + + test.ok( !err, "No error requesting API" ); + test.ok( resp.statusCode == 200, "HTTP 200 from API" ); + test.ok( "code" in data, "Found code prop in JSON response" ); + test.ok( data.code == 0, "Code is zero (no error)" ); + test.ok( !!data.job, "Found job in data" ); + + var job = data.job; + test.ok( job.id == self.job_id, "Job ID matches" ); + test.ok( job.progress > 0, "Job progress is still non-zero" ); + + test.ok( !!job.cpu, "Job has CPU metrics" ); + test.ok( job.cpu.count > 0, "Job CPU count is non-zero" ); + // test.ok( job.cpu.current > 0, "Job CPU current is non-zero" ); + + test.ok( !!job.mem, "Job has memory metrics" ); + test.ok( job.mem.count > 0, "Job memory count is non-zero" ); + // test.ok( job.mem.current > 0, "Job memory current is non-zero" ); + + test.done(); + + } ); + }, + + // app/update_job + + function testAPIUpdateJob(test) { + // test app/update_job api + var self = this; + var params = { + "session_id": session_id, + id: this.job_id, + notify_fail: 'test2@test.com' + }; + + request.json( api_url + '/app/update_job', params, function(err, resp, data) { + + test.ok( !err, "No error requesting API" ); + test.ok( resp.statusCode == 200, "HTTP 200 from API" ); + test.ok( "code" in data, "Found code prop in JSON response" ); + test.ok( data.code == 0, "Code is zero (no error)" ); + + var all_jobs = cronicle.getAllActiveJobs(); + var job = all_jobs[ self.job_id ]; + + test.ok( !!job, "Found our job in active list" ); + test.ok( job.event == self.event_id, "Job has correct Event ID" ); + test.ok( job.notify_fail == "test2@test.com", "Our notify_fail update was applied" ); + + test.done(); + + } ); + }, + + // wait for job to complete + + function testWaitJobComplete(test) { + // go into wait loop while job is still in progress + var self = this; + var params = { + "session_id": session_id, + id: this.job_id, + need_log: 1 + }; + var details = { code: 1 }; + var count = 0; + + async.doWhilst( + function (callback) { + // poll get_job_details API + request.json( api_url + '/app/get_job_details', params, function(err, resp, data) { + if (err) return callback(err); + if (resp.statusCode != 200) return callback(new Error("HTTP " + resp.statusCode + " " + resp.statusMessage)); + + // e-brake to prevent infinite loop + if (count++ > 100) return callback(new Error("Too many loop iterations polling get_job_details API")); + + details = data; + setTimeout( callback, 500 ); + } ); + }, + function () { return (details.code != 0); }, + function (err) { + // job is complete + var job = details.job; + + test.ok( !!job, "Got job details in response" ); + test.ok( job.id == self.job_id, "Job ID matches" ); + test.ok( !!job.complete, "Job is marked as complete" ); + test.ok( job.code == 0, "Job is not marked as an error" ); + test.ok( !!job.perf, "Job has perf metrics" ); + test.ok( !!job.pid, "Job record still has a pid" ); + + // job pid should be dead at this point + var ping = false; + try { ping = process.kill( job.pid, 0 ); } + catch (e) {;} + test.ok( !ping, "Job PID is dead" ); + + test.done(); + } + ); + }, + + // app/get_job_log + + function testAPIGetJobLog(test) { + // test get_job_log API (raw HTTP get, not a JSON API) + var self = this; + + request.get( api_url + '/app/get_job_log?id=' + this.job_id, function(err, resp, data) { + + test.ok( !err, "No error requesting API" ); + test.ok( resp.statusCode == 200, "HTTP 200 from API" ); + test.ok( !!data, "Got data buffer" ); + test.ok( data.length > 0, "Data buffer has length" ); + test.ok( data.toString().match(/success/i), "Log buffer contains expected string" ); + + test.done(); + + } ); + }, + + // app/get_event_history + + function testAPIGetEventHistory(test) { + // go into wait loop while event history is being written + var self = this; + var params = { + "session_id": session_id, + id: this.event_id, + offset: 0, + limit: 100 + }; + var details = { rows: [] }; + var count = 0; + + async.doWhilst( + function (callback) { + // poll get_event_history API + request.json( api_url + '/app/get_event_history', params, function(err, resp, data) { + if (err) return callback(err); + if (resp.statusCode != 200) return callback(new Error("HTTP " + resp.statusCode + " " + resp.statusMessage)); + + // e-brake to prevent infinite loop + if (count++ > 10) return callback(new Error("Too many loop iterations polling get_event_history API")); + + details = data; + setTimeout( callback, 500 ); + } ); + }, + function () { return ( !details.rows || !details.rows.length ); }, + function (err) { + // history is written + var stub = details.rows[0]; + + test.ok( !!stub, "Got event history in response" ); + test.ok( stub.id == self.job_id, "History ID matches Job ID" ); + test.ok( stub.code == 0, "Correct code in history item" ); + test.ok( stub.event == self.event_id, "History item Event ID matching Event ID" ); + test.ok( stub.elapsed > 0, "History item has non-zero elapsed time" ); + test.ok( stub.action == "job_complete", "History item has correct action" ); + + test.done(); + } + ); + }, + + // app/get_history + + function testAPIGetHistory(test) { + // go into wait loop while history is being written + var self = this; + var params = { + "session_id": session_id, + offset: 0, + limit: 100 + }; + var details = { rows: [] }; + var count = 0; + + async.doWhilst( + function (callback) { + // poll get_history API + request.json( api_url + '/app/get_history', params, function(err, resp, data) { + if (err) return callback(err); + if (resp.statusCode != 200) return callback(new Error("HTTP " + resp.statusCode + " " + resp.statusMessage)); + + // e-brake to prevent infinite loop + if (count++ > 10) return callback(new Error("Too many loop iterations polling get_history API")); + + details = data; + setTimeout( callback, 500 ); + } ); + }, + function () { return ( !details.rows || !details.rows.length ); }, + function (err) { + // history is written + var stub = details.rows[0]; + + test.ok( !!stub, "Got event history in response" ); + test.ok( stub.id == self.job_id, "History ID matches Job ID" ); + test.ok( stub.code == 0, "Correct code in history item" ); + test.ok( stub.event == self.event_id, "History item Event ID matching Event ID" ); + test.ok( stub.elapsed > 0, "History item has non-zero elapsed time" ); + test.ok( stub.action == "job_complete", "History item has correct action" ); + + test.done(); + } + ); + }, + + // app/get_activity + + function testAPIGetActivity(test) { + // go into wait loop while activity log is being written + var self = this; + var params = { + "session_id": session_id, + offset: 0, + limit: 100 + }; + var details = { rows: [] }; + var count = 0; + + async.doWhilst( + function (callback) { + // poll get_activity API + request.json( api_url + '/app/get_activity', params, function(err, resp, data) { + if (err) return callback(err); + if (resp.statusCode != 200) return callback(new Error("HTTP " + resp.statusCode + " " + resp.statusMessage)); + + // e-brake to prevent infinite loop + if (count++ > 10) return callback(new Error("Too many loop iterations polling get_activity API")); + + details = data; + setTimeout( callback, 500 ); + } ); + }, + function () { return ( !details.rows || !details.rows.length || (details.rows[0].id != self.job_id) ); }, + function (err) { + // activity is written + // test.debug("Activity response:", details); + + var stub = details.rows[0]; + test.debug("Activity first item:", stub); + + test.ok( !!stub, "Got activity in response" ); + test.ok( stub.id == self.job_id, "Activity ID matches Job ID" ); + test.ok( stub.event == self.event_id, "Activity item Event ID matches Event ID" ); + test.ok( stub.action == "job_run", "Activity item has correct action" ); + + test.done(); + } + ); + }, + + function testSchedulerEventTiming(test) { + // test various formats of event timing + + // timestamp for testing: Epoch 1454797620 + // Sat Feb 6 14:27:00 2016 (PST) + + var cursor = 1454797620; + var tz = "America/Los_Angeles"; + + test.ok( !!cronicle.checkEventTiming( {}, cursor, tz ), "Every minute should run" ); + + test.ok( !!cronicle.checkEventTiming( { minutes: [27] }, cursor, tz ), "Hourly should run" ); + test.ok( !cronicle.checkEventTiming( { minutes: [28] }, cursor, tz ), "Hourly should not run" ); + + test.ok( !!cronicle.checkEventTiming( { hours: [14], minutes: [27] }, cursor, tz ), "Daily should run" ); + test.ok( !!cronicle.checkEventTiming( { hours: [14] }, cursor, tz ), "Daily every minute should run" ); + test.ok( !cronicle.checkEventTiming( { hours: [17], minutes: [27] }, cursor, tz ), "Daily should not run" ); + + test.ok( !!cronicle.checkEventTiming( { weekdays: [6], hours: [14], minutes: [27] }, cursor, tz ), "Weekly should run" ); + test.ok( !!cronicle.checkEventTiming( { weekdays: [6], minutes: [27] }, cursor, tz ), "Weekly hourly should run" ); + test.ok( !cronicle.checkEventTiming( { weekdays: [0], hours: [14], minutes: [27] }, cursor, tz ), "Weekly should not run" ); + + test.ok( !!cronicle.checkEventTiming( { days: [6], hours: [14], minutes: [27] }, cursor, tz ), "Monthly should run" ); + test.ok( !!cronicle.checkEventTiming( { days: [6], minutes: [27] }, cursor, tz ), "Monthly hourly should run" ); + test.ok( !cronicle.checkEventTiming( { days: [5], hours: [14], minutes: [27] }, cursor, tz ), "Monthly should not run" ); + + test.ok( !!cronicle.checkEventTiming( { months: [2], days: [6], hours: [14], minutes: [27] }, cursor, tz ), "Yearly should run" ); + test.ok( !!cronicle.checkEventTiming( { months: [2], minutes: [27] }, cursor, tz ), "Yearly hourly should run" ); + test.ok( !cronicle.checkEventTiming( { months: [12], days: [6], hours: [14], minutes: [27] }, cursor, tz ), "Yearly should not run" ); + + test.ok( !!cronicle.checkEventTiming( { years: [2016], months: [2], days: [6], hours: [14], minutes: [27] }, cursor, tz ), "Single should run" ); + test.ok( !cronicle.checkEventTiming( { years: [2015], months: [2], days: [6], hours: [14], minutes: [27] }, cursor, tz ), "Single should not run" ); + + // now test same timestamp in a different timezone + tz = "America/New_York"; + + test.ok( !!cronicle.checkEventTiming( { hours: [17], minutes: [27] }, cursor, tz ), "New York should run" ); + test.ok( !cronicle.checkEventTiming( { hours: [14], minutes: [27] }, cursor, tz ), "New York should not run" ); + + test.done(); + }, + + function testUpdateEventForSchedule(test) { + // update event with hourly timing and a simple shell command + var self = this; + + var params = { + "params": { + "script": "#!/bin/sh\n\necho \"UNIT TEST STRING\"" + }, + "timing": { + "minutes": [25] // hourly on the 25th minute + }, + "plugin": "shellplug", + "web_hook": api_url + '/app/unit_test_web_hook' + }; + + storage.listFindUpdate( 'global/schedule', { id: this.event_id }, params, function(err) { + test.ok( !err, "Failed to update event: " + err ); + test.done(); + } ); + }, + + function testSchedulerTick(test) { + // tick scheduler with false time, which should start our job + var self = this; + + test.ok( !!cronicle.state.enabled, "Scheduler state is currently enabled" ); + + // add API handler for testing web hooks + cronicle.api_unit_test_web_hook = function(args, callback) { + // hello + var params = args.params || {}; + + if (self.expect_web_hook && self.current_test && (params.action == 'job_complete')) { + var test = self.current_test; + delete self.current_test; + + self.web_hook_data = params; + test.ok( !!params, "Got web hook data" ); + test.done(); + } + + callback({ code: 0 }); + }; // web hook handler + + // set props for api callback to detect + self.expect_web_hook = true; + self.web_hook_data = null; + self.current_test = test; + + // setup our fake timestamp to match event timing settings + var dargs = Tools.getDateArgs( Tools.timeNow(true) ); + dargs.min = 25; // match our event timing + + // tick the scheduler + cronicle.schedulerMinuteTick( dargs ); + }, + + function testWebHookData(test) { + // web hook should have got us here, so let's examine the data + var job = this.web_hook_data; + test.debug("Web hook data:", job); + + delete this.web_hook_data; + delete this.expect_web_hook; + + test.ok( !!job, "Got web hook data" ); + test.ok( !!job.id, "Job has an ID", job ); + test.ok( job.id != this.job_id, "Job ID does not match previous job", job ); + test.ok( job.code == 0, "Job is not marked as an error", job ); + test.ok( job.event == this.event_id, "Job Event ID matches", job ); + test.ok( job.category == "general", "Job has correct category", job ); + test.ok( job.plugin == "shellplug", "Job has correct Plugin", job ); + test.ok( !!job.base_app_url, "Job has correct key pulled from config via web hook", job ); + test.ok( job.something_custom == "nonstandard property", "Job has correct custom web hook property", job ); + test.ok( !job.smtp_hostname, "Job does not have config key not in the web hook key list", job ); + + test.done(); + }, + + function testRunFailedEvent(test) { + // run an event that fails + var self = this; + + // set props for api callback to detect + this.expect_web_hook = true; + this.web_hook_data = null; + this.current_test = test; + + storage.listFind( 'global/schedule', { id: this.event_id }, function(err, event) { + test.ok( !err, "No error locating event in schedule" ); + test.ok( !!event, "Found event in schedule" ); + + var job = Tools.copyHash( event, true ); + job.params.script = "#!/bin/sh\n\necho \"UNIT TEST DELIBERATE FAILURE\"\nexit 1\n"; + + cronicle.launchJob( job, function(err, jobs) { + // not doing anything here, as web hook should fire automatically and finish the test + } ); + } ); + }, + + function testRunFailedResults(test) { + // make sure failed event really failed + var job = this.web_hook_data; + test.debug( "Web hook data: ", job ); + + delete this.web_hook_data; + delete this.expect_web_hook; + + test.ok( !!job, "Got web hook data" ); + test.ok( !!job.id, "Job has an ID" ); + test.ok( job.code != 0, "Job is marked as an error" ); + test.ok( !!job.description, "Job has an error description" ); + test.ok( job.event == this.event_id, "Job Event ID matches" ); + test.ok( job.category == "general", "Job has correct category" ); + test.ok( job.plugin == "shellplug", "Job has correct Plugin" ); + + // need rest here, for async logs to finish inserting + setTimeout( function() { + test.done(); + }, 500 ); + }, + + function testRunDetachedEvent(test) { + // run event in detached mode + var self = this; + + storage.listFind( 'global/schedule', { id: this.event_id }, function(err, event) { + test.ok( !err, "No error locating event in schedule" ); + test.ok( !!event, "Found event in schedule" ); + + var job = Tools.copyHash( event, true ); + job.detached = 1; + + cronicle.launchJob( job, function(err, jobs) { + test.ok( !err, "No error launching job" ); + test.ok( !!jobs, "Got array of launched jobs" ); + test.ok( jobs.length == 1, "Launched exactly one job" ); + test.ok( jobs[0].id, "Got Job ID" ); + + // save new job id + self.detached_job_id = jobs[0].id; + + test.done(); + } ); + } ); + }, + + function testWaitForDetachedQueue(test) { + // monitor queue directory until finished file shows up + var self = this; + var file_spec = server.config.get('queue_dir') + '/*.json'; + var files_found = false; + + async.doWhilst( + function (callback) { + // poll queue dir + glob(file_spec, {}, function (err, files) { + // got task files + if (files && files.length) { + files_found = true; + } + setTimeout( callback, 250 ); + } ); + }, + function () { return (!files_found); }, + function (err) { + // got files, we're done + test.done(); + } + ); + }, + + function testFinishDetachedEvent(test) { + // force external queue to run to process finished event + + // set props for api callback to detect + this.expect_web_hook = true; + this.web_hook_data = null; + this.current_test = test; + + cronicle.monitorExternalQueue(); + // not calling test.done() as it should fire via web hook + }, + + function testDetachedWebHookData(test) { + // web hook should have got us here, so let's examine the data + var job = this.web_hook_data; + test.debug( "Detached web hook data: ", job ); + + delete this.web_hook_data; + delete this.expect_web_hook; + + test.ok( !!job, "Got web hook data" ); + test.ok( !!job.id, "Job has an ID" ); + test.ok( job.id == this.detached_job_id, "Job ID matches our detached job" ); + test.ok( job.code == 0, "Job is not marked as an error" ); + test.ok( job.event == this.event_id, "Job Event ID matches" ); + test.ok( job.category == "general", "Job has correct category" ); + test.ok( job.plugin == "shellplug", "Job has correct Plugin" ); + + // need rest here, for async logs to finish inserting, + // before we delete the associated event (which also deletes logs!) + setTimeout( function() { + test.done(); + }, 500 ); + }, + + // app/delete_event + + function testAPIDeleteEvent(test) { + // test app/delete_event api + var self = this; + var params = { + "id": this.event_id, + "session_id": session_id + }; + + request.json( api_url + '/app/delete_event', params, function(err, resp, data) { + + test.ok( !err, "No error requesting API" ); + test.ok( resp.statusCode == 200, "HTTP 200 from API" ); + test.ok( "code" in data, "Found code prop in JSON response" ); + test.ok( data.code == 0, "Code is zero (no error)" ); + + // check to see that event actually got deleted from storage + storage.listFind( 'global/schedule', { id: self.event_id }, function(err, event) { + + test.ok( !err, "No error expected for missing data" ); + test.ok( !event, "Data record should be null (deleted)" ); + + delete self.event_id; + + test.done(); + } ); + } ); + }, + + + + // TODO: app/abort_job + // TODO: app/abort_jobs + + // TODO: catch-up event + + + + // app/update_manager_state + + function testAPIUpdatemanagerState(test) { + // test app/update_manager_state api + var self = this; + var params = { + "session_id": session_id, + "enabled": 0 + }; + + // pre-check that state is currently enabled + test.ok( !!cronicle.state.enabled, "Scheduler state is currently enabled" ); + + // disable it via API + request.json( api_url + '/app/update_manager_state', params, function(err, resp, data) { + + test.ok( !err, "No error requesting API" ); + test.ok( resp.statusCode == 200, "HTTP 200 from API" ); + test.ok( "code" in data, "Found code prop in JSON response" ); + test.ok( data.code == 0, "Code is zero (no error)" ); + + // check to see that change took effect + test.ok( !cronicle.state.enabled, "State is actually disabled" ); + + test.done(); + } ); + }, + + // user/logout + + function testAPIUserLogout(test) { + // test user/logout api + var self = this; + var params = { + "session_id": session_id + }; + + request.json( api_url + '/user/logout', params, function(err, resp, data) { + + test.ok( !err, "No error requesting API" ); + test.ok( resp.statusCode == 200, "HTTP 200 from API" ); + test.ok( "code" in data, "Found code prop in JSON response" ); + test.ok( data.code == 0, "Code is zero (no error)" ); + + // check to see that session actually got deleted from storage + storage.get('sessions/' + session_id, function(err, data) { + + test.ok( !!err, "Error expected for missing session" ); + test.ok( !data, "Data record should be null (deleted)" ); + + test.done(); + } ); + } ); + } + + ], // tests array + + tearDown: function (callback) { + // always called right before shutdown + this.logDebug(1, "Running tearDown"); + + // add some delays here so async storage ops can complete + setTimeout( function() { + server.shutdown( function() { + // delete our mess after a short rest (just so no errors are logged) + setTimeout( function() { + try { cp.execSync('rm -rf data/unittest'); } + catch (e) {;} + + callback(); + }, 500 ); + } ); + }, 500 ); + } +}; diff --git a/lib/user.js b/lib/user.js new file mode 100644 index 0000000..e3132de --- /dev/null +++ b/lib/user.js @@ -0,0 +1,1397 @@ +// Simple User Login Server Component +// A component for the pixl-server daemon framework. +// Copyright (c) 2015 Joseph Huckaby +// Released under the MIT License + +var assert = require("assert"); +var Class = require("pixl-class"); +var Component = require("pixl-server/component"); +var Tools = require("pixl-tools"); +var Mailer = require('pixl-mail'); +var Request = require('pixl-request'); +var bcrypt = require('bcrypt-node'); +var ActiveDirectory = require('activedirectory2'); +const util = require('util'); + +module.exports = Class.create({ + + __name: 'User', + __parent: Component, + + defaultConfig: { + "smtp_hostname": "", + "session_expire_days": 30, + "max_failed_logins_per_hour": 5, + "max_forgot_passwords_per_hour": 3, + "free_accounts": 0, + "sort_global_users": 1, + "use_bcrypt": 1, + "valid_username_match": "^[\\w\\@\\-\\.]+$", + "block_username_match": "^(abuse|admin|administrator|localhost|127\\.0\\.0\\.1|nobody|noreply|root|support|sysadmin|webmanager|www|god|staff|null|0|constructor|__defineGetter__|__defineSetter__|hasOwnProperty|__lookupGetter__|__lookupSetter__|isPrototypeOf|propertyIsEnumerable|toString|valueOf|__proto__|toLocaleString)$", + "email_templates": { + "welcome_new_user": "", + "changed_password": "", + "recover_password": "" + }, + "default_privileges": { + "admin": 0 + } + }, + + hooks: null, + + startup: function (callback) { + // start user service + this.logDebug(3, "User Manager starting up"); + + // register our class as an API namespace + this.server.API.addNamespace("user", "api_", this); + + // add local references to other components + this.storage = this.server.Storage; + this.web = this.server.WebServer; + + // hook system for integrating with outer webapp + this.hooks = {}; + + // cache this from config + // this.usernameMatch new RegExp(this.config.get('valid_username_match')); + this.usernameMatch = /^[\w\.\-]+@?[\w\.\-]+$/ // alphanum or email like + this.usernameBlock = new RegExp(this.config.get('block_username_match'), "i"); + + // startup complete + callback(); + }, + + + normalizeUsername: function (username) { + // lower-case, strip all non-alpha + if (!username) return ''; + return username.toString().toLowerCase().replace(/\W+/g, ''); + }, + + api_create: async function (args, callback) { + // create new user account + var self = this; + const storageGetAsync = util.promisify(self.storage.get); + const storagetPutAsync = util.promisify(self.storage.put); + const fireHookAsync = util.promisify(self.fireHook); + + var user = args.params; + var path = 'users/' + this.normalizeUsername(user.username); + + if (!this.config.get('free_accounts')) { + return this.doError('user', "Only administrators can create new users.", callback); + } + + if (!this.requireParams(user, { + username: this.usernameMatch, + email: /^\S+\@\S+$/, + full_name: /\S/, + password: /.+/ + }, callback)) return; + + if (user.username.toString().match(this.usernameBlock)) { + return this.doError('user', "Username is blocked: " + user.username, callback); + } + + // first, make sure user doesn't already exist + + let old_user = await storageGetAsync(path); + if (old_user) return self.doError('user', "User already exists: " + user.username, callback); + + // now we can create the user + user.active = 1; + user.created = user.modified = Tools.timeNow(true); + user.salt = Tools.generateUniqueID(64, user.username); + user.password = self.generatePasswordHash(user.password, user.salt); + user.privileges = Tools.copyHash(self.config.get('default_privileges') || {}); + + args.user = user; + + try { + + await fireHookAsync('before_create', args) + + self.logDebug(6, "Creating user", user); + + await storagetPutAsync(path, user); + + self.logDebug(6, "Successfully created user: " + user.username); + self.logTransaction('user_create', user.username, + self.getClientInfo(args, { user: Tools.copyHashRemoveKeys(user, { password: 1, salt: 1 }) })); + + callback({ code: 0 }); + + // add to manager user list in the background + if (self.config.get('sort_global_users')) { + self.storage.listInsertSorted('global/users', { username: user.username }, ['username', 1], function (err) { + if (err) self.logError(1, "Failed to add user to manager list: " + err); + + // fire after hook in background + self.fireHook('after_create', args); + }); + } + else { + self.storage.listUnshift('global/users', { username: user.username }, function (err) { + if (err) self.logError(1, "Failed to add user to manager list: " + err); + + // fire after hook in background + self.fireHook('after_create', args); + }); + } + + // send e-mail in background (no callback) + args.user = user; + args.self_url = self.server.WebServer.getSelfURL(args.request, '/'); + self.sendEmail('welcome_new_user', args); + + } + + catch (err) { self.doError('user', "Failed to create user: " + err, callback); } + + }, + + do_ad_auth: function (user, password, domain) { + return new Promise((resolve, reject) => { + let ad = new ActiveDirectory({ url: ('ldap://' + domain) }); + ad.authenticate(user, password, (err, auth) => { + if (err || !auth) { resolve(false) } + else { resolve(true) } + }); + }); + }, + + api_login: async function (args, callback) { + // user login, validate password, create new session + var self = this; + + //const storageGetAsync = util.promisify(self.storage.get); + + const getUserAsync = function (key) { + return new Promise((resolve, reject) => { + self.storage.get(key, (err, data) => { + if (err || !data) { resolve(undefined) } + else { resolve(data) } + }); + }); + } + + var params = args.params; + + if (!this.requireParams(params, { + username: this.usernameMatch, + password: /.+/ + }, callback)) return; + + + // load user first + + let user = await getUserAsync('users/' + this.normalizeUsername(params.username)); + + if (!user) { + return self.doError('login', "Username or password incorrect.", callback); // deliberately vague + } + if (user.force_password_reset) { + return self.doError('login', "Account is locked out. Please reset your password to unlock it.", callback); + } + + if (!user.active) { + return self.doError('login', "User account is disabled: " + params.username, callback); + } + + args.user = user; + + let isValidPassword = false; + + if (user.ext_auth) { // do AD auth + + var ad_domain = self.server.config.get('ad_domain') || 'corp.cronical.com'; + var ad_user = params.username + '@' + ad_domain; + + // override default domain if username contains (e.g. user@domain.com) + if (params.username.split("@").length == 2) { + ad_domain = params.username.split("@")[1] + ad_user = params.username + } + + isValidPassword = await self.do_ad_auth(ad_user, params.password, ad_domain); + + } + + else { // do local auth + isValidPassword = self.comparePasswords(params.password, user.password, user.salt) + } + + if (!isValidPassword) { + // incorrect password + // (throttle this to prevent abuse) + var date_code = Math.floor(Tools.timeNow() / 3600); + if (date_code != user.fl_date_code) { + user.fl_date_code = date_code; + user.fl_count = 1; + } + else { + user.fl_count++; + if (user.fl_count > self.config.get('max_failed_logins_per_hour')) { + // lockout until password reset + self.logDebug(3, "Locking account due to too many failed login attempts: " + params.username); + user.force_password_reset = 1; + } + } + + // save user to update counters + self.storage.put('users/' + self.normalizeUsername(params.username), user, function (err, data) { + return self.doError('login', "Username or password incorrect.", callback); // deliberately vague + }); + + } + + + self.fireHook('before_login', args, function (err) { + if (err) { + return self.doError('login', "Failed to login: " + err, callback); + } + + // dates + var now = Tools.timeNow(true); + var expiration_date = Tools.normalizeTime( + now + (86400 * self.config.get('session_expire_days')), + { hour: 0, min: 0, sec: 0 } + ); + + // create session id and object + var session_id = Tools.generateUniqueID(64, params.username); + var session = { + id: session_id, + username: params.username, + ip: args.ip, + useragent: args.request.headers['user-agent'], + created: now, + modified: now, + expires: expiration_date + }; + self.logDebug(6, "Logging user in: " + params.username + ": New Session ID: " + session_id, session); + + // store session object + self.storage.put('sessions/' + session_id, session, function (err, data) { + if (err) { + return self.doError('user', "Failed to create session: " + err, callback); + } + else { + self.logDebug(6, "Successfully logged in"); + self.logTransaction('user_login', params.username, self.getClientInfo(args)); + + // set session expiration + self.storage.expire('sessions/' + session_id, expiration_date); + + callback(Tools.mergeHashes({ + code: 0, + username: user.username, + user: Tools.copyHashRemoveKeys(user, { password: 1, salt: 1 }), + session_id: session_id + }, args.resp || {})); + + args.session = session; + self.fireHook('after_login', args); + } // success + }); // save session + }); // hook before + + }, + + api_logout: function (args, callback) { + // user logout, kill session + var self = this; + + this.loadSession(args, function (err, session, user) { + if (!session) { + self.logDebug(6, "Session not found, but returning success anyway"); + callback({ code: 0 }); + return; + } + + args.user = user; + args.session = session; + + self.fireHook('before_logout', args, function (err) { + if (err) { + return self.doError('logout', "Failed to logout: " + err, callback); + } + + self.logDebug(6, "Logging user out: " + session.username + ": Session ID: " + session.id); + + // delete session object + self.storage.delete('sessions/' + session.id, function (err, data) { + // deliberately ignoring error here + + self.logDebug(6, "Successfully logged out"); + self.logTransaction('user_logout', session.username, self.getClientInfo(args)); + + callback({ code: 0 }); + + self.fireHook('after_logout', args); + }); // delete + }); // hook before + }); // load session + }, + + api_resume_session: function (args, callback) { + // validate existing session + var self = this; + + this.loadSession(args, function (err, session, user) { + if (!session) { + return self.doError('session', "Session has expired or is invalid.", callback); + } + if (!user) { + return self.doError('login', "User not found: " + session.username, callback); + } + if (!user.active) { + return self.doError('login', "User account is disabled: " + session.username, callback); + } + if (user.force_password_reset) { + return self.doError('login', "Account is locked out. Please reset your password to unlock it.", callback); + } + + args.user = user; + args.session = session; + + self.fireHook('before_resume_session', args, function (err) { + if (err) { + return self.doError('login', "Failed to login: " + err, callback); + } + + // update session, modified, expiration, etc. + var now = Tools.timeNow(true); + var expiration_date = Tools.normalizeTime( + now + (86400 * self.config.get('session_expire_days')), + { hour: 0, min: 0, sec: 0 } + ); + session.modified = now; + + var new_exp_day = false; + if (expiration_date != session.expires) { + session.expires = expiration_date; + new_exp_day = true; + } + + self.logDebug(6, "Recovering session for: " + session.username, session); + + // store session object + self.storage.put('sessions/' + session.id, session, function (err, data) { + if (err) { + return self.doError('user', "Failed to update session: " + err, callback); + } + else { + self.logDebug(6, "Successfully logged in"); + self.logTransaction('user_login', session.username, self.getClientInfo(args)); + + // set session expiration + if (new_exp_day && self.storage.config.get('expiration_updates')) { + self.storage.expire('sessions/' + session.id, expiration_date); + } + + callback(Tools.mergeHashes({ + code: 0, + username: session.username, + user: Tools.copyHashRemoveKeys(user, { password: 1, salt: 1 }), + session_id: session.id + }, args.resp || {})); + + self.fireHook('after_resume_session', args); + } // success + }); // save session + }); // hook before + }); // loaded session + }, + + api_update: function (args, callback) { + // update existing user + var self = this; + var updates = args.params; + var changed_password = false; + + this.loadSession(args, function (err, session, user) { + if (!session) { + return self.doError('session', "Session has expired or is invalid.", callback); + } + if (!user) { + return self.doError('user', "User not found: " + session.username, callback); + } + if (!user.active) { + return self.doError('user', "User account is disabled: " + session.username, callback); + } + if (updates.username != user.username) { + // sanity check + return self.doError('user', "Username mismatch.", callback); + } + + if (!self.comparePasswords(updates.old_password, user.password, user.salt)) { + return self.doError('login', "Your password is incorrect.", callback); + } + + args.user = user; + args.session = session; + + self.fireHook('before_update', args, function (err) { + if (err) { + return self.doError('user', "Failed to update user: " + err, callback); + } + + // check for password change + if (updates.new_password) { + updates.salt = Tools.generateUniqueID(64, user.username); + updates.password = self.generatePasswordHash(updates.new_password, updates.salt); + changed_password = true; + } // change password + else delete updates.password; + + delete updates.new_password; + delete updates.old_password; + + // don't allow user to update his own privs + delete updates.privileges; + + // apply updates + for (var key in updates) { + user[key] = updates[key]; + } + + // update user record + user.modified = Tools.timeNow(true); + + self.logDebug(6, "Updating user", user); + + self.storage.put("users/" + self.normalizeUsername(user.username), user, function (err, data) { + if (err) { + return self.doError('user', "Failed to update user: " + err, callback); + } + + self.logDebug(6, "Successfully updated user"); + self.logTransaction('user_update', user.username, + self.getClientInfo(args, { user: Tools.copyHashRemoveKeys(user, { password: 1, salt: 1 }) })); + + callback({ + code: 0, + user: Tools.copyHashRemoveKeys(user, { password: 1, salt: 1 }) + }); + + if (changed_password) { + // send e-mail in background (no callback) + args.user = user; + args.date_time = (new Date()).toLocaleString(); + self.sendEmail('changed_password', args); + } // changed_password + + self.fireHook('after_update', args); + }); // updated user + }); // hook before + }); // loaded session + }, + + api_delete: function (args, callback) { + // delete user account AND logout + var self = this; + var params = args.params; + + this.loadSession(args, function (err, session, user) { + if (!session) { + return self.doError('session', "Session has expired or is invalid.", callback); + } + + // make sure user exists and is active + if (!user) { + return self.doError('user', "User not found: " + session.username, callback); + } + if (!user.active) { + return self.doError('user', "User account is disabled: " + session.username, callback); + } + if (params.username != user.username) { + // sanity check + return self.doError('user', "Username mismatch.", callback); + } + + if (!self.comparePasswords(params.password, user.password, user.salt)) { + return self.doError('login', "Your password is incorrect.", callback); + } + + args.user = user; + args.session = session; + + self.fireHook('before_delete', args, function (err) { + if (err) { + return self.doError('login', "Failed to delete user: " + err, callback); + } + + self.logDebug(6, "Deleting session: " + session.id); + self.storage.delete('sessions/' + session.id, function (err, data) { + // ignore session delete error, proceed + + self.logDebug(6, "Deleting user", user); + self.storage.delete("users/" + self.normalizeUsername(user.username), function (err, data) { + if (err) { + return self.doError('user', "Failed to delete user: " + err, callback); + } + else { + self.logDebug(6, "Successfully deleted user"); + self.logTransaction('user_delete', user.username, self.getClientInfo(args)); + + callback({ + code: 0 + }); + + // remove from manager user list in the background + self.storage.listFindCut('global/users', { username: user.username }, function (err) { + if (err) self.logError(1, "Failed to remove user from manager list: " + err); + + self.fireHook('after_delete', args); + }); + + } // success + }); // delete user + }); // delete session + }); // hook before + }); // loaded session + }, + + api_forgot_password: function (args, callback) { + // send forgot password e-mail to user + var self = this; + var params = args.params; + + if (!this.requireParams(params, { + username: this.usernameMatch, + email: /^\S+\@\S+$/ + }, callback)) return; + + // load user first + this.storage.get('users/' + this.normalizeUsername(params.username), function (err, user) { + if (!user) { + return self.doError('login', "User account not found.", callback); // deliberately vague + } + if (user.email.toLowerCase() != params.email.toLowerCase()) { + return self.doError('login', "User account not found.", callback); // deliberately vague + } + if (!user.active) { + return self.doError('login', "User account is disabled: " + session.username, callback); + } + + if (user.ext_auth) { + return self.doError('login', "Password change is not allowed for this user", callback); + } + + // check API throttle + var date_code = Math.floor(Tools.timeNow() / 3600); + if (user.fp_date_code && (date_code == user.fp_date_code) && (user.fp_count > self.config.get('max_forgot_passwords_per_hour'))) { + // lockout until next hour + return self.doError('login', "This feature is locked due to too many requests. Please try again later."); + } + + args.user = user; + + self.fireHook('before_forgot_password', args, function (err) { + if (err) { + return self.doError('login', "Forgot password failed: " + err, callback); + } + + // create special recovery hash and expiration date for it + var recovery_key = Tools.generateUniqueID(64, user.username); + + // dates + var now = Tools.timeNow(true); + var expiration_date = Tools.normalizeTime(now + 86400, { hour: 0, min: 0, sec: 0 }); + + // create object + var recovery = { + key: recovery_key, + username: params.username, + ip: args.ip, + useragent: args.request.headers['user-agent'], + created: now, + modified: now, + expires: expiration_date + }; + self.logDebug(6, "Creating recovery key for: " + params.username + ": Key: " + recovery_key, recovery); + + // store recovery object + self.storage.put('password_recovery/' + recovery_key, recovery, function (err, data) { + if (err) { + return self.doError('user', "Failed to create recovery key: " + err, callback); + } + + self.logDebug(6, "Successfully created recovery key"); + + // set session expiration + self.storage.expire('password_recovery/' + recovery_key, expiration_date); + + // add some things to args for email body placeholder substitution + args.user = user; + args.self_url = self.server.WebServer.getSelfURL(args.request, '/'); + args.date_time = (new Date()).toLocaleString(); + args.recovery_key = recovery_key; + + // send e-mail to user + self.sendEmail('recover_password', args, function (err) { + if (err) { + return self.doError('email', err.message, callback); + } + + self.logTransaction('user_forgot_password', params.username, self.getClientInfo(args, { key: recovery_key })); + callback({ code: 0 }); + + // throttle this API to prevent abuse + if (date_code != user.fp_date_code) { + user.fp_date_code = date_code; + user.fp_count = 1; + } + else { + user.fp_count++; + } + + // save user to update counters + self.storage.put('users/' + self.normalizeUsername(params.username), user, function (err) { + // fire async hook + self.fireHook('after_forgot_password', args); + }); // save user + + }); // email sent + }); // stored recovery object + }); // hook before + }); // loaded user + }, + + api_reset_password: function (args, callback) { + // reset user password using recovery key + var self = this; + var params = args.params; + + if (!this.requireParams(params, { + username: this.usernameMatch, + new_password: /.+/, + key: /^[A-F0-9]{64}$/i + }, callback)) return; + + // load user first + this.storage.get('users/' + this.normalizeUsername(params.username), function (err, user) { + if (!user) { + return self.doError('login', "User account not found.", callback); + } + if (!user.active) { + return self.doError('login', "User account is disabled: " + session.username, callback); + } + + // load recovery key, make sure it matches this user + self.storage.get('password_recovery/' + params.key, function (err, recovery) { + if (!recovery) { + return self.doError('login', "Password reset failed.", callback); // deliberately vague + } + if (recovery.username != params.username) { + return self.doError('login', "Password reset failed.", callback); // deliberately vague + } + + args.user = user; + + self.fireHook('before_reset_password', args, function (err) { + if (err) { + return self.doError('login', "Failed to reset password: " + err, callback); + } + + // update user record + user.salt = Tools.generateUniqueID(64, user.username); + user.password = self.generatePasswordHash(params.new_password, user.salt); + user.modified = Tools.timeNow(true); + + // remove throttle lock + delete user.force_password_reset; + + self.logDebug(6, "Updating user for password reset", user); + + self.storage.put("users/" + self.normalizeUsername(user.username), user, function (err, data) { + if (err) { + return self.doError('user', "Failed to update user: " + err, callback); + } + self.logDebug(6, "Successfully updated user"); + self.logTransaction('user_update', user.username, + self.getClientInfo(args, { user: Tools.copyHashRemoveKeys(user, { password: 1, salt: 1 }) })); + + // delete recovery key (one time use only!) + self.logDebug(6, "Deleting recovery key: " + params.key); + self.storage.delete('password_recovery/' + params.key, function (err, data) { + + // ignore error, call it done + self.logTransaction('user_password_reset', params.username, self.getClientInfo(args, { key: params.key })); + callback({ code: 0 }); + + // send e-mail in background (no callback) + args.user = user; + args.date_time = (new Date()).toLocaleString(); + self.sendEmail('changed_password', args); + + // fire after hook + self.fireHook('after_reset_password', args); + }); // deleted recovery key + }); // updated user + }); // hook before + }); // recovery key loaded + }); // user loaded + }, + + // + // Administrator Level Calls: + // + + api_admin_create: function (args, callback) { + // admin only: create new user account + var self = this; + var new_user = args.params; + var path = 'users/' + this.normalizeUsername(new_user.username); + + if (!this.requireParams(new_user, { + username: this.usernameMatch, + email: /^\S+\@\S+$/, + full_name: /\S/, + password: /.+/ + }, callback)) return; + + this.loadSession(args, function (err, session, admin_user) { + if (!session) { + return self.doError('session', "Session has expired or is invalid.", callback); + } + if (!admin_user) { + return self.doError('user', "User not found: " + session.username, callback); + } + if (!admin_user.active) { + return self.doError('user', "User account is disabled: " + session.username, callback); + } + if (!admin_user.privileges.admin) { + return self.doError('user', "User is not an administrator: " + session.username, callback); + } + + // first, make sure new user doesn't already exist + self.storage.get(path, function (err, old_user) { + if (old_user) { + return self.doError('user_exists', "User already exists: " + new_user.username, callback); + } + + // optionally send e-mail + var send_welcome_email = new_user.send_email || false; + delete new_user.send_email; + + // now we can create the user + new_user.active = 1; + new_user.created = new_user.modified = Tools.timeNow(true); + new_user.salt = Tools.generateUniqueID(64, new_user.username); + new_user.password = self.generatePasswordHash(new_user.password, new_user.salt); + new_user.privileges = new_user.privileges || Tools.copyHash(self.config.get('default_privileges') || {}); + + args.admin_user = admin_user; + args.session = session; + args.user = new_user; + + self.fireHook('before_create', args, function (err) { + if (err) { + return self.doError('user', "Failed to create user: " + err, callback); + } + + self.logDebug(6, "Creating user", new_user); + + self.storage.put(path, new_user, function (err, data) { + if (err) { + return self.doError('user', "Failed to create user: " + err, callback); + } + else { + self.logDebug(6, "Successfully created user: " + new_user.username); + self.logTransaction('user_create', new_user.username, + self.getClientInfo(args, { user: Tools.copyHashRemoveKeys(new_user, { password: 1, salt: 1 }) })); + + callback({ code: 0 }); + + // add to manager user list in the background + if (self.config.get('sort_global_users')) { + self.storage.listInsertSorted('global/users', { username: new_user.username }, ['username', 1], function (err) { + if (err) self.logError(1, "Failed to add user to manager list: " + err); + + // fire after hook in background + self.fireHook('after_create', args); + }); + } + else { + self.storage.listUnshift('global/users', { username: new_user.username }, function (err) { + if (err) self.logError(1, "Failed to add user to manager list: " + err); + + // fire after hook in background + self.fireHook('after_create', args); + }); + } + + // send e-mail in background (no callback) + if (send_welcome_email) { + args.user = new_user; + args.self_url = self.server.WebServer.getSelfURL(args.request, '/'); + self.sendEmail('welcome_new_user', args); + } + + } // success + }); // save user + }); // hook before + }); // check exists + }); // load session + }, + + api_admin_update: function (args, callback) { + // admin only: update any user + var self = this; + var updates = args.params; + var path = 'users/' + this.normalizeUsername(updates.username); + + if (!this.requireParams(args.params, { + username: this.usernameMatch + }, callback)) return; + + this.loadSession(args, function (err, session, admin_user) { + if (!session) { + return self.doError('session', "Session has expired or is invalid.", callback); + } + if (!admin_user) { + return self.doError('user', "User not found: " + session.username, callback); + } + if (!admin_user.active) { + return self.doError('user', "User account is disabled: " + session.username, callback); + } + if (!admin_user.privileges.admin) { + return self.doError('user', "User is not an administrator: " + session.username, callback); + } + + self.storage.get(path, function (err, user) { + if (err) { + return self.doError('user', "User not found: " + updates.username, callback); + } + + args.admin_user = admin_user; + args.session = session; + args.user = user; + + self.fireHook('before_update', args, function (err) { + if (err) { + return self.doError('user', "Failed to update user: " + err, callback); + } + + // check for password change + if (updates.new_password) { + updates.salt = Tools.generateUniqueID(64, user.username); + updates.password = self.generatePasswordHash(updates.new_password, updates.salt); + } // change password + else delete updates.password; + + delete updates.new_password; + + // apply updates + for (var key in updates) { + user[key] = updates[key]; + } + + // update user record + user.modified = Tools.timeNow(true); + + self.logDebug(6, "Admin updating user", user); + + self.storage.put(path, user, function (err, data) { + if (err) { + return self.doError('user', "Failed to update user: " + err, callback); + } + + self.logDebug(6, "Successfully updated user"); + self.logTransaction('user_update', user.username, + self.getClientInfo(args, { user: Tools.copyHashRemoveKeys(user, { password: 1, salt: 1 }) })); + + callback({ + code: 0, + user: Tools.copyHashRemoveKeys(user, { password: 1, salt: 1 }) + }); + + self.fireHook('after_update', args); + }); // updated user + }); // hook before + }); // loaded user + }); // loaded session + }, + + api_admin_delete: function (args, callback) { + // admin only: delete any user account + var self = this; + var params = args.params; + var path = 'users/' + this.normalizeUsername(params.username); + + if (!this.requireParams(params, { + username: this.usernameMatch + }, callback)) return; + + this.loadSession(args, function (err, session, admin_user) { + if (!session) { + return self.doError('session', "Session has expired or is invalid.", callback); + } + if (!admin_user) { + return self.doError('user', "User not found: " + session.username, callback); + } + if (!admin_user.active) { + return self.doError('user', "User account is disabled: " + session.username, callback); + } + if (!admin_user.privileges.admin) { + return self.doError('user', "User is not an administrator: " + session.username, callback); + } + + self.storage.get(path, function (err, user) { + if (err) { + return self.doError('user', "User not found: " + params.username, callback); + } + + args.admin_user = admin_user; + args.session = session; + args.user = user; + + self.fireHook('before_delete', args, function (err) { + if (err) { + return self.doError('login', "Failed to delete user: " + err, callback); + } + + self.logDebug(6, "Deleting user", user); + self.storage.delete("users/" + self.normalizeUsername(user.username), function (err, data) { + if (err) { + return self.doError('user', "Failed to delete user: " + err, callback); + } + else { + self.logDebug(6, "Successfully deleted user"); + self.logTransaction('user_delete', user.username, self.getClientInfo(args)); + + callback({ + code: 0 + }); + + // remove from manager user list in the background + self.storage.listFindCut('global/users', { username: user.username }, function (err) { + if (err) self.logError(1, "Failed to remove user from manager list: " + err); + + self.fireHook('after_delete', args); + }); + + } // success + }); // delete user + }); // hook before + }); // loaded user + }); // loaded session + }, + + api_admin_get_user: function (args, callback) { + // admin only: get single user record, for editing + var self = this; + var params = Tools.mergeHashes(args.params, args.query); + + if (!this.requireParams(params, { + username: this.usernameMatch + }, callback)) return; + + this.loadSession(args, function (err, session, admin_user) { + if (!session) { + return self.doError('session', "Session has expired or is invalid.", callback); + } + if (!admin_user) { + return self.doError('user', "User not found: " + session.username, callback); + } + if (!admin_user.active) { + return self.doError('user', "User account is disabled: " + session.username, callback); + } + if (!admin_user.privileges.admin) { + return self.doError('user', "User is not an administrator: " + session.username, callback); + } + + // load user + var path = 'users/' + self.normalizeUsername(params.username); + self.storage.get(path, function (err, user) { + if (err) { + return self.doError('user', "Failed to load user: " + err, callback); + } + + // success, return user record + callback({ + code: 0, + user: Tools.copyHashRemoveKeys(user, { password: 1, salt: 1 }) + }); + }); // loaded user + + }); // loaded session + }, + + api_admin_get_users: function (args, callback) { + // admin only: get chunk of users from global list, with pagination + var self = this; + var params = Tools.mergeHashes(args.params, args.query); + + this.loadSession(args, function (err, session, admin_user) { + if (!session) { + return self.doError('session', "Session has expired or is invalid.", callback); + } + if (!admin_user) { + return self.doError('user', "User not found: " + session.username, callback); + } + if (!admin_user.active) { + return self.doError('user', "User account is disabled: " + session.username, callback); + } + if (!admin_user.privileges.admin) { + return self.doError('user', "User is not an administrator: " + session.username, callback); + } + + if (!params.offset) params.offset = 0; + if (!params.limit) params.limit = 50; + + self.storage.listGet('global/users', params.offset, params.limit, function (err, stubs, list) { + if (err) { + // no users found, not an error for this API + return callback({ + code: 0, + rows: [], + list: { length: 0 } + }); + } + + // create array of paths to user records + var paths = []; + for (var idx = 0, len = stubs.length; idx < len; idx++) { + paths.push('users/' + self.normalizeUsername(stubs[idx].username)); + } + + // load all users + self.storage.getMulti(paths, function (err, users) { + if (err) { + return self.doError('user', "Failed to load users: " + err, callback); + } + + // remove passwords and salts + for (var idx = 0, len = users.length; idx < len; idx++) { + users[idx] = Tools.copyHashRemoveKeys(users[idx], { password: 1, salt: 1 }); + } + + // success, return users and list header + callback({ + code: 0, + rows: users, + list: list + }); + }); // loaded users + }); // got username list + }); // loaded session + }, + + api_external_login: function (args, callback) { + // query external user management system for login + var self = this; + var url = this.config.get('external_user_api'); + if (!url) return this.doError('user', "No external_user_api config param set.", callback); + + this.logDebug(6, "Externally logging in via: " + url, args.request.headers); + + // must pass along cookie and user-agent + var request = new Request(args.request.headers['user-agent'] || 'PixlUser API'); + request.get(url, { + headers: { 'Cookie': args.request.headers['cookie'] || args.params.cookie || args.query.cookie || '' } + }, + function (err, resp, data) { + // check for error + if (err) return self.doError('user', err, callback); + if (resp.statusCode != 200) { + return self.doError('user', "Bad HTTP Response: " + resp.statusMessage, callback); + } + + var json = null; + try { json = JSON.parse(data.toString()); } + catch (err) { + return self.doError('user', "Failed to parse JSON response: " + err, callback); + } + var code = json.code || json.Code; + if (code) { + return self.doError('user', "External API Error: " + (json.description || json.Description), callback); + } + + self.logDebug(6, "Got response from external user system:", json); + + var username = json.username || json.Username || ''; + var remote_user = json.user || json.User || null; + + if (username && remote_user) { + // user found in response! update our records and create a local session + var path = 'users/' + self.normalizeUsername(username); + + if (!username.match(self.usernameMatch)) { + return self.doError('user', "Username contains illegal characters: " + username); + } + + self.logDebug(7, "Testing if user exists: " + path); + + self.storage.get(path, function (err, user) { + var new_user = false; + if (!user) { + // first time, create new user + self.logDebug(6, "Creating new user: " + username); + new_user = true; + user = { + username: username, + active: 1, + created: Tools.timeNow(true), + modified: Tools.timeNow(true), + salt: Tools.generateUniqueID(64, username), + password: Tools.generateUniqueID(64), // unused + privileges: Tools.copyHash(self.config.get('default_privileges') || {}) + }; + } // new user + else { + self.logDebug(7, "User already exists: " + username); + if (user.force_password_reset) { + return self.doError('login', "Account is locked out. Please reset your password to unlock it.", callback); + } + if (!user.active) { + return self.doError('login', "User account is disabled: " + username, callback); + } + } + + // sync user info + user.full_name = remote_user.full_name || remote_user.FullName || username; + user.email = remote_user.email || remote_user.Email || (username + '@' + self.server.hostname); + + // must reset all privileges here, as remote system may delete keys when privs are revoked + for (var key in user.privileges) { + user.privileges[key] = 0; + } + + // copy over privileges + var privs = remote_user.privileges || remote_user.Privileges || {}; + for (var key in privs) { + var ckey = key.replace(/\W+/g, '_').toLowerCase(); + user.privileges[ckey] = privs[key] ? 1 : 0; + } + + // copy over avatar url + user.avatar = json.avatar || json.Avatar || ''; + + // save user locally + self.storage.put(path, user, function (err) { + if (err) return self.doError('user', "Failed to create user: " + err, callback); + + // copy to args for logging + args.user = user; + + if (new_user) { + self.logDebug(6, "Successfully created user: " + username); + self.logTransaction('user_create', username, + self.getClientInfo(args, { user: Tools.copyHashRemoveKeys(user, { password: 1, salt: 1 }) })); + } + + // now perform a local login + self.fireHook('before_login', args, function (err) { + if (err) { + return self.doError('login', "Failed to login: " + err, callback); + } + + // now create session + var now = Tools.timeNow(true); + var expiration_date = Tools.normalizeTime( + now + (86400 * self.config.get('session_expire_days')), + { hour: 0, min: 0, sec: 0 } + ); + + // create session id and object + var session_id = Tools.generateUniqueID(64, username); + var session = { + id: session_id, + username: username, + ip: args.ip, + useragent: args.request.headers['user-agent'], + created: now, + modified: now, + expires: expiration_date + }; + self.logDebug(6, "Logging user in: " + username + ": New Session ID: " + session_id, session); + + // store session object + self.storage.put('sessions/' + session_id, session, function (err, data) { + if (err) { + return self.doError('user', "Failed to create session: " + err, callback); + } + + // copy to args to logging + args.session = session; + + self.logDebug(6, "Successfully logged in", username); + self.logTransaction('user_login', username, self.getClientInfo(args)); + + // set session expiration + self.storage.expire('sessions/' + session_id, expiration_date); + + callback(Tools.mergeHashes({ + code: 0, + username: username, + user: Tools.copyHashRemoveKeys(user, { password: 1, salt: 1 }), + session_id: session_id + }, args.resp || {})); + + self.fireHook('after_login', args); + + // add to manager user list in the background + if (new_user) { + if (self.config.get('sort_global_users')) { + self.storage.listInsertSorted('global/users', { username: username }, ['username', 1], function (err) { + if (err) self.logError(1, "Failed to add user to manager list: " + err); + self.fireHook('after_create', args); + }); + } + else { + self.storage.listUnshift('global/users', { username: username }, function (err) { + if (err) self.logError(1, "Failed to add user to manager list: " + err); + self.fireHook('after_create', args); + }); + } + } // new user + + }); // save session + }); // before_login + }); // save user + }); // user get + } // user is logged in + else { + // API must require a browser redirect, so pass back to client + // add our encoded self URL onto end of redirect URL + var url = json.location || json.Location; + url += encodeURIComponent(self.web.getSelfURL(args.request, '/')); + + self.logDebug(6, "Browser redirect required: " + url); + + callback({ code: 0, location: url }); + } + }); + }, + + sendEmail: function (name, args, callback) { + // send e-mail using template system and arg placeholders, if enabled + var self = this; + var emails = this.config.get('email_templates') || {}; + + if (emails[name]) { + // email is enabled + args.config = this.server.config.get(); + // generate mailer on the fly to catch config change + var mail = new Mailer( + this.config.get('smtp_hostname') || this.server.config.get('smtp_hostname') || "127.0.0.1", + this.config.get('smtp_port') || this.server.config.get('smtp_port') || 25 + ); + + mail.setOptions(this.server.config.get('mail_options')); + + mail.send(emails[name], args, function (err, data) { + if (err) self.logError('email', "Failed to send e-mail: " + err, { name: name, data: data }); + else self.logDebug(6, "Email sent successfully", { name: name, data: data }); + if (callback) callback(err); + }); + } + }, + + registerHook: function (name, callback) { + // register a function as a hook handler + name = name.toLowerCase(); + this.hooks[name] = callback; + }, + + fireHook: function (name, data, callback) { + // fire custom hook, allowing webapp to intercept and alter data or throw an error + name = name.toLowerCase(); + if (!callback) callback = function () { }; + + if (this.hooks[name]) { + this.hooks[name](data, callback); + } + else callback(null); + }, + + getClientInfo: function (args, params) { + // return client info object suitable for logging in the data column + if (!params) params = {}; + params.ip = args.ip; + params.headers = args.request.headers; + return params; + }, + + loadSession: function (args, callback) { + // make sure session is valid + var self = this; + var session_id = args.cookies['session_id'] || args.request.headers['x-session-id'] || args.params.session_id || args.query.session_id; + if (!session_id) return callback(new Error("No Session ID could be found")); + + this.storage.get('sessions/' + session_id, function (err, session) { + if (err) return callback(err, null); + + // also load user + self.storage.get('users/' + self.normalizeUsername(session.username), function (err, user) { + if (err) return callback(err, null); + + // get session_id out of args.params, so it doesn't interfere with API calls + delete args.params.session_id; + + // pass both session and user to callback + callback(null, session, user); + }); + }); + }, + + requireParams: function (params, rules, callback) { + // require params to exist and have length + assert(arguments.length == 3, "Wrong number of arguments to requireParams"); + for (var key in rules) { + var regexp = rules[key]; + if (typeof (params[key]) == 'undefined') { + this.doError('api', "Missing parameter: " + key, callback); + return false; + } + if (!params[key].toString().match(regexp)) { + this.doError('api', "Malformed parameter: " + key, callback); + return false; + } + } + return true; + }, + + doError: function (code, msg, callback) { + // log error and send api response + assert(arguments.length == 3, "Wrong number of arguments to doError"); + this.logError(code, msg); + callback({ code: code, description: msg }); + return false; + }, + + generatePasswordHash: function (password, salt) { + // generate crypto hash of password given plain password and salt string + if (this.config.get('use_bcrypt')) { + // use extremely secure but CPU expensive bcrypt algorithm + return bcrypt.hashSync(password + salt); + } + else { + // use weaker but fast salted SHA-256 algorithm + return Tools.digestHex(password + salt, 'sha256'); + } + }, + + comparePasswords: function (password, hash, salt) { + // compare passwords for login, given plaintext, pw hash and user salt + if (this.config.get('use_bcrypt')) { + // use extremely secure but CPU expensive bcrypt algorithm + return bcrypt.compareSync(password + salt, hash); + } + else { + // use weaker but fast salted SHA-256 algorithm + return (hash == this.generatePasswordHash(password, salt)); + } + }, + + shutdown: function (callback) { + // shutdown user service + callback(); + } + +}); diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..77edeeb --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1488 @@ +{ + "name": "Cronicle", + "version": "0.8.53", + "lockfileVersion": 1, + "requires": true, + "dependencies": { + "@kwsites/file-exists": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@kwsites/file-exists/-/file-exists-1.1.1.tgz", + "integrity": "sha512-m9/5YGR18lIwxSFDwfE3oA7bWuq9kdau6ugN4H2rJeyhFQZcG9AgSHkQtSD15a8WvTgfz9aikZMrKPHvbpqFiw==", + "requires": { + "debug": "^4.1.1" + }, + "dependencies": { + "debug": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.1.tgz", + "integrity": "sha512-doEwdvm4PCeK4K3RQN2ZC2BYUBaxwLARCqZmMjtF8a51J2Rb0xpVloFRnCODwqjpwnAoao4pelN8l3RJdv3gRQ==", + "requires": { + "ms": "2.1.2" + } + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + } + } + }, + "@kwsites/promise-deferred": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@kwsites/promise-deferred/-/promise-deferred-1.1.1.tgz", + "integrity": "sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw==" + }, + "abstract-logging": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz", + "integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==" + }, + "accepts": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.3.tgz", + "integrity": "sha1-w8p0NJOGSMPg2cHjKN1otiLChMo=", + "requires": { + "mime-types": "~2.1.11", + "negotiator": "0.6.1" + } + }, + "activedirectory2": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/activedirectory2/-/activedirectory2-2.1.0.tgz", + "integrity": "sha512-HaccG+/mf5NpHL1mAcLzXed4+gGlO6l7mkBi8vNIo6sTJvLoJjHgvJg12F4cy5CNcRqvPS48++s5tfdSiafn4Q==", + "requires": { + "abstract-logging": "^2.0.0", + "async": "^3.1.0", + "ldapjs": "^2.1.0", + "merge-options": "^2.0.0" + }, + "dependencies": { + "async": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.0.tgz", + "integrity": "sha512-TR2mEZFVOj2pLStYxLht7TyfuRzaydfpxr3k9RpHIzMgw7A64dzsdqCxH1WJyQdoe8T10nDXd9wnEigmiuHIZw==" + } + } + }, + "after": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/after/-/after-0.8.2.tgz", + "integrity": "sha1-/ts5T58OAqqXaOcCvaI7UF+ufh8=" + }, + "align-text": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/align-text/-/align-text-0.1.4.tgz", + "integrity": "sha1-DNkKVhCT810KmSVsIrcGlDP60Rc=", + "requires": { + "kind-of": "^3.0.2", + "longest": "^1.0.1", + "repeat-string": "^1.5.2" + } + }, + "ansi-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.0.tgz", + "integrity": "sha1-7QMXwyIGT3lGbAKWa922Bas32Zg=" + }, + "ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "requires": { + "color-convert": "^1.9.0" + }, + "dependencies": { + "color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "requires": { + "color-name": "1.1.3" + } + }, + "color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=" + } + } + }, + "any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha1-q8av7tzqUugJzcA3au0845Y10X8=" + }, + "approximate-now": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/approximate-now/-/approximate-now-1.0.0.tgz", + "integrity": "sha512-n8SkhunMoE1jYuC70sAof82NvFXhxHRSxQxk2Zl2+SpFraLE9rKkSMi+KumnXXukhPWe5iUa6cO4535ePtovQQ==" + }, + "array-filter": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/array-filter/-/array-filter-0.0.1.tgz", + "integrity": "sha1-fajPLiZijtcygDWB/SH2fKzS7uw=" + }, + "array-map": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/array-map/-/array-map-0.0.0.tgz", + "integrity": "sha1-iKK6tz0c97zVwbEYoAP2b2ZfpmI=" + }, + "array-reduce": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/array-reduce/-/array-reduce-0.0.0.tgz", + "integrity": "sha1-FziZ0//Rx9k4PkR5Ul2+J4yrXys=" + }, + "arraybuffer.slice": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/arraybuffer.slice/-/arraybuffer.slice-0.0.6.tgz", + "integrity": "sha1-8zshWfBTKj8xB6JywMz70a0peco=" + }, + "asn1": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.4.tgz", + "integrity": "sha512-jxwzQpLQjSmWXgwaCZE9Nz+glAG01yF1QnWgbhGwHI5A6FRIEY6IVqtHhIepHqI7/kyEyQEagBC5mBEFlIYvdg==", + "requires": { + "safer-buffer": "~2.1.0" + } + }, + "assert-plus": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", + "integrity": "sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU=" + }, + "async": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/async/-/async-2.6.0.tgz", + "integrity": "sha512-xAfGg1/NTLBBKlHFmnd7PlmUW9KhVQIUuSrYem9xzFUZy13ScvtyGGejaae9iAVRiRq9+Cx7DPFaAAhCpyxyPw==", + "requires": { + "lodash": "^4.14.0" + } + }, + "asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k=" + }, + "backo2": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/backo2/-/backo2-1.0.2.tgz", + "integrity": "sha1-MasayLEpNjRj41s+u2n038+6eUc=" + }, + "backoff": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/backoff/-/backoff-2.5.0.tgz", + "integrity": "sha1-9hbtqdPktmuMp/ynn2lXIsX44m8=", + "requires": { + "precond": "0.2" + } + }, + "balanced-match": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz", + "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=" + }, + "base64-arraybuffer": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-0.1.5.tgz", + "integrity": "sha1-c5JncZI7Whl0etZmqlzUv5xunOg=" + }, + "base64id": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/base64id/-/base64id-1.0.0.tgz", + "integrity": "sha1-R2iMuZu2gE8OBtPnY7HDLlfY5rY=" + }, + "bcrypt-node": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/bcrypt-node/-/bcrypt-node-0.1.0.tgz", + "integrity": "sha1-Ol3OQ1JwqEQq2BTmUq6+poN2YLk=" + }, + "better-assert": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/better-assert/-/better-assert-1.0.2.tgz", + "integrity": "sha1-QIZrnhueC1W0gYlDEeaPr/rrxSI=", + "requires": { + "callsite": "1.0.0" + } + }, + "blob": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/blob/-/blob-0.0.4.tgz", + "integrity": "sha1-vPEwUspURj8w+fx+lbmkdjCpSSE=" + }, + "brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "requires": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "callsite": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/callsite/-/callsite-1.0.0.tgz", + "integrity": "sha1-KAOY5dZkvXQDi28JBRU+borxvCA=" + }, + "camelcase": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-1.2.1.tgz", + "integrity": "sha1-m7UwTS4LVmmLLHWLCKPqqdqlijk=" + }, + "center-align": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/center-align/-/center-align-0.1.3.tgz", + "integrity": "sha1-qg0yYptu6XIgBBHL1EYckHvCt60=", + "requires": { + "align-text": "^0.1.3", + "lazy-cache": "^1.0.3" + } + }, + "chalk": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.1.tgz", + "integrity": "sha512-ObN6h1v2fTJSmUXoS3nMQ92LbDK9be4TV+6G+omQlGJFdcUX5heKi1LZ1YnRMIgwTLEj3E24bT6tYni50rlCfQ==", + "requires": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + } + }, + "chart.js": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-2.7.1.tgz", + "integrity": "sha512-pX1oQAY86MiuyZ2hY593Acbl4MLHKrBBhhmZ1YqSadzQbbsBE2rnd6WISoHjIsdf0WDeC0hbePYCz2ZxkV8L+g==", + "requires": { + "chartjs-color": "~2.2.0", + "moment": "~2.18.0" + }, + "dependencies": { + "moment": { + "version": "2.18.1", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.18.1.tgz", + "integrity": "sha1-w2GT3Tzhwu7SrbfIAtu8d6gbHA8=" + } + } + }, + "chartjs-color": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/chartjs-color/-/chartjs-color-2.2.0.tgz", + "integrity": "sha1-hKL7dVeH7YXDndbdjHsdiEKbrq4=", + "requires": { + "chartjs-color-string": "^0.5.0", + "color-convert": "^0.5.3" + } + }, + "chartjs-color-string": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/chartjs-color-string/-/chartjs-color-string-0.5.0.tgz", + "integrity": "sha512-amWNvCOXlOUYxZVDSa0YOab5K/lmEhbFNKI55PWc4mlv28BDzA7zaoQTGxSBgJMHIW+hGX8YUrvw/FH4LyhwSQ==", + "requires": { + "color-name": "^1.0.0" + } + }, + "class-plus": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/class-plus/-/class-plus-1.0.0.tgz", + "integrity": "sha512-k1a+aPxk1OfVs/+WOcV0LkrcCb5hsA6EKPQTEuLch7jQBLNavDt4tSEZyEYnBbYVkoBOFxdzdZ1aVvbv5VDIKA==" + }, + "cliui": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-2.1.0.tgz", + "integrity": "sha1-S0dXYP+AJkx2LDoXGQMukcf+oNE=", + "requires": { + "center-align": "^0.1.1", + "right-align": "^0.1.1", + "wordwrap": "0.0.2" + }, + "dependencies": { + "wordwrap": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-0.0.2.tgz", + "integrity": "sha1-t5Zpu0LstAn4PVg8rVLKF+qhZD8=" + } + } + }, + "codemirror": { + "version": "5.59.2", + "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-5.59.2.tgz", + "integrity": "sha512-/D5PcsKyzthtSy2NNKCyJi3b+htRkoKv3idswR/tR6UAvMNKA7SrmyZy6fOONJxSRs1JlUWEDAbxqfdArbK8iA==" + }, + "color-convert": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-0.5.3.tgz", + "integrity": "sha1-vbbGnOZg+t/+CwAHzER+G59ygr0=" + }, + "color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + }, + "colors": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/colors/-/colors-0.5.1.tgz", + "integrity": "sha1-fQAj6usVTo7p/Oddy5I9DtFmd3Q=" + }, + "combined-stream": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.6.tgz", + "integrity": "sha1-cj599ugBrFYTETp+RFqbactjKBg=", + "requires": { + "delayed-stream": "~1.0.0" + } + }, + "component-bind": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/component-bind/-/component-bind-1.0.0.tgz", + "integrity": "sha1-AMYIq33Nk4l8AAllGx06jh5zu9E=" + }, + "component-emitter": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.1.2.tgz", + "integrity": "sha1-KWWU8nU9qmOZbSrwjRWpURbJrsM=" + }, + "component-inherit": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/component-inherit/-/component-inherit-0.0.3.tgz", + "integrity": "sha1-ZF/ErfWLcrZJ1crmUTVhnbJv8UM=" + }, + "concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=" + }, + "cookie": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.3.1.tgz", + "integrity": "sha1-5+Ch+e9DtMi6klxcWpboBtFoc7s=" + }, + "core-util-is": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", + "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=" + }, + "daemon": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/daemon/-/daemon-1.1.0.tgz", + "integrity": "sha1-bFECyB2wvoVvyQCPwsk1s5iGSug=" + }, + "debug": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.3.3.tgz", + "integrity": "sha1-QMRT5n5uE8kB3ewxeviYbNqe/4w=", + "requires": { + "ms": "0.7.2" + } + }, + "decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha1-9lNNFRSCabIDUue+4m9QH5oZEpA=" + }, + "delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk=" + }, + "dotenv": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-8.2.0.tgz", + "integrity": "sha512-8sJ78ElpbDJBHNeBzUbUVLsqKdccaa/BXF1uPTw3GrvQTBgrQrtObr2mUrE38vzYd8cEv+m/JBfDLioYcfXoaw==" + }, + "drange": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/drange/-/drange-1.1.1.tgz", + "integrity": "sha512-pYxfDYpued//QpnLIm4Avk7rsNtAtQkUES2cwAYSvD/wd2pKD71gN2Ebj3e7klzXwjocvE8c5vx/1fxwpqmSxA==" + }, + "engine.io": { + "version": "1.8.3", + "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-1.8.3.tgz", + "integrity": "sha1-jef5eJXSDTm4X4ju7nd7K9QrE9Q=", + "requires": { + "accepts": "1.3.3", + "base64id": "1.0.0", + "cookie": "0.3.1", + "debug": "2.3.3", + "engine.io-parser": "1.3.2", + "ws": "1.1.2" + } + }, + "engine.io-client": { + "version": "1.8.3", + "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-1.8.3.tgz", + "integrity": "sha1-F5jtk0USRkU9TG9jXXogH+lA1as=", + "requires": { + "component-emitter": "1.2.1", + "component-inherit": "0.0.3", + "debug": "2.3.3", + "engine.io-parser": "1.3.2", + "has-cors": "1.1.0", + "indexof": "0.0.1", + "parsejson": "0.0.3", + "parseqs": "0.0.5", + "parseuri": "0.0.5", + "ws": "1.1.2", + "xmlhttprequest-ssl": "1.5.3", + "yeast": "0.1.2" + }, + "dependencies": { + "component-emitter": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.2.1.tgz", + "integrity": "sha1-E3kY1teCg/ffemt8WmPhQOaUJeY=" + } + } + }, + "engine.io-parser": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-1.3.2.tgz", + "integrity": "sha1-k3sHnwAH0Ik+xW1GyyILjLQ1Igo=", + "requires": { + "after": "0.8.2", + "arraybuffer.slice": "0.0.6", + "base64-arraybuffer": "0.1.5", + "blob": "0.0.4", + "has-binary": "0.1.7", + "wtf-8": "1.0.0" + } + }, + "errno": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/errno/-/errno-0.1.7.tgz", + "integrity": "sha512-MfrRBDWzIWifgq6tJj60gkAwtLNb6sQPlcFrSOflcP1aFmmruKQ2wRnze/8V6kgyz7H3FF8Npzv78mZ7XLLflg==", + "requires": { + "prr": "~1.0.1" + } + }, + "escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=" + }, + "extsprintf": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.4.0.tgz", + "integrity": "sha1-4mifjzVvrWLMplo6kcXfX5VRaS8=" + }, + "font-awesome": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/font-awesome/-/font-awesome-4.7.0.tgz", + "integrity": "sha1-j6jPBBGhoxr9B7BtKQK7n8gVoTM=" + }, + "form-data": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.3.2.tgz", + "integrity": "sha1-SXBJi+YEwgwAXU9cI67NIda0kJk=", + "requires": { + "asynckit": "^0.4.0", + "combined-stream": "1.0.6", + "mime-types": "^2.1.12" + } + }, + "formidable": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-1.2.1.tgz", + "integrity": "sha512-Fs9VRguL0gqGHkXS5GQiMCr1VhZBxz0JnJs4JmMp/2jL18Fmbzvv7vOFRU+U8TBkHEE/CX1qDXzJplVULgsLeg==" + }, + "fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=" + }, + "glob": { + "version": "5.0.15", + "resolved": "https://registry.npmjs.org/glob/-/glob-5.0.15.tgz", + "integrity": "sha1-G8k2ueAvSmA/zCIuz3Yz0wuLk7E=", + "requires": { + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "2 || 3", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + } + }, + "has-binary": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/has-binary/-/has-binary-0.1.7.tgz", + "integrity": "sha1-aOYesWIQyVRaClzOBqhzkS/h5ow=", + "requires": { + "isarray": "0.0.1" + }, + "dependencies": { + "isarray": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", + "integrity": "sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8=" + } + } + }, + "has-cors": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-cors/-/has-cors-1.1.0.tgz", + "integrity": "sha1-XkdHk/fqmEPRu5nCPu9J/xJv/zk=" + }, + "has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=" + }, + "he": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/he/-/he-1.1.1.tgz", + "integrity": "sha1-k0EP0hsAlzUVH4howvJx80J+I/0=" + }, + "indexof": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/indexof/-/indexof-0.0.1.tgz", + "integrity": "sha1-gtwzbSMrkGIXnQWrMpOmYFn9Q10=" + }, + "inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=", + "requires": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + }, + "ipaddr.js": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.8.1.tgz", + "integrity": "sha1-+kt5+kf9Pe9eOxWYJRYcClGclCc=" + }, + "is-buffer": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", + "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==" + }, + "is-fullwidth-code-point": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", + "integrity": "sha1-o7MKXE8ZkYMWeqq5O+764937ZU8=" + }, + "is-plain-obj": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz", + "integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==" + }, + "isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=" + }, + "jquery": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/jquery/-/jquery-3.3.1.tgz", + "integrity": "sha512-Ubldcmxp5np52/ENotGxlLe6aGMvmF4R8S6tZjsP6Knsaxd/xp3Zrh50cG93lR6nPXyUFwzN3ZSOQI0wRJNdGg==" + }, + "json3": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/json3/-/json3-3.3.2.tgz", + "integrity": "sha1-PAQ0dD35Pi9cQq7nsZvLSDV19OE=" + }, + "jsonify": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/jsonify/-/jsonify-0.0.0.tgz", + "integrity": "sha1-LHS27kHZPKUbe1qu6PUDYx0lKnM=" + }, + "jstimezonedetect": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/jstimezonedetect/-/jstimezonedetect-1.0.6.tgz", + "integrity": "sha1-YNMfus2/V69xsGSIfuBA8Vla21I=" + }, + "kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", + "requires": { + "is-buffer": "^1.1.5" + } + }, + "lazy-cache": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/lazy-cache/-/lazy-cache-1.0.4.tgz", + "integrity": "sha1-odePw6UEdMuAhF07O24dpJpEbo4=" + }, + "ldap-filter": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/ldap-filter/-/ldap-filter-0.3.3.tgz", + "integrity": "sha1-KxTGiiqdQQTb28kQocqF/Riel5c=", + "requires": { + "assert-plus": "^1.0.0" + } + }, + "ldapjs": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/ldapjs/-/ldapjs-2.2.3.tgz", + "integrity": "sha512-143MayI+cSo1PEngge0HMVj3Fb0TneX4Qp9yl9bKs45qND3G64B75GMSxtZCfNuVsvg833aOp1UWG8peFu1LrQ==", + "requires": { + "abstract-logging": "^2.0.0", + "asn1": "^0.2.4", + "assert-plus": "^1.0.0", + "backoff": "^2.5.0", + "ldap-filter": "^0.3.3", + "once": "^1.4.0", + "vasync": "^2.2.0", + "verror": "^1.8.1" + } + }, + "lodash": { + "version": "4.17.19", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.19.tgz", + "integrity": "sha512-JNvd8XER9GQX0v2qJgsaN/mzFCNA5BRe/j8JN9d+tWyGLSodKQHKFicdwNYzWwI3wjRnaKPsGj1XkBjx/F96DQ==" + }, + "longest": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/longest/-/longest-1.0.1.tgz", + "integrity": "sha1-MKCy2jj3N3DoKUoNIuZiXtd9AJc=" + }, + "mdi": { + "version": "1.9.33", + "resolved": "https://registry.npmjs.org/mdi/-/mdi-1.9.33.tgz", + "integrity": "sha1-PK9tlfxrgAYzYwvWK6DPH73msuI=" + }, + "merge-options": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-options/-/merge-options-2.0.0.tgz", + "integrity": "sha512-S7xYIeWHl2ZUKF7SDeBhGg6rfv5bKxVBdk95s/I7wVF8d+hjLSztJ/B271cnUiF6CAFduEQ5Zn3HYwAjT16DlQ==", + "requires": { + "is-plain-obj": "^2.0.0" + } + }, + "mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==" + }, + "mime-db": { + "version": "1.44.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.44.0.tgz", + "integrity": "sha512-/NOTfLrsPBVeH7YtFPgsVWveuL+4SjjYxaQ1xtM1KMFj7HdxlBlxeyNLzhyJVx7r4rZGJAZ/6lkKCitSc/Nmpg==" + }, + "mime-types": { + "version": "2.1.27", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.27.tgz", + "integrity": "sha512-JIhqnCasI9yD+SsmkquHBxTSEuZdQX5BuQnS2Vc7puQQQ+8yiP5AY5uWhpdv4YL4VM5c6iliiYWPgJ/nJQLp7w==", + "requires": { + "mime-db": "1.44.0" + } + }, + "minimatch": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", + "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==", + "requires": { + "brace-expansion": "^1.1.7" + } + }, + "minimist": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz", + "integrity": "sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0=" + }, + "mkdirp": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz", + "integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=", + "requires": { + "minimist": "0.0.8" + } + }, + "moment": { + "version": "2.22.1", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.22.1.tgz", + "integrity": "sha512-shJkRTSebXvsVqk56I+lkb2latjBs8I+pc2TzWc545y2iFnSjm7Wg0QMh+ZWcdSLQyGEau5jI8ocnmkyTgr9YQ==" + }, + "moment-timezone": { + "version": "0.5.32", + "resolved": "https://registry.npmjs.org/moment-timezone/-/moment-timezone-0.5.32.tgz", + "integrity": "sha512-Z8QNyuQHQAmWucp8Knmgei8YNo28aLjJq6Ma+jy1ZSpSk5nyfRT8xgUbSQvD2+2UajISfenndwvFuH3NGS+nvA==", + "requires": { + "moment": ">= 2.9.0" + } + }, + "moo": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/moo/-/moo-0.4.3.tgz", + "integrity": "sha512-gFD2xGCl8YFgGHsqJ9NKRVdwlioeW3mI1iqfLNYQOv0+6JRwG58Zk9DIGQgyIaffSYaO1xsKnMaYzzNr1KyIAw==" + }, + "ms": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-0.7.2.tgz", + "integrity": "sha1-riXPJRKziFodldfwN4aNhDESR2U=" + }, + "mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "requires": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "nearley": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/nearley/-/nearley-2.11.0.tgz", + "integrity": "sha512-clqqhEuP0ZCJQ85Xv2I/4o2Gs/fvSR6fCg5ZHVE2c8evWyNk2G++ih4JOO3lMb/k/09x6ihQ2nzKUlB/APCWjg==", + "requires": { + "nomnom": "~1.6.2", + "railroad-diagrams": "^1.0.0", + "randexp": "^0.4.2" + } + }, + "negotiator": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.1.tgz", + "integrity": "sha1-KzJxhOiZIQEXeyhWP7XnECrNDKk=" + }, + "netmask": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/netmask/-/netmask-1.0.6.tgz", + "integrity": "sha1-ICl+idhvb2QA8lDZ9Pa0wZRfzTU=" + }, + "node-static": { + "version": "0.7.10", + "resolved": "https://registry.npmjs.org/node-static/-/node-static-0.7.10.tgz", + "integrity": "sha512-bd7zO5hvCWzdglgwz9t82T4mYTEUzEG5pXnSqEzitvmEacusbhl8/VwuCbMaYR9g2PNK5191yBtAEQLJEmQh1A==", + "requires": { + "colors": ">=0.6.0", + "mime": "^1.2.9", + "optimist": ">=0.3.4" + }, + "dependencies": { + "colors": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/colors/-/colors-1.4.0.tgz", + "integrity": "sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA==" + } + } + }, + "nodemailer": { + "version": "6.4.11", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.4.11.tgz", + "integrity": "sha512-BVZBDi+aJV4O38rxsUh164Dk1NCqgh6Cm0rQSb9SK/DHGll/DrCMnycVDD7msJgZCnmVa8ASo8EZzR7jsgTukQ==" + }, + "nomnom": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/nomnom/-/nomnom-1.6.2.tgz", + "integrity": "sha1-hKZqJgF0QI/Ft3oY+IjszET7aXE=", + "requires": { + "colors": "0.5.x", + "underscore": "~1.4.4" + } + }, + "object-assign": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.0.tgz", + "integrity": "sha1-ejs9DpgGPUP0wD8uiubNUahog6A=" + }, + "object-component": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/object-component/-/object-component-0.0.3.tgz", + "integrity": "sha1-8MaapQ78lbhmwYb0AKM3acsvEpE=" + }, + "once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", + "requires": { + "wrappy": "1" + } + }, + "optimist": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/optimist/-/optimist-0.6.1.tgz", + "integrity": "sha1-2j6nRob6IaGaERwybpDrFaAZZoY=", + "requires": { + "minimist": "~0.0.1", + "wordwrap": "~0.0.2" + } + }, + "options": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/options/-/options-0.0.6.tgz", + "integrity": "sha1-7CLTEoBrtT5zF3Pnza788cZDEo8=" + }, + "parsejson": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/parsejson/-/parsejson-0.0.3.tgz", + "integrity": "sha1-q343WfIJ7OmUN5c/fQ8fZK4OZKs=", + "requires": { + "better-assert": "~1.0.0" + } + }, + "parseqs": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/parseqs/-/parseqs-0.0.5.tgz", + "integrity": "sha1-1SCKNzjkZ2bikbouoXNoSSGouJ0=", + "requires": { + "better-assert": "~1.0.0" + } + }, + "parseuri": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/parseuri/-/parseuri-0.0.5.tgz", + "integrity": "sha1-gCBKUNTbt3m/3G6+J3jZDkvOMgo=", + "requires": { + "better-assert": "~1.0.0" + } + }, + "path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=" + }, + "pixl-acl": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pixl-acl/-/pixl-acl-1.0.1.tgz", + "integrity": "sha512-IXcZpwU3O6fBSiYIw8h+ePb0r/zbooaZtAZ9+HzPeMyQpxF4HpTXznIQdZ8pSTQlHf1191x+8vdEV5bJr42tdQ==", + "requires": { + "ipaddr.js": "1.8.1" + } + }, + "pixl-args": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/pixl-args/-/pixl-args-1.0.3.tgz", + "integrity": "sha1-Y8CmepIfuxsoXMylGSpJkQ/tozQ=", + "requires": { + "pixl-class": ">=1.0.0" + } + }, + "pixl-cache": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/pixl-cache/-/pixl-cache-1.0.6.tgz", + "integrity": "sha512-hUjTuSyJ+IHsz7rWdhu9QyD2955GL9k0vqb/kJDH9KQtKcRPc8HsmqVZoeFkkuj+miSELD6XfHsE8Gtm71TnfA==" + }, + "pixl-class": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/pixl-class/-/pixl-class-1.0.3.tgz", + "integrity": "sha512-YS3MS4IuBNvy1gAuMJtQiZPMdmRjK2EwqomJNUG3PJtCz5bbG5MWgY7xV780jx/2oIj/IHNYGyZmfqQRs0KTZw==" + }, + "pixl-cli": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/pixl-cli/-/pixl-cli-1.0.12.tgz", + "integrity": "sha512-fT/iKIOqsJQgY8RCrDHXgA9kdt+inVwllL4FlhMrkmsBJNWnbSS7+DwJ2RNnmQ9VpNBGjJhHK1uDF6SApn/i8Q==", + "requires": { + "chalk": "2.4.1", + "pixl-args": "^1.0.0", + "pixl-class": "^1.0.0", + "pixl-tools": "^1.0.0", + "repeating": "3.0.0", + "string-width": "2.1.1", + "widest-line": "2.0.0", + "word-wrap": "1.2.3" + } + }, + "pixl-config": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/pixl-config/-/pixl-config-1.0.12.tgz", + "integrity": "sha512-41DO/LjAArkbI9exzfelKnrDezP+Nmkylea4Bt3CK4NMrjEhfM8rWlqsbD3dtwGdLimQfYUOE/p2bPs3Ii8N7g==", + "requires": { + "pixl-args": "^1.0.0", + "pixl-class": "^1.0.0", + "pixl-tools": "^1.0.0" + } + }, + "pixl-json-stream": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/pixl-json-stream/-/pixl-json-stream-1.0.6.tgz", + "integrity": "sha512-G4mcIWyKdovBQpChsw307crSnucbUGDQ1s9RgSODy+foDdIcvs19ezMbTkoHHoJNSeR4DPFbJ0kZGhFdPElbxg==", + "requires": { + "pixl-class": "^1.0.0" + } + }, + "pixl-logger": { + "version": "1.0.20", + "resolved": "https://registry.npmjs.org/pixl-logger/-/pixl-logger-1.0.20.tgz", + "integrity": "sha512-Sf/Xb+IHp1nJ0zNDMgi/qRfA11jrSavoS4+42MvPMs8Y2+JZtb2jq0SAQ90Hh4GkDkHlQiabVWU5Z1tQ0hhvCQ==", + "requires": { + "chalk": "2.4.1", + "class-plus": "^1.0.0", + "pixl-tools": "^1.0.0" + } + }, + "pixl-mail": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/pixl-mail/-/pixl-mail-1.0.10.tgz", + "integrity": "sha512-w4uDDOMeGCbmdn19E4nc8sCGPyQh2n/hmUhcuRq8G9XNHXuhhLRGt2AJPi/bYSDlFaWDIlFgJFAiZaOqeT0zNQ==", + "requires": { + "nodemailer": "6.4.11", + "pixl-class": "^1.0.0", + "pixl-tools": "^1.0.19" + } + }, + "pixl-perf": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/pixl-perf/-/pixl-perf-1.0.7.tgz", + "integrity": "sha512-V0r15m/obM6Fe1cpFLw+CDyCNfN+9Q1J0ua+QBQhX+EWR0MqKotbAV6aGJ2i4wphK+73+UPV/5xYxJTdoHdBag==", + "requires": { + "pixl-class": "^1.0.0" + } + }, + "pixl-request": { + "version": "1.0.35", + "resolved": "https://registry.npmjs.org/pixl-request/-/pixl-request-1.0.35.tgz", + "integrity": "sha512-hUhgnLesj/qGDoKI2EJ22RU+fnWokojNZj2ZgUmKdFdi7S89/Qyjy6gjMJr+zLC3XPVe0sUNFjaB8zZsxJ47VA==", + "requires": { + "errno": "0.1.7", + "form-data": "2.3.2", + "pixl-class": "^1.0.0", + "pixl-perf": "^1.0.0", + "pixl-xml": "^1.0.0" + } + }, + "pixl-server": { + "version": "1.0.28", + "resolved": "https://registry.npmjs.org/pixl-server/-/pixl-server-1.0.28.tgz", + "integrity": "sha512-AotcwllSbEUr2WCHM2IPRw7HVuU8InN/5s6bwYy7OMOsdI6RiLkraYGbXZU9jSUWdjG6rwGy2JP2tE0dsloJpQ==", + "requires": { + "async": "2.6.1", + "daemon": "1.1.0", + "mkdirp": "0.5.1", + "pixl-args": "^1.0.3", + "pixl-class": "^1.0.2", + "pixl-config": "^1.0.8", + "pixl-logger": "^2.0.0", + "pixl-tools": "^1.0.19", + "uncatch": "^1.0.0" + }, + "dependencies": { + "async": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/async/-/async-2.6.1.tgz", + "integrity": "sha512-fNEiL2+AZt6AlAw/29Cr0UDe4sRAHCpEHh54WMz+Bb7QfNcFw4h3loofyJpLeQs4Yx7yuqu/2dLgM5hKOs6HlQ==", + "requires": { + "lodash": "^4.17.10" + } + }, + "pixl-logger": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/pixl-logger/-/pixl-logger-2.0.2.tgz", + "integrity": "sha512-7AvHREGUkKOnPk7j+p0RzrzLX/Jt65i3F4XBoHQcHwSxnRjc8Obuv8nzuEuxCAzj0AjgBcJnqx4ZAg8pi+iXRA==", + "requires": { + "approximate-now": "1.0.0", + "chalk": "2.4.1", + "class-plus": "^1.0.0", + "pixl-tools": "^1.0.0", + "uncatch": "^1.0.0" + } + } + } + }, + "pixl-server-api": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/pixl-server-api/-/pixl-server-api-1.0.2.tgz", + "integrity": "sha512-GFkfVJQ8yxqVzKpShIsfjP2FJJEV0hlcgybyqboKtC2QAs+swENQFh5x4MN4FSI5AKtAzN2HZ44vhQYm+hcGoQ==", + "requires": { + "pixl-class": "^1.0.0", + "pixl-server": "^1.0.0", + "pixl-tools": "^1.0.0" + } + }, + "pixl-server-storage": { + "version": "2.0.20", + "resolved": "https://registry.npmjs.org/pixl-server-storage/-/pixl-server-storage-2.0.20.tgz", + "integrity": "sha512-XduwyXu/p82dUCxxz3ZE94ueVeGRLRJSsPFvXkbwd4qswf8j5IAf/Iq0hnkcRVPsYZm2+Jr3cyZjAIkOR39UCA==", + "requires": { + "async": "2.6.0", + "he": "1.1.1", + "mkdirp": "0.5.1", + "moo": "0.4.3", + "nearley": "2.11.0", + "pixl-cache": "^1.0.1", + "pixl-class": "^1.0.0", + "pixl-perf": "^1.0.0", + "pixl-server": "^1.0.0", + "pixl-tools": "^1.0.0", + "porter-stemmer": "0.9.1", + "unidecode": "0.1.8" + } + }, + "pixl-server-web": { + "version": "1.1.36", + "resolved": "https://registry.npmjs.org/pixl-server-web/-/pixl-server-web-1.1.36.tgz", + "integrity": "sha512-MLA7Df555HhOMZaSZKUheqMp64bp9XF5zc3pw3rSTcoZurY9gO+Y2iUuxSbAeGL+GBMFrmeFBLdIuImio0xxBQ==", + "requires": { + "async": "2.6.0", + "errno": "0.1.7", + "formidable": "1.2.1", + "node-static": "0.7.10", + "pixl-acl": "1.0.1", + "pixl-class": "^1.0.0", + "pixl-perf": "^1.0.0", + "pixl-server": "^1.0.0", + "stream-meter": "1.0.4" + } + }, + "pixl-tools": { + "version": "1.0.28", + "resolved": "https://registry.npmjs.org/pixl-tools/-/pixl-tools-1.0.28.tgz", + "integrity": "sha512-Kw14VFyMXl0KZ2RJdBTzBuMFXh3OfuB/sV1FV0bsrt4U82h+ovoeNLP7X6vj56Peu09w9WMh+3wwxTvhrx2JRQ==", + "requires": { + "async": "2.6.1", + "errno": "0.1.7", + "glob": "5.0.15", + "rimraf": "2.6.3" + }, + "dependencies": { + "async": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/async/-/async-2.6.1.tgz", + "integrity": "sha512-fNEiL2+AZt6AlAw/29Cr0UDe4sRAHCpEHh54WMz+Bb7QfNcFw4h3loofyJpLeQs4Yx7yuqu/2dLgM5hKOs6HlQ==", + "requires": { + "lodash": "^4.17.10" + } + } + } + }, + "pixl-unit": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pixl-unit/-/pixl-unit-1.0.11.tgz", + "integrity": "sha512-L3Gx8v3rlp+YCA3DXwWx6qIExT5aQ5qbv788MdUi6i7qUub9YNDrjDNHi+AZnJ7kcTmnnfeLPVDNLIkGf/oiww==", + "dev": true, + "requires": { + "async": "2.6.0", + "chalk": "2.4.1", + "pixl-args": "^1.0.0", + "pixl-cli": "^1.0.0", + "pixl-tools": "^1.0.0" + } + }, + "pixl-webapp": { + "version": "1.0.17", + "resolved": "https://registry.npmjs.org/pixl-webapp/-/pixl-webapp-1.0.17.tgz", + "integrity": "sha512-cZL8E68wcuYrLKq1T5w2IVeFnfUHLPgFk43/T2bL+p43Btz3giJAi4PykjcuHEP881WlrrSUujQomBc7hky7DQ==" + }, + "pixl-xml": { + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/pixl-xml/-/pixl-xml-1.0.13.tgz", + "integrity": "sha1-W6cUCL3zeZTCOd0BJRwQxTwFh+M=" + }, + "porter-stemmer": { + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/porter-stemmer/-/porter-stemmer-0.9.1.tgz", + "integrity": "sha1-oW7Oo6vkRySsiMFIACHqNrqiH5s=" + }, + "precond": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/precond/-/precond-0.2.3.tgz", + "integrity": "sha1-qpWRvKokkj8eD0hJ0kD0fvwQdaw=" + }, + "process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==" + }, + "prr": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/prr/-/prr-1.0.1.tgz", + "integrity": "sha1-0/wRS6BplaRexok/SEzrHXj19HY=" + }, + "railroad-diagrams": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/railroad-diagrams/-/railroad-diagrams-1.0.0.tgz", + "integrity": "sha1-635iZ1SN3t+4mcG5Dlc3RVnN234=" + }, + "randexp": { + "version": "0.4.9", + "resolved": "https://registry.npmjs.org/randexp/-/randexp-0.4.9.tgz", + "integrity": "sha512-maAX1cnBkzIZ89O4tSQUOF098xjGMC8N+9vuY/WfHwg87THw6odD2Br35donlj5e6KnB1SB0QBHhTQhhDHuTPQ==", + "requires": { + "drange": "^1.0.0", + "ret": "^0.2.0" + } + }, + "read-last-lines": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/read-last-lines/-/read-last-lines-1.8.0.tgz", + "integrity": "sha512-oPL0cnZkhsO2xF7DBrdzVhXSNajPP5TzzCim/2IAjeGb17ArLLTRriI/ceV6Rook3L27mvbrOvLlf9xYYnaftQ==", + "requires": { + "mz": "^2.7.0" + } + }, + "readable-stream": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz", + "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==", + "requires": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "repeat-string": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/repeat-string/-/repeat-string-1.6.1.tgz", + "integrity": "sha1-jcrkcOHIirwtYA//Sndihtp15jc=" + }, + "repeating": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/repeating/-/repeating-3.0.0.tgz", + "integrity": "sha1-9MN2/dIBV2H2+W9DA7EiTVgegC8=" + }, + "ret": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/ret/-/ret-0.2.2.tgz", + "integrity": "sha512-M0b3YWQs7R3Z917WRQy1HHA7Ba7D8hvZg6UE5mLykJxQVE2ju0IXbGlaHPPlkY+WN7wFP+wUMXmBFA0aV6vYGQ==" + }, + "right-align": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/right-align/-/right-align-0.1.3.tgz", + "integrity": "sha1-YTObci/mo1FWiSENJOFMlhSGE+8=", + "requires": { + "align-text": "^0.1.1" + } + }, + "rimraf": { + "version": "2.6.3", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.6.3.tgz", + "integrity": "sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==", + "requires": { + "glob": "^7.1.3" + }, + "dependencies": { + "glob": { + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz", + "integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==", + "requires": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + } + } + } + }, + "safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" + }, + "safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" + }, + "shell-quote": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.6.1.tgz", + "integrity": "sha1-9HgZSczkAmlxJ0MOo7PFR29IF2c=", + "requires": { + "array-filter": "~0.0.0", + "array-map": "~0.0.0", + "array-reduce": "~0.0.0", + "jsonify": "~0.0.0" + } + }, + "simple-git": { + "version": "2.31.0", + "resolved": "https://registry.npmjs.org/simple-git/-/simple-git-2.31.0.tgz", + "integrity": "sha512-/+rmE7dYZMbRAfEmn8EUIOwlM2G7UdzpkC60KF86YAfXGnmGtsPrKsym0hKvLBdFLLW019C+aZld1+6iIVy5xA==", + "requires": { + "@kwsites/file-exists": "^1.1.1", + "@kwsites/promise-deferred": "^1.1.1", + "debug": "^4.3.1" + }, + "dependencies": { + "debug": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.1.tgz", + "integrity": "sha512-doEwdvm4PCeK4K3RQN2ZC2BYUBaxwLARCqZmMjtF8a51J2Rb0xpVloFRnCODwqjpwnAoao4pelN8l3RJdv3gRQ==", + "requires": { + "ms": "2.1.2" + } + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + } + } + }, + "socket.io": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-1.7.3.tgz", + "integrity": "sha1-uK+cq6AJSeVo42nxMn6pvp6iRhs=", + "requires": { + "debug": "2.3.3", + "engine.io": "1.8.3", + "has-binary": "0.1.7", + "object-assign": "4.1.0", + "socket.io-adapter": "0.5.0", + "socket.io-client": "1.7.3", + "socket.io-parser": "2.3.1" + } + }, + "socket.io-adapter": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-0.5.0.tgz", + "integrity": "sha1-y21LuL7IHhB4uZZ3+c7QBGBmu4s=", + "requires": { + "debug": "2.3.3", + "socket.io-parser": "2.3.1" + } + }, + "socket.io-client": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-1.7.3.tgz", + "integrity": "sha1-sw6GqhDV7zVGYBwJzeR2Xjgdo3c=", + "requires": { + "backo2": "1.0.2", + "component-bind": "1.0.0", + "component-emitter": "1.2.1", + "debug": "2.3.3", + "engine.io-client": "1.8.3", + "has-binary": "0.1.7", + "indexof": "0.0.1", + "object-component": "0.0.3", + "parseuri": "0.0.5", + "socket.io-parser": "2.3.1", + "to-array": "0.1.4" + }, + "dependencies": { + "component-emitter": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.2.1.tgz", + "integrity": "sha1-E3kY1teCg/ffemt8WmPhQOaUJeY=" + } + } + }, + "socket.io-parser": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-2.3.1.tgz", + "integrity": "sha1-3VMgJRA85Clpcya+/WQAX8/ltKA=", + "requires": { + "component-emitter": "1.1.2", + "debug": "2.2.0", + "isarray": "0.0.1", + "json3": "3.3.2" + }, + "dependencies": { + "debug": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.2.0.tgz", + "integrity": "sha1-+HBX6ZWxofauaklgZkE3vFbwOdo=", + "requires": { + "ms": "0.7.1" + } + }, + "isarray": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", + "integrity": "sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8=" + }, + "ms": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/ms/-/ms-0.7.1.tgz", + "integrity": "sha1-nNE8A62/8ltl7/3nzoZO6VIBcJg=" + } + } + }, + "source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w=" + }, + "stream-meter": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/stream-meter/-/stream-meter-1.0.4.tgz", + "integrity": "sha1-Uq+Vql6nYKJJFxZwTb/5D3Ov3R0=", + "requires": { + "readable-stream": "^2.1.4" + } + }, + "string-width": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-2.1.1.tgz", + "integrity": "sha512-nOqH59deCq9SRHlxq1Aw85Jnt4w6KvLKqWVik6oA9ZklXLNIOlqg4F2yrT1MVaTjAqvVwdfeZ7w7aCvJD7ugkw==", + "requires": { + "is-fullwidth-code-point": "^2.0.0", + "strip-ansi": "^4.0.0" + } + }, + "string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "requires": { + "safe-buffer": "~5.1.0" + } + }, + "strip-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-4.0.0.tgz", + "integrity": "sha1-qEeQIusaw2iocTibY1JixQXuNo8=", + "requires": { + "ansi-regex": "^3.0.0" + } + }, + "supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "requires": { + "has-flag": "^3.0.0" + } + }, + "thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "requires": { + "any-promise": "^1.0.0" + } + }, + "thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha1-GhkY1ALY/D+Y+/I02wvMjMEOlyY=", + "requires": { + "thenify": ">= 3.1.0 < 4" + } + }, + "to-array": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/to-array/-/to-array-0.1.4.tgz", + "integrity": "sha1-F+bBH3PdTz10zaek/zI46a2b+JA=" + }, + "uglify-js": { + "version": "2.8.22", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-2.8.22.tgz", + "integrity": "sha1-1Uk0d4qNoUkD+imjJvskwKtRoaA=", + "requires": { + "source-map": "~0.5.1", + "uglify-to-browserify": "~1.0.0", + "yargs": "~3.10.0" + } + }, + "uglify-to-browserify": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/uglify-to-browserify/-/uglify-to-browserify-1.0.2.tgz", + "integrity": "sha1-bgkk1r2mta/jSeOabWMoUKD4grc=", + "optional": true + }, + "ultron": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/ultron/-/ultron-1.0.2.tgz", + "integrity": "sha1-rOEWq1V80Zc4ak6I9GhTeMiy5Po=" + }, + "uncatch": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/uncatch/-/uncatch-1.0.2.tgz", + "integrity": "sha1-Xw0CzFhLgNbLo8kmqylwMFC7d2o=" + }, + "underscore": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.4.4.tgz", + "integrity": "sha1-YaajIBBiKvoHljvzJSA88SI51gQ=" + }, + "unidecode": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/unidecode/-/unidecode-0.1.8.tgz", + "integrity": "sha1-77swFTi8RSRqmsjFWdcvAVMFBT4=" + }, + "util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=" + }, + "vasync": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/vasync/-/vasync-2.2.0.tgz", + "integrity": "sha1-z951GGChWCLbOxMrxZsRakra8Bs=", + "requires": { + "verror": "1.10.0" + } + }, + "verror": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz", + "integrity": "sha1-OhBcoXBTr1XW4nDB+CiGguGNpAA=", + "requires": { + "assert-plus": "^1.0.0", + "core-util-is": "1.0.2", + "extsprintf": "^1.2.0" + } + }, + "widest-line": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-2.0.0.tgz", + "integrity": "sha1-AUKk6KJD+IgsAjOqDgKBqnYVInM=", + "requires": { + "string-width": "^2.1.1" + } + }, + "window-size": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/window-size/-/window-size-0.1.0.tgz", + "integrity": "sha1-VDjNLqk7IC76Ohn+iIeu58lPnJ0=" + }, + "word-wrap": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz", + "integrity": "sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==" + }, + "wordwrap": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-0.0.3.tgz", + "integrity": "sha1-o9XabNXAvAAI03I0u68b7WMFkQc=" + }, + "wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=" + }, + "ws": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/ws/-/ws-1.1.2.tgz", + "integrity": "sha1-iiRPoFJAHgjJiGz0SoUYnh/UBn8=", + "requires": { + "options": ">=0.0.5", + "ultron": "1.0.x" + } + }, + "wtf-8": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/wtf-8/-/wtf-8-1.0.0.tgz", + "integrity": "sha1-OS2LotDxw00e4tYw8V0O+2jhBIo=" + }, + "xmlhttprequest-ssl": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-1.5.3.tgz", + "integrity": "sha1-GFqIjATspGw+QHDZn3tJ3jUomS0=" + }, + "yargs": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-3.10.0.tgz", + "integrity": "sha1-9+572FfdfB0tOMDnTvvWgdFDH9E=", + "requires": { + "camelcase": "^1.0.2", + "cliui": "^2.1.0", + "decamelize": "^1.0.0", + "window-size": "0.1.0" + } + }, + "yeast": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/yeast/-/yeast-0.1.2.tgz", + "integrity": "sha1-AI4G2AlDIMNy28L47XagymyKxBk=" + }, + "zxcvbn": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/zxcvbn/-/zxcvbn-3.5.0.tgz", + "integrity": "sha1-XCFWUdFdcpmt2I8u725lTppsBBU=" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..3c3bf5d --- /dev/null +++ b/package.json @@ -0,0 +1,67 @@ +{ + "name": "Cronicle", + "version": "1.8.56", + "description": "A simple, distributed task scheduler and runner with a web based UI.", + "author": "Joseph Huckaby ", + "homepage": "https://github.com/jhuckaby/Cronicle", + "license": "MIT", + "main": "lib/main.js", + "scripts": { + "test": "pixl-unit lib/test.js" + }, + "repository": { + "type": "git", + "url": "https://github.com/jhuckaby/Cronicle" + }, + "bugs": { + "url": "https://github.com/jhuckaby/Cronicle/issues" + }, + "keywords": [ + "cron", + "crontab", + "scheduler" + ], + "dependencies": { + "activedirectory2": "^2.1.0", + "async": "2.6.0", + "bcrypt-node": "0.1.0", + "chart.js": "2.7.1", + "codemirror": "^5.59.2", + "dotenv": "^8.2.0", + "font-awesome": "4.7.0", + "glob": "5.0.15", + "jquery": "3.3.1", + "jstimezonedetect": "1.0.6", + "mdi": "1.9.33", + "mkdirp": "0.5.1", + "moment": "2.22.1", + "moment-timezone": "0.5.32", + "netmask": "1.0.6", + "pixl-args": "^1.0.3", + "pixl-class": "^1.0.2", + "pixl-cli": "^1.0.8", + "pixl-config": "^1.0.5", + "pixl-json-stream": "^1.0.6", + "pixl-logger": "^1.0.20", + "pixl-mail": "^1.0.10", + "pixl-perf": "^1.0.5", + "pixl-request": "^1.0.35", + "pixl-server": "^1.0.28", + "pixl-server-api": "^1.0.2", + "pixl-server-storage": "^2.0.10", + "pixl-server-web": "^1.1.36", + "pixl-tools": "^1.0.28", + "pixl-webapp": "^1.0.17", + "read-last-lines": "^1.8.0", + "shell-quote": "1.6.1", + "simple-git": "^2.31.0", + "socket.io": "1.7.3", + "socket.io-client": "1.7.3", + "uglify-js": "2.8.22", + "uncatch": "^1.0.0", + "zxcvbn": "3.5.0" + }, + "devDependencies": { + "pixl-unit": "^1.0.10" + } +} diff --git a/sample_conf/backup b/sample_conf/backup new file mode 100644 index 0000000..d3b85b7 --- /dev/null +++ b/sample_conf/backup @@ -0,0 +1,12 @@ +# Cronicle Data Export v1.0 +# Hostname: local +# Date/Time: Fri Feb 05 2021 14:42:18 GMT-0500 (Eastern Standard Time) +# Format: KEY - JSON + +users/admin - {"username":"admin","password":"$2a$10$VAF.FNvz1JqhCAB5rCh9GOa965eYWH3fcgWIuQFAmsZnnVS/.ye1y","full_name":"Administrator","email":"admin@cronicle.com","active":1,"modified":1612277068,"created":1612277068,"salt":"salty","privileges":{"admin":1}} +global/users - {"page_size":100,"first_page":0,"last_page":0,"length":1,"type":"list"} +global/users/0 - {"type":"list_page","items":[{"username":"admin"}]} +global/categories - {"page_size":50,"first_page":0,"last_page":0,"length":4,"type":"list"} +global/categories/0 - {"type":"list_page","items":[{"title":"Admin","description":"","max_children":0,"notify_success":"","notify_fail":"","web_hook":"","enabled":1,"gcolor":"#f4d03f","id":"ckksife2k01","modified":1612542932,"created":1612542932,"username":"admin"},{"title":"Chain","description":"","max_children":0,"notify_success":"","notify_fail":"","web_hook":"","enabled":1,"gcolor":"#58d68d","id":"ckkqcfxkg26","modified":1612411947,"created":1612411947,"username":"admin"},{"title":"Demo","description":"","max_children":0,"notify_success":"","notify_fail":"","web_hook":"","enabled":1,"gcolor":"#ec7063 ","id":"ckkog7wj41t","modified":1612297359,"created":1612297359,"username":"admin"},{"id":"general","title":"General","enabled":1,"username":"admin","modified":1612277068,"created":1612277068,"description":"For events that don't fit anywhere else.","gcolor":"#3498DB","max_children":0}]} +global/schedule - {"page_size":50,"first_page":0,"last_page":0,"length":15,"type":"list"} +global/schedule/0 - {"type":"list_page","items":[{"enabled":1,"params":{"script":"#!/usr/bin/env bash\n\n# set up data folder for git sync\n# once completed set git.enabled=true (will sync on pressing backup button, restart and shutdown)\n# for auto update set git.auto=true (to invoke git sync on each metadata change)\n\nUSER=\"cronicle\"\nPASSWORD=\"P@ssw0rd\"\nREPO=\"http://$USER:$PASSWORD@github.com/yourUser/yourRepo.git\"\n# or use ssh keys for auth if possible\n\nexport GIT_AUTHOR_NAME=\"cronicle\"\nexport GIT_COMMITER_NAME=\"cronicle\"\nexport EMAIL=\"admin@cronicle.com\"\n\ncd data && \\\ngit init && \\\ngit remote add origin $REPO && \\\ngit add global users && \\\ngit commit -m \"Initial commit\" && \\\ngit branch --set-upstream-to=origin/master master && \\\ngit push -u origin master\n\n# later on you can configure user/email/branch and remote names in config file/keys\n# git.add option let you control what folders/files to sync:\n# global,users - (default) just metadata (schedules/events/users/etc)\n# global,users,logs - metadata + job history + activity log (10-20MB per 5K jobs)\n# global,users,logs,jobs - to cover everything (~100-150MB per 5K jobs)\n\n# to debug git sync try to \"git push\" manually or check logs for \"Git Sync Error\"\n\n# next time while setting up cronicle on other machine just do \"git clone $REPO data\" instead of setup\n# if using cronicle-edge docker image just set GIT_REPO env variable to take care of it, e.g.\n# docker run -d -e GIT_REPO=$REPO -p 3012:3012 cronicle:edge manager","annotate":0,"json":0,"lang":"shell","theme":"default","sub_params":0,"tty":0},"timing":false,"max_children":1,"timeout":3600,"catch_up":0,"queue_max":1000,"timezone":"America/New_York","plugin":"shellplug","title":"gitInit","silent":0,"graph_icon":"f09b","args":"","ticks":"","category":"ckksife2k01","target":"allgrp","algo":"random","multiplex":0,"stagger":0,"retries":0,"retry_delay":0,"detached":0,"queue":0,"chain":"","chain_error":"","notify_success":"","notify_fail":"","web_hook":"","web_hook_start":"","web_hook_error":0,"cpu_limit":0,"cpu_sustain":0,"memory_limit":0,"memory_sustain":0,"log_max_size":0,"notes":"","id":"ekkrmrzb2h6","modified":1612543627,"created":1612489772,"username":"admin","salt":""},{"enabled":1,"params":{"script":"#!/usr/bin/env node\n\nlet stdin = JSON.parse(require(\"fs\").readFileSync(0));\n// stdin will contain some info about the job that is chaining this notification\n\nconst PixlMail = require('/opt/cronicle/node_modules/pixl-mail');\nconst mail = new PixlMail( 'mailrelay.cronicle.com', 25 );\n// mail.setOption( 'secure', true ); // use ssl\n// mail.setOption( 'auth', { user: 'fsmith', pass: '12345' } );\n\nlet body = `\n Exit code: [chain_code]\n Description: [chain_description]\n Data: [chain_data]\n`\nlet message = \n \"To: notify@cronicle.com\\n\" + \n \"From: admin@cronicle.com\\n\" + \n \"Subject: [source] failed\\n\" +\n \"\\n\" + body + \"\\n\" ;\n \n\nmail.send( message, stdin, function(err) {\n if (err) return console.log( \"Mail Error: \" + err );\n console.log(\"message sent\");\n} );","annotate":0,"json":0,"lang":"javascript","theme":"default","sub_params":0,"tty":0},"timing":false,"max_children":1,"timeout":3600,"catch_up":0,"queue_max":1000,"timezone":"America/New_York","plugin":"shellplug","title":"notify","category":"ckksife2k01","target":"allgrp","algo":"random","multiplex":0,"stagger":0,"retries":0,"retry_delay":0,"detached":0,"queue":0,"chain":"","chain_error":"","notify_success":"","notify_fail":"","web_hook":"","cpu_limit":0,"cpu_sustain":0,"memory_limit":0,"memory_sustain":0,"log_max_size":0,"notes":"Randomly Generated Job","id":"ekkqcopde2a","modified":1612543044,"created":1612412357,"username":"admin","salt":"","silent":0,"graph_icon":"f0e0","args":"","ticks":"","web_hook_start":"","web_hook_error":0},{"enabled":1,"params":{"script":"#!/usr/bin/env bash\n\necho \"Chain 3\"\n\nsleep 2","annotate":0,"json":0,"lang":"shell","theme":"default","sub_params":0,"tty":0},"timing":false,"max_children":1,"timeout":3600,"catch_up":0,"queue_max":1000,"timezone":"America/New_York","plugin":"shellplug","category":"ckkqcfxkg26","title":"chain3","silent":0,"graph_icon":"f111","args":"","ticks":"","target":"allgrp","algo":"random","multiplex":0,"stagger":0,"retries":0,"retry_delay":0,"detached":0,"queue":0,"chain":"ekkqcgqiw27","chain_error":"","notify_success":"","notify_fail":"","web_hook":"","web_hook_start":"","web_hook_error":0,"cpu_limit":0,"cpu_sustain":0,"memory_limit":0,"memory_sustain":0,"log_max_size":0,"notes":"","salt":"","id":"ekkqchhlc29","modified":1612543706,"created":1612412020,"username":"admin"},{"enabled":1,"params":{"script":"#!/usr/bin/env bash\n\necho \"Chain 2\"\n\nsleep 2","annotate":0,"json":0,"lang":"shell","theme":"default","sub_params":0,"tty":0},"timing":false,"max_children":1,"timeout":3600,"catch_up":0,"queue_max":1000,"timezone":"America/New_York","plugin":"shellplug","category":"ckkqcfxkg26","title":"chain2","silent":0,"graph_icon":"f111","args":"","ticks":"","target":"allgrp","algo":"random","multiplex":0,"stagger":0,"retries":0,"retry_delay":0,"detached":0,"queue":0,"chain":"ekkqchhlc29","chain_error":"","notify_success":"","notify_fail":"","web_hook":"","web_hook_start":"","web_hook_error":0,"cpu_limit":0,"cpu_sustain":0,"memory_limit":0,"memory_sustain":0,"log_max_size":0,"notes":"","salt":"","id":"ekkqch38n28","modified":1612412112,"created":1612412001,"username":"admin"},{"enabled":1,"params":{"script":"#!/usr/bin/env bash\n\necho \"Chain 1\"\n\nsleep 2","annotate":0,"json":0,"lang":"shell","theme":"default","sub_params":0,"tty":0},"timing":false,"max_children":1,"timeout":3600,"catch_up":0,"queue_max":1000,"timezone":"America/New_York","plugin":"shellplug","category":"ckkqcfxkg26","title":"chain1","silent":0,"graph_icon":"f111","args":"","ticks":"","target":"allgrp","algo":"random","multiplex":0,"stagger":0,"retries":0,"retry_delay":0,"detached":0,"queue":0,"chain":"ekkqch38n28","chain_error":"","notify_success":"","notify_fail":"","web_hook":"","web_hook_start":"","web_hook_error":0,"cpu_limit":0,"cpu_sustain":0,"memory_limit":0,"memory_sustain":0,"log_max_size":0,"notes":"","id":"ekkqcgqiw27","modified":1612412033,"created":1612411985,"username":"admin","salt":""},{"enabled":1,"params":{"script":"#!/usr/bin/env python3\n\nimport os\nfrom pyspark import SparkContext, SparkConf\nfrom pyspark.sql import SparkSession\n\nconf = SparkConf()\n# place DB drivers into jars folder (e.g. /opt/cronicle/jars)\nconf.set(\"spark.driver.extraClassPath\", \"jars/*\");\nconf.set(\"spark.executor.extraClassPath\", \"jars/*\"); \n\nsc = SparkContext(conf = conf)\nspark = SparkSession(sc)\n\nurl = \"jdbc:mysql://127.0.0.1:3306/demo?user=root&password=root\"\n\ndf = spark \\\n .read \\\n .format(\"jdbc\") \\\n .option(\"url\", url) \\\n .option(\"query\", \"select * from mytable LIMIT 100\") \\\n .load()\n\n# Looks the schema of this DataFrame.\ndf.show(10)\n\n# ! on alpine install gcompat: apk add gcompat\ndf.write.format(\"parquet\").mode(\"overwrite\").save(\"/tmp/data.parquet\")","annotate":0,"json":0,"lang":"python","theme":"solarized light","sub_params":0,"tty":0},"timing":false,"max_children":1,"timeout":3600,"catch_up":0,"queue_max":1000,"timezone":"America/New_York","plugin":"shellplug","title":"PySpark_Parquet","silent":0,"graph_icon":"f111","args":"","ticks":"","category":"ckkog7wj41t","target":"allgrp","algo":"random","multiplex":0,"stagger":0,"retries":0,"retry_delay":0,"detached":0,"queue":0,"chain":"","chain_error":"ekkqcopde2a","notify_success":"","notify_fail":"","web_hook":"","web_hook_start":"","web_hook_error":0,"cpu_limit":0,"cpu_sustain":0,"memory_limit":0,"memory_sustain":0,"log_max_size":0,"notes":"","salt":"","id":"ekkqcf9m725","modified":1612501484,"created":1612411916,"username":"admin"},{"enabled":1,"params":{"script":"#!/usr/bin/env python3\n\n# to install python/pyspark on alpine:\n# apk add python3 gcompat\n# pip3 install pyspark\n# make sure Java (open-jdk) is installed too\n\nimport sys\nfrom pyspark import SparkContext\nfrom pyspark.sql import SparkSession\n\nsc = SparkContext(\"local\", \"word count\")\nspark = SparkSession(sc)\n\ntext_file = sc.textFile(\"/etc/os-release\")\ncounts = text_file.flatMap(lambda line: line.split(\" \")) \\\n .map(lambda word: (word, 1)) \\\n .reduceByKey(lambda a, b: a + b)\ncounts.toDF([\"word\", \"count\"]).show(50)","annotate":0,"json":0,"lang":"python","theme":"solarized light","sub_params":0,"tty":0},"timing":false,"max_children":1,"timeout":3600,"catch_up":0,"queue_max":1000,"timezone":"America/New_York","plugin":"shellplug","title":"PySpark_Hello","silent":0,"graph_icon":"f111","args":"","ticks":"","category":"ckkog7wj41t","target":"allgrp","algo":"random","multiplex":0,"stagger":0,"retries":0,"retry_delay":0,"detached":0,"queue":0,"chain":"","chain_error":"ekkqcopde2a","notify_success":"","notify_fail":"","web_hook":"","web_hook_start":"","web_hook_error":0,"cpu_limit":0,"cpu_sustain":0,"memory_limit":0,"memory_sustain":0,"log_max_size":0,"notes":"","id":"ekkqcd1gb23","modified":1612543081,"created":1612411813,"username":"admin","salt":""},{"enabled":1,"params":{"script":"#!/usr/bin/env -S java --source 11 -cp \"jars/*\"\n\n// use Java 11 or higher to run code as script\n// if using modern syntax (e.g. var or textblocks) you might switch to Scala syntax\n\nimport java.io.*;\nimport java.net.*;\n\npublic class HttpRequestDemo {\n \n public static void main(String[] args) throws Exception\n {\n System.out.println(getHTML(\"http://www.example.com\"));\n }\n\n public static String getHTML(String urlToRead) throws Exception {\n StringBuilder result = new StringBuilder();\n URL url = new URL(urlToRead);\n HttpURLConnection conn = (HttpURLConnection) url.openConnection();\n conn.setRequestMethod(\"GET\");\n BufferedReader rd = new BufferedReader(new InputStreamReader(conn.getInputStream()));\n String line;\n while ((line = rd.readLine()) != null) {\n result.append(line);\n }\n rd.close();\n return result.toString();\n }\n}\n","annotate":0,"json":0,"lang":"java","theme":"darcula","sub_params":0,"tty":0},"timing":false,"max_children":1,"timeout":3600,"catch_up":0,"queue_max":1000,"timezone":"America/New_York","plugin":"shellplug","title":"java_demo","category":"ckkog7wj41t","target":"allgrp","algo":"random","multiplex":0,"stagger":0,"retries":0,"retry_delay":0,"detached":0,"queue":0,"chain":"","chain_error":"","notify_success":"","notify_fail":"","web_hook":"","cpu_limit":0,"cpu_sustain":0,"memory_limit":0,"memory_sustain":0,"log_max_size":0,"notes":"Randomly Generated Job","id":"ekkqa4w6s1a","modified":1612543529,"created":1612408073,"username":"admin","salt":"","silent":0,"graph_icon":"f111","args":"","ticks":"","web_hook_start":"","web_hook_error":0},{"enabled":1,"params":{"wf_type":"event","wf_event":"ekkog584p1r","wf_args":"5,6,7,8,9","wf_concur":"2","wf_maxerr":"(None)","wf_strict":0,"TEMP_KEY":"46211113b4d633ee00e5d36a9ebd60a2214ba8323321c90df937c30f2e53e659","BASE_URL":"http://manager1:3012"},"timing":false,"max_children":1,"timeout":3600,"catch_up":0,"queue_max":1000,"timezone":"America/New_York","plugin":"workflow","title":"workflow_event","category":"general","target":"allgrp","algo":"random","multiplex":0,"stagger":0,"retries":0,"retry_delay":0,"detached":0,"queue":0,"chain":"","chain_error":"","notify_success":"","notify_fail":"","web_hook":"","cpu_limit":0,"cpu_sustain":0,"memory_limit":0,"memory_sustain":0,"log_max_size":0,"notes":"This workflow will invoke specific event multiple times (based on argument count). Argument value will appear on target job as JOB_ARG env variable. Event and workflow concurrency levels should be in line ( WF should be <= event concurrency, otherwise WF will follow event concurrency)","salt":"41a2253c","silent":0,"graph_icon":"f07c","args":"","ticks":"3AM,8:20AM,16:00","web_hook_start":"","web_hook_error":0,"id":"ekkog6xpr1s","modified":1612298737,"created":1612297314,"username":"admin"},{"enabled":1,"params":{"script":"#!/usr/bin/env bash\n\n# this event will be invoked by workflow event multiple times with different argument value\n# argument value appears as JOB_ARG env variable\n# If WF concurrency > then event concurrency the latter value will be used to run jobs in parallel\n\necho argument value: $JOB_ARG\nsleep $JOB_ARG","annotate":0,"json":0,"lang":"shell","theme":"default","sub_params":0,"tty":0},"timing":false,"max_children":3,"timeout":3600,"catch_up":0,"queue_max":1000,"timezone":"America/New_York","plugin":"shellplug","title":"00_wf_event","silent":0,"graph_icon":"f111","args":"","ticks":"","category":"general","target":"allgrp","algo":"random","multiplex":0,"stagger":0,"retries":0,"retry_delay":0,"detached":0,"queue":0,"chain":"","chain_error":"","notify_success":"","notify_fail":"","web_hook":"","web_hook_start":"","web_hook_error":0,"cpu_limit":0,"cpu_sustain":0,"memory_limit":0,"memory_sustain":0,"log_max_size":0,"notes":"","id":"ekkog584p1r","modified":1612298830,"created":1612297234,"username":"admin","salt":""},{"enabled":1,"params":{"duration":"7","progress":1,"burn":0,"action":"Success","secret":"Will not be shown in Event UI"},"timing":{"minutes":[29],"hours":[16]},"max_children":1,"timeout":3600,"catch_up":0,"queue_max":1000,"timezone":"America/New_York","plugin":"testplug","title":"03_wf_step_three","category":"general","target":"allgrp","algo":"random","multiplex":0,"stagger":0,"retries":0,"retry_delay":0,"detached":0,"queue":0,"chain":"","chain_error":"","notify_success":"","notify_fail":"","web_hook":"","cpu_limit":0,"cpu_sustain":0,"memory_limit":0,"memory_sustain":0,"log_max_size":0,"notes":"Randomly Generated Job","id":"ekko474ip05","modified":1612296599,"created":1612277167,"username":"admin","salt":"","silent":0,"graph_icon":"f111","args":"","ticks":"","web_hook_start":"","web_hook_error":0},{"enabled":1,"params":{"duration":"10","progress":1,"burn":0,"action":"Success","secret":"Will not be shown in Event UI"},"timing":false,"max_children":1,"timeout":3600,"catch_up":0,"queue_max":1000,"timezone":"America/New_York","plugin":"testplug","title":"02_wf_step_two","category":"general","target":"allgrp","algo":"random","multiplex":0,"stagger":0,"retries":0,"retry_delay":0,"detached":0,"queue":0,"chain":"","chain_error":"","notify_success":"","notify_fail":"","web_hook":"","cpu_limit":0,"cpu_sustain":0,"memory_limit":0,"memory_sustain":0,"log_max_size":0,"notes":"wf step 2","id":"ekko473os04","modified":1612296610,"created":1612277166,"username":"admin","salt":"","silent":0,"graph_icon":"f111","args":"","ticks":"","web_hook_start":"","web_hook_error":0},{"enabled":1,"params":{"duration":"14","progress":1,"burn":0,"action":"Success","secret":"Will not be shown in Event UI"},"timing":false,"max_children":1,"timeout":3600,"catch_up":0,"queue_max":1000,"timezone":"America/New_York","plugin":"testplug","title":"01_wf_step_one","category":"general","target":"allgrp","algo":"random","multiplex":0,"stagger":0,"retries":0,"retry_delay":0,"detached":0,"queue":0,"chain":"","chain_error":"","notify_success":"","notify_fail":"","web_hook":"","cpu_limit":0,"cpu_sustain":0,"memory_limit":0,"memory_sustain":0,"log_max_size":0,"notes":"Randomly Generated Job","id":"ekko472v803","modified":1612296628,"created":1612277165,"username":"admin","salt":"","silent":0,"graph_icon":"f111","args":"","ticks":"","web_hook_start":"","web_hook_error":0},{"enabled":1,"params":{"wf_type":"category","wf_event":"","wf_args":"","wf_concur":"2","wf_maxerr":"(None)","wf_strict":0,"TEMP_KEY":"1918f9ce4832839359dfaf6b9ec26857651a67fd267940dee94d225ce36e9bbb","BASE_URL":"http://manager1:3012"},"timing":{"minutes":[59],"hours":[21]},"max_children":1,"timeout":3600,"catch_up":0,"queue_max":1000,"timezone":"America/New_York","plugin":"workflow","title":"workflow_category","category":"general","target":"allgrp","algo":"random","multiplex":0,"stagger":0,"retries":0,"retry_delay":0,"detached":0,"queue":0,"chain":"","chain_error":"","notify_success":"","notify_fail":"","web_hook":"","cpu_limit":0,"cpu_sustain":0,"memory_limit":0,"memory_sustain":0,"log_max_size":0,"notes":"This workflow will invoke all jobs in the same category, excluding workflow events. Order is based on event title (A->Z)","id":"ekko4726g02","modified":1612299635,"created":1612277164,"username":"admin","salt":"41a2253c","silent":0,"graph_icon":"f07c","args":"","ticks":"9:50AM","web_hook_start":"","web_hook_error":0},{"enabled":1,"params":{"script":"#!/usr/bin/env bash\n\n# Cronicle injects some metadata to stdin \n# please note - stdin will be supressed if using tty option\n# also, printing JSON strings might cause issues (cronicle tries to interpret them)\ninput=\"$(cat - )\"\necho \"JSON data from stdin:\"\necho \"--> $input\"\n# most of the items from stdin JSON are available as env variables (e.g. JOB_ID)\n\necho \"------------------------------------------------\"\n\necho \"reporting progress:\"\n sleep 2 && echo \" -- step1 completed (10%)\" && echo 10%\n sleep 2 && echo \" -- step2 completed (50%)\" && echo 50%\n sleep 2 && echo \" -- step3 completed (90%)\" && echo 90%\n sleep 1\n\n# reporting performance \"Interpret JSON\" should be checked\necho '{\"perf\":{\"step3\":55,\"step2\":30,\"step1\":20}}'\n\n# alter chain reaction. Chain data can be sent to stdin of the new job\necho '{ \"chain\": \"ekko473os04\", \"chain_data\": { \"custom_key\": \"foobar\", \"value\": 42 } }' # on success\necho '{ \"chain_error\": \"ekko473os04\" }' # chain event on failure\n\n# alter email notification\necho '{ \"notify_success\": \"admin@cronicle.com\" }'\necho '{ \"notify_fail\": \"\" }'\n\n# paramter placeholders. Requires \"Resolve parameters\" to be checked\n# below example populates params.jdbc.demo value (from config)\necho sample parameter from config: [/jdbc/demo]\n\n# arguments (let non-editor users to parametrize script)\necho argument1: $ARG1 # as env variable\necho argument2: [/ARG2] # as parameter. Requires \"Resolve parameters\" to be checked\n\n# PLUGIN_SECRET a string you can set in plugin options.\n# It's injected as env variable to the running job, and never gets back to UI\necho plugin secret: $PLUGIN_SECRET","annotate":0,"json":1,"lang":"shell","theme":"gruvbox-dark","sub_params":1,"tty":0},"timing":{"minutes":[50],"hours":[23]},"max_children":1,"timeout":3600,"catch_up":0,"queue_max":1000,"timezone":"America/New_York","plugin":"shellplug","title":"shell_demo","category":"ckkog7wj41t","target":"allgrp","algo":"random","multiplex":0,"stagger":0,"retries":0,"retry_delay":0,"detached":0,"queue":0,"chain":"","chain_error":"","notify_success":"","notify_fail":"","web_hook":"","cpu_limit":0,"cpu_sustain":0,"memory_limit":0,"memory_sustain":0,"log_max_size":0,"notes":"Randomly Generated Job","id":"ekko4719i01","modified":1612554127,"created":1612277163,"username":"admin","salt":"","silent":0,"graph_icon":"f111","args":"New York, 33","ticks":"2021-02-05 11:58","web_hook_start":"","web_hook_error":0}]} diff --git a/sample_conf/config.json b/sample_conf/config.json new file mode 100644 index 0000000..67fd028 --- /dev/null +++ b/sample_conf/config.json @@ -0,0 +1,148 @@ +{ + "base_app_url": "http://localhost:3012", + "email_from": "admin@cronicle.com", + "smtp_hostname": "mailrelay.cronicle.com", + "smtp_port": 25, + "secret_key": "autogenerated", + "ad_domain": "corp.cronicle.com", + + "log_dir": "logs", + "log_filename": "[component].log", + "log_columns": ["hires_epoch", "date", "hostname", "pid", "component", "category", "code", "msg", "data"], + "log_archive_path": "logs/archives/[yyyy]/[mm]/[dd]/[filename]-[yyyy]-[mm]-[dd].log.gz", + "log_crashes": true, + "pid_file": "logs/cronicled.pid", + "copy_job_logs_to": "", + "queue_dir": "queue", + "debug_level": 9, + "maintenance": "04:00", + "list_row_max": 10000, + "job_data_expire_days": 180, + "child_kill_timeout": 10, + "dead_job_timeout": 120, + "manager_ping_freq": 20, + "manager_ping_timeout": 60, + "udp_broadcast_port": 3014, + "scheduler_startup_grace": 10, + "universal_web_hook": "", + "track_manual_jobs": false, + + "server_comm_use_hostnames": true, + "web_direct_connect": false, + "web_socket_use_hostnames": true, + + "job_memory_max": 1073741824, + "job_memory_sustain": 0, + "job_cpu_max": 0, + "job_cpu_sustain": 0, + "job_log_max_size": 0, + "job_env": {}, + + "web_hook_text_templates": { + "job_start": "🚀 *[event_title]* started on [hostname] <[job_details_url] | More details>", + "job_complete": "✔️ *[event_title]* completed successfully on [hostname] <[job_details_url] | More details>", + "job_warning": "⚠️ *[event_title]* completed with warning on [hostname]:\nWarning: _*[description]*_\n <[job_details_url] | More details>", + "job_failure": "❌ *[event_title]* failed on [hostname]:\nError: _*[description]*_\n <[job_details_url] | More details>", + "job_launch_failure": "💥 Failed to launch *[event_title]*:\n*[description]*\n<[edit_event_url] | More details>" + }, + + "client": { + "name": "Cronicle", + "debug": 1, + "default_password_type": "password", + "privilege_list": [ + { "id": "admin", "title": "Administrator" }, + { "id": "create_events", "title": "Create Events" }, + { "id": "edit_events", "title": "Edit Events" }, + { "id": "delete_events", "title": "Delete Events" }, + { "id": "run_events", "title": "Run Events" }, + { "id": "abort_events", "title": "Abort Events" }, + { "id": "state_update", "title": "Toggle Scheduler" } + ], + "new_event_template": { + "enabled": 1, + "params": {}, + "timing": { "minutes": [0] }, + "max_children": 1, + "timeout": 3600, + "catch_up": 0, + "queue_max": 1000 + } + }, + + "git": { + "enabled": false, + "auto": false, + "user": "cronicle", + "email": "croncile@cronicle.com", + "remote": "origin", + "branch": "master" + }, + + "Storage": { + "engine": "Filesystem", + "list_page_size": 50, + "concurrency": 4, + "log_event_types": { "get": 1, "put": 1, "head": 1, "delete": 1, "expire_set": 1 }, + + "Filesystem": { + "base_dir": "data", + "key_namespaces": 1 + } + }, + + "WebServer": { + "http_port": 3012, + "http_htdocs_dir": "htdocs", + "http_max_upload_size": 104857600, + "http_static_ttl": 3600, + "http_static_index": "index.html", + "http_server_signature": "Cronicle 1.0", + "http_gzip_text": true, + "http_timeout": 30, + "http_regex_json": "(text|javascript|js|json)", + "http_response_headers": { + "Access-Control-Allow-Origin": "*" + }, + + "https": false, + "https_port": 3013, + "https_cert_file": "conf/ssl.crt", + "https_key_file": "conf/ssl.key", + "https_force": false, + "https_timeout": 30, + "https_header_detect": { + "Front-End-Https": "^on$", + "X-Url-Scheme": "^https$", + "X-Forwarded-Protocol": "^https$", + "X-Forwarded-Proto": "^https$", + "X-Forwarded-Ssl": "^on$" + } + }, + + "User": { + "session_expire_days": 30, + "max_failed_logins_per_hour": 5, + "max_forgot_passwords_per_hour": 3, + "free_accounts": false, + "sort_global_users": true, + "use_bcrypt": true, + + "email_templates": { + "welcome_new_user": "conf/emails/welcome_new_user.txt", + "changed_password": "conf/emails/changed_password.txt", + "recover_password": "conf/emails/recover_password.txt" + }, + + "default_privileges": { + "admin": 0, + "create_events": 1, + "edit_events": 1, + "delete_events": 1, + "run_events": 0, + "abort_events": 0, + "state_update": 0 + } + } + +} diff --git a/sample_conf/emails/changed_password.txt b/sample_conf/emails/changed_password.txt new file mode 100644 index 0000000..fc4beb8 --- /dev/null +++ b/sample_conf/emails/changed_password.txt @@ -0,0 +1,16 @@ +To: [/user/email] +From: [/config/email_from] +Subject: Your Cronicle password was changed + +Hey [/user/full_name], + +Someone recently changed the password on your Cronicle account. If this was you, then all is well, and you can disregard this message. However, if you suspect your account is being hacked, you might want to consider using the "Forgot Password" feature (located on the login page) to reset your password. + +Here is the information we gathered from the request: + +Date/Time: [/date_time] +IP Address: [/ip] +User Agent: [/request/headers/user-agent] + +Regards, +The Cronicle Team diff --git a/sample_conf/emails/event_error.txt b/sample_conf/emails/event_error.txt new file mode 100644 index 0000000..c53c830 --- /dev/null +++ b/sample_conf/emails/event_error.txt @@ -0,0 +1,19 @@ +To: [/notify_fail] +From: [/config/email_from] +Subject: ⚠️ Cronicle Event Error: [/title] + +Date/Time: [/nice_date_time] +Event Title: [/title] +Hostname: [/hostname] + +Error Description: +[/description] + +Edit Event: +[/edit_event_url] + +Event Notes: +[/notes] + +Regards, +The Cronicle Team diff --git a/sample_conf/emails/job_fail.txt b/sample_conf/emails/job_fail.txt new file mode 100644 index 0000000..c00d4bf --- /dev/null +++ b/sample_conf/emails/job_fail.txt @@ -0,0 +1,36 @@ +To: [/notify_fail] +From: [/config/email_from] +Subject: ⚠️ [/status]: [/event_title] + +Date/Time: [/nice_date_time] +Event Title: [/event_title] +Category: [/category_title] +Server Target: [/nice_target] +Plugin: [/plugin_title] + +Job ID: [/id] +Hostname: [/hostname] +PID: [/pid] +Elapsed Time: [/nice_elapsed] +Performance Metrics: [/perf] +Avg. Memory Usage: [/nice_mem] +Avg. CPU Usage: [/nice_cpu] +Error Code: [/code] + +Error Description: +[/description] + +Job Details: +[/job_details_url] + +Job Debug Log ([/nice_log_size]): +[/job_log_url] + +Edit Event: +[/edit_event_url] + +Event Notes: +[/notes] + +Regards, +The Cronicle Team diff --git a/sample_conf/emails/job_success.txt b/sample_conf/emails/job_success.txt new file mode 100644 index 0000000..b22624e --- /dev/null +++ b/sample_conf/emails/job_success.txt @@ -0,0 +1,35 @@ +To: [/notify_success] +From: [/config/email_from] +Subject: ✅ [OK] [/event_title] + +Date/Time: [/nice_date_time] +Event Title: [/event_title] +Category: [/category_title] +Server Target: [/nice_target] +Plugin: [/plugin_title] + +Job ID: [/id] +Hostname: [/hostname] +PID: [/pid] +Elapsed Time: [/nice_elapsed] +Performance Metrics: [/perf] +Avg. Memory Usage: [/nice_mem] +Avg. CPU Usage: [/nice_cpu] + +Job Details: +[/job_details_url] + +Job Debug Log ([/nice_log_size]): +[/job_log_url] + +Edit Event: +[/edit_event_url] + +Description: +[/description] + +Event Notes: +[/notes] + +Regards, +The Cronicle Team diff --git a/sample_conf/emails/recover_password.txt b/sample_conf/emails/recover_password.txt new file mode 100644 index 0000000..e9fa72c --- /dev/null +++ b/sample_conf/emails/recover_password.txt @@ -0,0 +1,20 @@ +To: [/user/email] +From: [/config/email_from] +Subject: Forgot your Cronicle password? + +Hey [/user/full_name], + +Someone recently requested to have your password reset on your Cronicle account. To make sure this is really you, this confirmation was sent to the e-mail address we have on file for your account. If you really want to reset your password, please click the link below. If you cannot click the link, copy and paste it into your browser. + +[/config/base_app_url]#Login?u=[/user/username]&h=[/recovery_key] + +This password reset page will expire after 24 hours. + +If you suspect someone is trying to hack your account, here is the information we gathered from the request: + +Date/Time: [/date_time] +IP Address: [/ip] +User Agent: [/request/headers/user-agent] + +Regards, +The Cronicle Team diff --git a/sample_conf/emails/welcome_new_user.txt b/sample_conf/emails/welcome_new_user.txt new file mode 100644 index 0000000..7cc60c0 --- /dev/null +++ b/sample_conf/emails/welcome_new_user.txt @@ -0,0 +1,12 @@ +To: [/user/email] +From: [/config/email_from] +Subject: Welcome to Cronicle! + +Hey [/user/full_name], + +Welcome to Cronicle! Your new account username is "[/user/username]". You can login to your new account by clicking the following link, or copying & pasting it into your browser: + +[/config/base_app_url] + +Regards, +The Cronicle Team diff --git a/sample_conf/setup.json b/sample_conf/setup.json new file mode 100644 index 0000000..7e3b4a8 --- /dev/null +++ b/sample_conf/setup.json @@ -0,0 +1,450 @@ +{ + "storage": [ + [ "put", "users/admin", { + "username": "admin", + "password": "$2a$10$VAF.FNvz1JqhCAB5rCh9GOa965eYWH3fcgWIuQFAmsZnnVS/.ye1y", + "full_name": "Administrator", + "email": "admin@cronicle.com", + "active": 1, + "modified": 1434125333, + "created": 1434125333, + "salt": "salty", + "privileges": { + "admin": 1 + } + } ], + [ "listCreate", "global/users", { "page_size": 100 } ], + [ "listPush", "global/users", { "username": "admin" } ], + [ "listCreate", "global/plugins", {} ], + [ "listPush", "global/plugins", { + "id": "testplug", + "title": "Test Plugin", + "enabled": 1, + "command": "bin/test-plugin.js", + "username": "admin", + "modified": 1434125333, + "created": 1434125333, + "params": [ + { "id":"duration", "type":"text", "size":10, "title":"Test Duration (seconds)", "value": 60 }, + { "id":"progress", "type":"checkbox", "title":"Report Progress", "value": 1 }, + { "id":"burn", "type":"checkbox", "title":"Burn Memory/CPU", "value": 0 }, + { "id":"action", "type":"select", "title":"Simulate Action", "items":["Success","Failure","Crash"], "value": "Success" }, + { "id":"secret", "type":"hidden", "value":"Will not be shown in Event UI" } + ] + } ], + [ "listPush", "global/plugins", { + "id": "shellplug", + "title": "Shell Script", + "enabled": 1, + "command": "bin/shell-plugin.js", + "username": "admin", + "modified": 1434125333, + "created": 1434125333, + "params": [ + { "id":"script", "type":"textarea", "rows":10, "title":"Script Source", "value": "#!/usr/bin/env bash\n\n# Enter your shell script code here\n\necho \"print integer with % to report progress (e.g. 20%)\"\n sleep 2 && echo `date` && echo 10%\n sleep 2 && echo `date` && echo 40%\n sleep 2 && echo `date` && echo 90%\n sleep 2\necho '{\"perf\":{\"step3\":55,\"step2\":30,\"step1\":20}}'" }, + { "id":"annotate", "type":"checkbox", "title":"Add Date/Time Stamps to Log", "value": 0 }, + { "id":"json", "type":"checkbox", "title":"Interpret JSON in Output", "value": 0 }, + {"type":"select","id":"lang","title":"syntax","items":["shell","powershell","javascript","python","perl","groovy","java","csharp","scala"],"value":"shell"}, + {"type":"select","id":"theme","title":"theme","items":["default","gruvbox-dark","solarized light","solarized dark","darcula"],"value":"default"}, + {"type":"checkbox","id":"sub_params","title":"Resolve parameters","value":0}, + {"type":"checkbox","id":"tty","title":"Use terminal emulator","value":0} + + ] + } ], + + [ "listPush", "global/plugins", { + "params": [ + { + "type": "select", + "id": "wf_type", + "title": "Workflow Type", + "items": [ + "category", + "event" + ], + "value": "category" + }, + { + "type": "select", + "id": "wf_event", + "title": "Event", + "items": [ + "(none)" + ], + "value": "" + }, + { + "type": "text", + "id": "wf_args", + "title": "Event workflow arguments", + "size": 40, + "value": "" + }, + { + "type": "select", + "id": "wf_concur", + "title": "Concurrency level", + "items": [ + "(default)", + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9", + "10", + "11", + "12", + "13", + "14", + "15", + "16" + ], + "value": "(default)" + }, + + { + "type": "select", + "id": "wf_maxerr", + "title": "Max Errors", + "items": [ + "(None)", + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9", + "10" + ], + "value": "(None)" + }, + { "type":"checkbox", "id":"wf_strict", "title":"Report error on any job failure", "value":0 } + ], + "enabled": 1, + "title": "Workflow", + "command": "bin/workflow.js", + "cwd": "", + "uid": "", + "secret": "", + "id": "workflow", + "username": "admin", + "modified": 1608659489, + "created": 1608325957 + } ], + + [ "listPush", "global/plugins", { + "id": "urlplug", + "title": "HTTP Request", + "enabled": 1, + "command": "bin/url-plugin.js", + "username": "admin", + "modified": 1434125333, + "created": 1434125333, + "params": [ + { "type":"select", "id":"method", "title":"Method", "items":["GET", "HEAD", "POST"], "value":"GET" }, + { "type":"textarea", "id":"url", "title":"URL", "rows":3, "value":"http://" }, + { "type":"textarea", "id":"headers", "title":"Request Headers", "rows":4, "value":"User-Agent: Cronicle/1.0" }, + { "type":"textarea", "id":"data", "title":"POST Data", "rows":4, "value":"" }, + { "type":"text", "id":"timeout", "title":"Timeout (Seconds)", "size":5, "value":"30" }, + { "type":"checkbox", "id":"follow", "title":"Follow Redirects", "value":0 }, + { "type":"checkbox", "id":"ssl_cert_bypass", "title":"SSL Cert Bypass", "value":0 }, + { "type":"text", "id":"success_match", "title":"Success Match", "size":20, "value":"" }, + { "type":"text", "id":"error_match", "title":"Error Match", "size":20, "value":"" } + ] + } ], + [ "listCreate", "global/categories", {} ], + [ "listPush", "global/categories", { + "id": "general", + "title": "General", + "enabled": 1, + "username": "admin", + "modified": 1434125333, + "created": 1434125333, + "description": "For events that don't fit anywhere else.", + "gcolor": "#3498DB", + "max_children": 0 + } ], + [ "listCreate", "global/server_groups", {} ], + [ "listPush", "global/server_groups", { + "id": "maingrp", + "title": "Manager Group", + "regexp": "_HOSTNAME_", + "manager": 1 + } ], + [ "listPush", "global/server_groups", { + "id": "allgrp", + "title": "All Servers", + "regexp": ".+", + "manager": 0 + } ], + [ "listCreate", "global/servers", {} ], + [ "listPush", "global/servers", { + "hostname": "_HOSTNAME_", + "ip": "_IP_" + } ], + [ "listCreate", "global/schedule", {} ], + [ "listCreate", "global/api_keys", {} ], + [ "listCreate", "global/conf_keys", {} ], + [ "listPush", "global/conf_keys", { + "id": "base_app_url", + "title": "base_app_url", + "key": "http://localhost:3012", + "description": "overrides app url displayed in notifications" + }], + [ "listPush", "global/conf_keys", { + "id": "ad_domain", + "title": "ad_domain", + "key": "corp.cronicle.com", + "description": "default AD domain for external auth. You can also prepend domain to the username (e.g. user@domain.com)" + }], + [ "listPush", "global/conf_keys", { + "id": "smtp_hostname", + "title": "smtp_hostname", + "key": "mailrelay.cronicle.com", + "description": "SMTP server (port 25 is used default)" + }], + [ "listPush", "global/conf_keys", { + "id": "email_from", + "title": "email_from", + "key": "admin@cronicle.com", + "description": "Notification sender" + }], + [ "listPush", "global/conf_keys", { + "id": "admin_web_hook", + "title": "admin_web_hook", + "key": "", + "description": "Webhook for activity log notifications. Uses slack markdown.\nTip: use cronicle run api to handle notification with custom event" + }], + [ "listPush", "global/conf_keys", { + "id": "custom_live_log_socket_url", + "title": "custom_live_log_socket_url", + "key": "http://localhost:3012", + "description": "!this requires browser page refresh\noverrides the host for live log connection. On multinode cluster this can be assigned to each node, e.g. \ncustom_live_log_socket_url.manager\ncustom_live_log_socket_url.worker1\nCan specify custom port too. This is useful if using reverse proxy or docker/swarm" + }] + + ,[ "listPush", "global/conf_keys", { + "id": "web_hook_text_templates_job_complete", + "title": "web_hook_text_templates.job_complete", + "key": "✔️ *[event_title]* completed successfully on [hostname] <[job_details_url] | More details>", + "description": "Success notification (slack markdown by default)" + }] + + ,[ "listPush", "global/conf_keys", { + "id": "web_hook_text_templates_job_failure", + "title": "web_hook_text_templates.job_failure", + "key": "❌ *[event_title]* failed on [hostname]: Error: _*[description]*_ <[job_details_url] | More details>", + "description": "Error notification (slack markdown by default)" + }] + + ,[ "listPush", "global/conf_keys", { + "id": "web_hook_text_templates_job_start", + "title": "web_hook_text_templates.job_start", + "key": "🚀 *[event_title]* started on [hostname] <[job_details_url] | More details>", + "description": "Start notification (slack markdown by default)" + }] + + ,[ "listPush", "global/conf_keys", { + "id": "web_hook_text_templates_job_warning", + "title": "web_hook_text_templates.job_warning", + "key": "⚠️ *[event_title]* completed with warning on [hostname]: Warning: _*[description]*_ <[job_details_url] | More details>", + "description": "Warning notification. Warning is exit code 255 (-1) and it's treaded as success" + }] + + ,[ "listPush", "global/conf_keys", { + "id": "web_hooks_slack_general", + "title": "web_hooks.slack_general", + "key": "https://hooks.slack.com/services/yourIncomingWebHook", + "description": "You can add webhook info under web_hooks object and then use property name (e.g. slack_general) to specify that webhook in notification options, instead of using full url. Use either url string (like this example) or object to specify custom data/options/headers and some other items (see example below)" + }] + + ,[ "listPush", "global/conf_keys", { + "id": "web_hooks_slack_info_data_channel", + "title": "web_hooks.slack_info.data.channel", + "key": "cronicle", + "description": "Add custom key to request body (e.g. to specify channel)" + }] + + ,[ "listPush", "global/conf_keys", { + "id": "web_hooks_slack_info_textkey", + "title": "web_hooks.slack_info.textkey", + "key": "markdown", + "description": "By default cronicle message is added as text key on webhook request body. Use this config if you need to use something else (e.g. markdown, html, etc). You can specify nested key too using dot notation e.g. 'data.mytextkey'" + }] + + ,[ "listPush", "global/conf_keys", { + "id": "web_hooks_slack_info_compact", + "title": "web_hooks.slack_info.compact", + "key": 0, + "description": "(Notification webhooks only) Include only basic info in payload (id, title, action) and your custom data. Useful in case of key conflicts" + }] + + ,[ "listPush", "global/conf_keys", { + "id": "web_hooks_slack_info_token", + "title": "web_hooks.slack_info.token", + "key": "xoxp-xxxxxxxxx-xxxx", + "description": "This is a shortcut for web_hooks.slack_info.headers.Authorization = Bearer xoxp-xxxxxxxxx-xxxx" + }] + + ,[ "listPush", "global/conf_keys", { + "id": "web_hooks_slack_info_url", + "title": "web_hooks.slack_info.url", + "key": "https://slack.com/api/chat.postMessage", + "description": "Specify webhook url (for object). If using incoming webhooks then just specify it as string (see slack_general example above)" + }] + + ,[ "listPush", "global/conf_keys", { + "id": "oninfo_web_hook", + "title": "oninfo_web_hook", + "key": "", + "description": "Special webhook - will fire on info message, e.g. server startup/restart/error. Those messages appear on activity log" + }] + + ,[ "listPush", "global/conf_keys", { + "id": "universal_web_hook", + "title": "universal_web_hook", + "key": "", + "description": "Special webhook - will fire on each job start/completion" + }] + + ,[ "listPush", "global/conf_keys", { + "id": "onupdate_web_hook", + "title": "onupdate_web_hook", + "key": "", + "description": "Special webhook - will fire on metadata update (e.g. on event update)" + }] + + ,[ "listPush", "global/conf_keys", { + "id": "ui_live_log_ws", + "title": "ui.live_log_ws", + "key": 0, + "description": "Turns on classic websocket api for live log" + }] + + ,[ "listPush", "global/conf_keys", { + "id": "manager", + "title": "manager", + "key": 0, + "description": "If set to true eligble node will become a manager right away skipping default manager election procedure (and avoiding initial lag on start up). Only do this on single manager cluster / single node setup" + }] + + ,[ "listPush", "global/conf_keys", { + "id": "params_demo", + "title": "params.jdbc.demo", + "key": "jdbc:mysql://localhost:3306/demo", + "description": "If resolve parameters is checked, these params will be injected into script as job parameters.\nIn shell plug you can use placeholder (e.g. [/demo]) or env variable (e.g. $JOB_DEMO)" + }] + + ,[ "listPush", "global/conf_keys", { + "id": "git_enabled", + "title": "git.enabled", + "key": 0, + "description": "Sync up data folder with remote git repository. To push data press backup button on scheduled event page.\n Should init git repo in data folder first. You can also set remote, branch and user via config (default is origin/master/cronicle)" + }] + + ,[ "listPush", "global/conf_keys", { + "id": "git_auto", + "title": "git.auto", + "key": 0, + "description": "If git is enabled this option will trigger git sync on each metadata update (e.g. on event change)" + }] + + ,[ "listPush", "global/conf_keys", { + "id": "git_add", + "title": "git.add", + "key": "global,users", + "description": "List of folders/files to sync via git sync. Global and user folders are default (covers metadata).\n Add 'logs' folder to keep trends/activity logs and 'jobs' for entire job logs" + }] + + ,[ "listPush", "global/conf_keys", { + "id": "_read_me_", + "title": "_read_me_", + "key": "please read", + "description": "Those keys are applied right after storage and webserver init, and then can be updated at runtime (no need to restart cronicle). Please note that you cannot override storage/webserver parameters.\nTo add nested config (object) use dot syntax, e.g. servers.host1. If you convert some nested key into string it would erase related subkeys from config object. In this case just remove that string key and click reload button . To check actual config state use Config Viewer link" + }] + + + + ], + + "build": { + "common": [ + [ "symlinkCompress", "node_modules/jquery/dist/jquery.min.js", "htdocs/js/external/" ], + [ "symlinkCompress", "node_modules/jquery/dist/jquery.min.map", "htdocs/js/external/" ], + [ "symlinkCompress", "node_modules/zxcvbn/dist/zxcvbn.js", "htdocs/js/external/" ], + [ "symlinkCompress", "node_modules/zxcvbn/dist/zxcvbn.js.map", "htdocs/js/external/" ], + [ "symlinkCompress", "node_modules/chart.js/dist/Chart.min.js", "htdocs/js/external/" ], + + + [ "symlinkCompress", "node_modules/font-awesome/css/font-awesome.min.css", "htdocs/css/" ], + [ "symlinkCompress", "node_modules/font-awesome/css/font-awesome.css.map", "htdocs/css/" ], + [ "copyFiles", "node_modules/font-awesome/fonts/*", "htdocs/fonts/" ], + + [ "symlinkCompress", "node_modules/mdi/css/materialdesignicons.min.css", "htdocs/css/" ], + [ "symlinkCompress", "node_modules/mdi/css/materialdesignicons.min.css.map", "htdocs/css/" ], + [ "copyFiles", "node_modules/mdi/fonts/*", "htdocs/fonts/" ], + + [ "symlinkCompress", "node_modules/moment/min/moment.min.js", "htdocs/js/external/" ], + [ "symlinkCompress", "node_modules/moment-timezone/builds/moment-timezone-with-data.min.js", "htdocs/js/external/" ], + [ "symlinkCompress", "node_modules/jstimezonedetect/dist/jstz.min.js", "htdocs/js/external/" ], + + [ "symlinkFile", "node_modules/pixl-webapp/js", "htdocs/js/common" ], + [ "symlinkFile", "node_modules/pixl-webapp/css/base.css", "htdocs/css/" ], + [ "copyFiles", "node_modules/pixl-webapp/fonts/*", "htdocs/fonts/" ], + + [ "chmodFiles", "755", "bin/*" ] + ], + "dev": [ + [ "deleteFiles", "htdocs/css/_combo*" ], + [ "deleteFiles", "htdocs/js/_combo*" ], + [ "deleteFile", "htdocs/index.html" ], + [ "deleteFile", "htdocs/index.html.gz" ], + [ "symlinkFile", "htdocs/index-dev.html", "htdocs/index.html" ], + [ "symlinkFile", "sample_conf", "conf" ] + ], + "dist": [ + { + "action": "generateSecretKey", + "file": "sample_conf/config.json", + "key": "secret_key" + }, + [ "copyDir", "sample_conf", "conf", true ], + [ "copyFile", "htdocs/index-dev.html", "htdocs/index.html" ], + [ "copyDir", "node_modules/codemirror", "htdocs/codemirror", true ], + { + "action": "bundleCompress", + "uglify": false, + "header": "/* Copyright (c) PixlCore.com, MIT License. https://github.com/jhuckaby/Cronicle */", + "dest_bundle": "htdocs/js/_combo.js", + "html_file": "htdocs/index.html", + "match_key": "COMBINE_SCRIPT", + "dest_bundle_tag": "" + }, + { + "action": "bundleCompress", + "strip_source_maps": true, + "dest_bundle": "htdocs/css/_combo.css", + "html_file": "htdocs/index.html", + "match_key": "COMBINE_STYLE", + "dest_bundle_tag": "" + }, + { + "action": "printMessage", + "lines": [ + "Welcome to Cronicle!", + "First time installing? You should configure your settings in '/opt/cronicle/conf/config.json'.", + "Next, if this is a manager server, type: '/opt/cronicle/bin/control.sh setup' to init storage.", + "Then, to start the service, type: '/opt/cronicle/bin/control.sh start'.", + "For full docs, please visit: http://github.com/jhuckaby/Cronicle", + "Enjoy!" + ] + } + ] + } +} \ No newline at end of file diff --git a/sample_conf/ssl.crt b/sample_conf/ssl.crt new file mode 100644 index 0000000..d785dbb --- /dev/null +++ b/sample_conf/ssl.crt @@ -0,0 +1,20 @@ +-----BEGIN CERTIFICATE----- +MIIDMjCCAhqgAwIBAgIJANQO8EFYRaZiMA0GCSqGSIb3DQEBBQUAMBoxGDAWBgNV +BAMTD3d3dy5leGFtcGxlLmNvbTAeFw0xMzA2MjUxNDMxMDFaFw0yMzA2MjMxNDMx +MDFaMBoxGDAWBgNVBAMTD3d3dy5leGFtcGxlLmNvbTCCASIwDQYJKoZIhvcNAQEB +BQADggEPADCCAQoCggEBANhUOPoivca6J5zWqqp3xkd/iTjE3ME7/36wTprNDIt4 +VQpiQfMMUeE4UQadivNJJnzAsB/LtZs2T3h//InwhC+40bsmtj0DVqGadthr8x8l +tV15vfwztMQ5e1eU3u0oBQzS3o1sLplD8U88GeTAqamgQLG769kHbqqsxA/lYuAh +O3SRHxkpEgW5pC72lbsTqE8U6ipLry3S3wT1+4BCFC/gFtdg4ILjgqfPNAvzR72R +X+kdy2jJ5fAaE3C/ca8uRkN+rqt2QV5c1MP12UNn5OEQntMNrB6/91V3jfN3UEEF +s9hLZSstPIxYye2B7PkFlW3irR34HuDrSki0u0k4xIkCAwEAAaN7MHkwHQYDVR0O +BBYEFHb75UVd2UnNO2ml+y227nRujASpMEoGA1UdIwRDMEGAFHb75UVd2UnNO2ml ++y227nRujASpoR6kHDAaMRgwFgYDVQQDEw93d3cuZXhhbXBsZS5jb22CCQDUDvBB +WEWmYjAMBgNVHRMEBTADAQH/MA0GCSqGSIb3DQEBBQUAA4IBAQDXiSZaNq0p0IQq +tttyaksuIITs7WYusXhZiMufO9rbIiQErCgTZuUE/DahIPQFKAUwTS6VQO9rkFTj +1nfDJe/SD9EoIco7gxe/9a/FiGi53uTlVq8F87clr87NCh+t8VMwLMO/43tsgl2U +A0kaExBo1roJwJcrADNsyCfSsB8n2y2n7Q8QOdJ0HOzHT0vvYUJZaOprV9dp+8Yj ++raVZRibPA+H4jUEBHk387knpUx5tWeJd+7RCA1pCcAU2b3lfcL6zbiUGhJdrAbx +LZ3egEKpKR4Ld7w1NN7rQaGQ3FWlIdIGUGFiMsT5LdubC6ABnqdxd+Sg6sfnsbcK +/x+0/eC8 +-----END CERTIFICATE----- diff --git a/sample_conf/ssl.key b/sample_conf/ssl.key new file mode 100644 index 0000000..2cc62f1 --- /dev/null +++ b/sample_conf/ssl.key @@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEogIBAAKCAQEA2FQ4+iK9xronnNaqqnfGR3+JOMTcwTv/frBOms0Mi3hVCmJB +8wxR4ThRBp2K80kmfMCwH8u1mzZPeH/8ifCEL7jRuya2PQNWoZp22GvzHyW1XXm9 +/DO0xDl7V5Te7SgFDNLejWwumUPxTzwZ5MCpqaBAsbvr2QduqqzED+Vi4CE7dJEf +GSkSBbmkLvaVuxOoTxTqKkuvLdLfBPX7gEIUL+AW12DgguOCp880C/NHvZFf6R3L +aMnl8BoTcL9xry5GQ36uq3ZBXlzUw/XZQ2fk4RCe0w2sHr/3VXeN83dQQQWz2Etl +Ky08jFjJ7YHs+QWVbeKtHfge4OtKSLS7STjEiQIDAQABAoIBADv3hN/Z9496FPcG +DsM4do9lTC2fbK5oKlf9GZ0R0DNtRO2e9TchqCTtjpBt5ZGxKmkUpP37YzlGYds+ +Z0v5jzsHWaQug///x+j+P4mYywlMU604zTB3SNnIMWfCzdUh7dxzK9w6K+Syj9bu +CyN9QMrTsHtUY3mC9Ot8/tCFPtZv/SQY2Y9kqCb2RJJ/vTcTcsjEXjMHFuCv9+KZ +VUGX1sm+VtYY5nUtNEmVYWxCfrU5YRxL6W8iuwdWveGIrvkAnktdmnFPM+MGztmh +7MCxE5X8m8+yUDo/SqUeqniX6nYq6/I7MpNG3XVEE1OEY5o/20+cDxXci9Pn6AXj +TuiZ/XECgYEA+wxFtLh4nna1UE3+nPHel13IC90cneqdRT8wISNXF+Sm1rRfSI4s +r461xmA/BwEFhWrO1xbNi15978zsTUUaPWka4sqh7ZI+/m93C3vbvI1xWGttSqg0 +pWI/RjO3rM4vnd0tMgmmNCc/47IB/DXiKJPOcfUScyCS+4j+1ghJ250CgYEA3Jig +5jltZXyUTpHP+FoLV+BGjkxu1S35xjWHg6i6j9LPYxsMWgXTc7OhmCXEBY5lPPSF +hTjqKMvzr79obzPKHt083USzZIyDC0P7uCsmBL6oiTVN1XhpO/1gG8tFHLuZa6B3 +Jd074vrsrAMcEottoxyST9jgdiB7Fh47Rjfqht0CgYANBTLsT5D57wAyXQkyjJzV +zuhcLSiZzBxCBifx4ApZU+OPSSWT9sO8izNESaObMmNd6w81OpqIeusfL8qlq0rU +Goppbsb9MlOQEKnk75SS7+cMBe5SK+0nErRjaLVDAiKYFmuMp9F17P80SPwvX4AO +SLQxVtuRGwRkhVNqOF3URQKBgAdyD1w16/9U6RyNx1s2jtN0em0rH0KKvrd17xD+ +jO11zBIoQ452S+DH21hrTeZyG/CmwCry9NRTrfHsn/XA5b2M8hT10KhAJdwne0OI +EUxvsviOmAXwfnzL3IaToc2Kd28uh1b71J2gooRbxoLJufWbbUTMqSbTidQBSTbh +hETxAoGAN4E5AJF+CiSzG0TO4BbKpfnmr6HV/mD4zMiUHYNPQDHRoFo0hxsPEwzj +CY2RL6tBnCdzFtZiZYsX7D0uAma0dVRyVvlkDVIH2A65T4OUcXbeaB6Jcf2Z706f +iNPBZa/RKsDJ/RTeZDP8NZfhfhqJq2Nvp/1/hMGCbWfHshltL0M= +-----END RSA PRIVATE KEY-----