initial commit - opensourcing project

This commit is contained in:
vadoli 2021-07-30 12:18:29 +00:00
parent 4c05f5e971
commit 75f03edcdc
156 changed files with 30516 additions and 2 deletions

1
.gitignore vendored Normal file
View file

@ -0,0 +1 @@
.DS_Store

162
README.md
View file

@ -1,2 +1,160 @@
# alnoda-workspaces
Work directly inside docker conntainers
# Workspaces in docker
### Contents
* [About](#about)
* [Quickstart](#quickstart)
* [Available workspaces](#available-workspaces)
* [Why dev environmet in docker](#why-dev-environmet-in-docker)
* [Workspaces-in-docker vs. cloud IDE](#workspaces-in-docker-vs.-cloud-ide)
* [Workspaces-in-docker vs. other docker workspaces](#workspaces-in-docker-vs.-other-docker-workspaces)
* [Workspaces-in-docker principles](#workspaces-in-docker-principles)
* [Contribution](#contribution)
## About
Workspaces make development, experiments and workloads isolated in their own dockerized environments,
enabling working directly inside the running docker containers, and managing numerous projects easily.
Workspaces include tools and software that make working inside docker nearly as convenient as working
directly with local environment.
![Workspaces are amazing!](./workspaces/workspace-in-docker/img/workspace-demo.gif)
Workspace is not only a development environment. Some workspaces include lots of tools and packages, that otherwise would
take lots of time to set up.
Workspaces are familar docker images and containers. Working with workspaces will not require learning any new tools,
only will help to improve docker skills.
Workspaces allow moving complete environments between machinnes and cloud servers as easy as 2 clicks. With workspaces you can
actually have 2 develpment machines, and sync them very easily. You can even work in the vary same workspace on your Mac laptop
and Windows PC. And then you move the complete development or runtime environment to a cloud Linux server in under 1 minute.
Workspaces are great for collaboration. Imagine being able to share with your peer not only the codebase, but also to the whole environment
with all the dependencies and config files.
Workspaces make containerization and deployment as easy as possible, without the need of building docker
images again. We love containers for their ability to run on production environment exactly the same way
they are running on "your laptop". We already run a lot in containers. Why not build our apps in containers as well?
Then we can simply skip the extra step of building images again and again.
Workspaces can be used as true Linux terminals. Anywhere. On any system. In fact, workspaces include lots of tools that
make woking in terminal a pleasure.
<p align="center">
<img src="./workspaces/ubuntu-workspace/img/ubuntu-workspace.gif" alt="Ubuntu workspace" width="750">
</p>
Workspaces are great to preserve sometnig that works. Imagin you have made an MVP. Something actually working. It is not enough just to
commit the code. Save the complete workspace! With all environments, dependencies and configurations. Later it might save you a lot of time.
Workspaces are completely open-source and include only popular wide-spread open-source tools. No proprietary software.
## Quickstart
Launching new workspace is as easy as
```sh
docker run --name space-1 -d -p 8020-8030:8020-8030 alnoda/workspace-in-docker
```
that is it! Open workspace UI ***[http://localhost:8020](http://localhost:8020)***
or enter workspace terminal
```sh
docker exec -it space-1 /bin/zsh
```
## Available workspaces
1. [`ubuntu-workspace`](./workspaces/ubuntu-workspace/README.md). Primarily intended as an advanced Ubuntu terminal that runs anywhere, this workspace works best
when you need interactive linux, python or node shell for ad-hock tasks.
2. [`base-workspace`](./workspaces/base-workspace/README.md). Does not include IDE, and serves as a building base for other workspaces with different IDEs.
3. [`workspace-in-docker`](./workspaces/workspace-in-docker/README.md). Workspace-in-docker is a good choice if you want control and versatility. It is lightweight,
includes open-source web-based version of Visual Studio Code, and a reasonable collection of tools that
make working inside docker container nearly as convenient as working on local environment.
Very customizable.
## Why dev environmet in docker
Have your ever participated in several software (sysops, analytics, BI, data science, ML) projects at the same time?
And each of them having different environments (dev, stage, prod), cloud config file, k8s cluster, ansible secret,
terraform workspace... Managing such a collection of environments is so much easier
if everything is packaged inside a docker container, that you can start and stop by one single command.
There are lots of limitations compared to local environment. For example, you will have to work only with WEB-based tools - this limits your choices
and might not include your favourite shiny IDE, on which you spent so many months to configure and learn. Also a web-based IDE is always slower
than its native alternative, and probably not as feature-rich.
But the benefits of isolating everything related to the specific project in one docker image that can be saved, versioned, moved to another machine,
deployed on cloud server or shared with anyone, by far outweighs those limitations.
*Workspaces promote non-canonical way of using docker, taking advantage of features, we don't often use.*
We already run a lot in containers. Why not build our apps in containers as well?
Workspace encapsulates nicely project or experiment with all the dependencies, helping to avoid situation when one environment can affect another.
> Docker is a great tool to bundle together all the things that are related to the same project. It is light-weight and you can start multiple workspaces.
We often over-engineer and waste time on something that we could do better. If we do something on our local machines, it becomes isolated, non-transferrable
and not shareable. If we do the same work inside a running docker container, we can commit it to the new image and share with peers or run
on cloud servers, and this is all - just 3 commands in terminal.
## Workspaces-in-docker vs. cloud IDE
There are other options that provide isolated environments and let work in cloud directly.
Cloud9, CodeAnywhere, GitPod, GroomIDE you name it - are awesome and affordable services, that are even easier to use than workspaces-in-docker.
But workspaces-in-docker have some serious advantages:
- workspaces-in-docker are transferrable, they can be used on both local machine as well as in cloud,
being easily moved back and forth. You might not always have perfect internet, and running workspace on the local machine can be more convenient.
And you can always move workspace to a powerfule server in cloud within couple minutes.
- workspaces-in-docker are more configurable. Tey are open-source docker images after all. Create new image from the workspace image,
and you will have highly customized personal workspace.
Workspaces-in-docker is a middle ground between two extremes: the unwieldy static local environment - unseparable from your laptop,
and euphemeral cloud environments that are much less versatile.
## Workspaces-in-docker vs. other docker workspaces
The idea of development in docker is not new at all. In fact there are other projects in GitHub
that encourage users to work directly in the docker containers.
On the left side of the spectrum, there are WEB-based IDE, each of them having their own docker images.
On the right side there exist over-packed docker images for particular use-cases, that include way too many tools than probably needed.
Workspaces-in-docker take the niche in the middle. Instead of building one heavy unwieldy image that focuses on one specific use-case,
we create a "constructor" stack of images build on top of each other for better control and ease of customization.
And we take security seriously. Workspaces-in-docker by default run under non-root user. Of course you can run or enter workspace as root too.
This makes it secure to share with peers, or deploy production ready applications directly as workspace container.
## Workspaces-in-docker principles
When developing our dockerized workspaces, we try to set and follow some rules:
- worksapces should be easy to use and solve real problems
- start with generic workspaces, suitable for common needs
- more specific workspaces should be built on top of generic workspaces
- rather than building ubder-workspace for all needs, workspaces should branch out from ther generic workspace and focus on specific needs
- workspaces should be well-tested and documented
- workspaces should include only open source tools
- workspaces don't include as much stuff as possible, workspace should have minimal needed set of tools
- include in the workspace only tools that share processes and data. Reverse proxy, load balanncers,
databases, server resource monitors should not be the part of the workspace. Instead they should be integrated with
workspace in docker compose.
## Contribution
Contributions, pull requests and any form of feedback or collaboration is super welcome!

View file

@ -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.

File diff suppressed because it is too large Load diff

View file

@ -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" );
};

View file

@ -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

View file

@ -0,0 +1,219 @@
#!/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))")"
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
;;
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 <<EOF
start - Starts $NAME.
stop - Stops $NAME and wait until it actually exits.
restart - Calls stop, then start (hard restart).
status - Checks whether $NAME is currently running.
setup - Runs initial storage setup.
maint - Runs daily maintenance routine.
admin - Creates new emergency admin account (specify user / pass).
export - Exports data to specified file.
import - Imports data from specified file.
upgrade - Upgrades $NAME to the latest stable (or specify version).
migrate - Migrate storage data to another location.
version - Outputs the current $NAME package version.
help - Displays this screen.
EOF
ERROR=2
;;
esac
done
exit $ERROR
## ====================================================================
## The Apache Software License, Version 1.1
##
## Copyright (c) 2000 The Apache Software Foundation. All rights
## reserved.
##
## Redistribution and use in source and binary forms, with or without
## modification, are permitted provided that the following conditions
## are met:
##
## 1. Redistributions of source code must retain the above copyright
## notice, this list of conditions and the following disclaimer.
##
## 2. Redistributions in binary form must reproduce the above copyright
## notice, this list of conditions and the following disclaimer in
## the documentation and/or other materials provided with the
## distribution.
##
## 3. The end-user documentation included with the redistribution,
## if any, must include the following acknowledgment:
## "This product includes software developed by the
## Apache Software Foundation (http://www.apache.org/)."
## Alternately, this acknowledgment may appear in the software itself,
## if and wherever such third-party acknowledgments normally appear.
##
## 4. The names "Apache" and "Apache Software Foundation" must
## not be used to endorse or promote products derived from this
## software without prior written permission. For written
## permission, please contact apache@apache.org.
##
## 5. Products derived from this software may not be called "Apache",
## nor may "Apache" appear in their name, without prior written
## permission of the Apache Software Foundation.
##
## THIS SOFTWARE IS PROVIDED ``AS IS'' AND ANY EXPRESSED OR IMPLIED
## WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
## OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
## DISCLAIMED. IN NO EVENT SHALL THE APACHE SOFTWARE FOUNDATION OR
## ITS CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
## SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
## LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF
## USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
## ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
## OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT
## OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF
## SUCH DAMAGE.
## ====================================================================
##
## This software consists of voluntary contributions made by many
## individuals on behalf of the Apache Software Foundation. For more
## information on the Apache Software Foundation, please see
## <http://www.apache.org/>.
##
## Portions of this software are based upon public domain software
## originally written at the National Center for Supercomputing Applications,
## University of Illinois, Urbana-Champaign.
##
#

View file

@ -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

View file

@ -0,0 +1,10 @@
#!/bin/sh
# Start Cronicle in debug mode
# No daemon fork, and all logs emitted to stdout
# Add --master to force instant master on startup
HOMEDIR="$(dirname "$(cd -- "$(dirname "$0")" && (pwd -P 2>/dev/null || pwd))")"
cd $HOMEDIR
node --trace-warnings $HOMEDIR/lib/main.js --debug --echo "$@"

View file

@ -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/master/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/master.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

View file

@ -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');
} );

View file

@ -0,0 +1,137 @@
#!/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');
// 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" } );
var child = cp.spawn( script_file, [], {
stdio: ['pipe', 'pipe', 'pipe']
} );
var kill_timer = null;
var stderr_buffer = '';
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: "<pre>" + stderr_buffer.replace(/</g, '&lt;').trim() + "</pre>"
};
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

View file

@ -0,0 +1,610 @@
#!/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 master server
var setup = require('../conf/setup.json');
// make sure this is only run once
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(1);
}
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 master 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 master.\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\-\.]+$/)) {
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'
],
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
};

View file

@ -0,0 +1,440 @@
#!/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'
];
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 master 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();

View file

@ -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: "<pre>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-------------------------------------------------</pre>",
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 );
} );

View file

@ -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: "<pre>" + text.replace(/</g, '&lt;').trim() + "</pre>"
};
}
// 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);
} );
});

View file

@ -0,0 +1,10 @@
<!DOCTYPE HTML>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Blank</title>
</head>
<body>
&nbsp;
</body>
</html>

View file

@ -0,0 +1,585 @@
/* 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_master {
cursor: default;
}
#d_tab_master.active {
cursor: pointer;
}
#d_tab_master.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;
vertical-align: top;
width: 66px;
height: 250px;
margin-left: 5px;
overflow-y: auto;
}
div.pie-legend-container {
width: 66px;
}
div.pie-legend-item {
width: 60px;
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: ':'
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

View file

@ -0,0 +1,119 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
<title>Loading...</title>
<meta name="description" content="A simple distributed task scheduler and runner.">
<meta name="author" content="Joseph Huckaby">
<link rel="shortcut icon" href="/favicon.ico">
<!-- BUILD: COMBINE_STYLE_START -->
<link rel="stylesheet" href="css/base.css">
<link rel="stylesheet" href="css/style.css">
<link rel="stylesheet" href="css/font-awesome.min.css">
<link rel="stylesheet" href="css/materialdesignicons.min.css">
<!-- BUILD: COMBINE_STYLE_END -->
</head>
<body>
<div id="d_message" class="message" style="display:none" onMouseUp="app.hideMessage(250)">
<div id="d_message_inner" class="message_inner"></div>
</div>
<div id="d_scroll_time" style="opacity:0"><i class="fa fa-clock-o">&nbsp;</i><span></span></div>
<div id="d_header">
<div class="container">
<div id="d_header_logo" class="left">
<div class="header_clock_layer" id="d_header_clock_hour"></div>
<div class="header_clock_layer" id="d_header_clock_minute"></div>
<div class="header_clock_layer" id="d_header_clock_second"></div>
</div>
<div id="d_header_title" class="left"></div>
<div id="d_header_user_container" style="right"></div>
<div class="clear"></div>
</div>
</div>
<div class="container">
<div class="master_content_container">
<!-- Main Content Area -->
<div class="tab_bar" style="display:none">
<div id="tab_Login" class="tab inactive" style="display:none"><span class="content"></span></div>
<div id="tab_Home" class="tab inactive"><span class="content"><i class="mdi mdi-home-variant mdi-lg">&nbsp;</i>Home</span></div>
<div id="tab_Schedule" class="tab inactive"><span class="content"><i class="mdi mdi-calendar-clock mdi-lg">&nbsp;</i>Schedule</span></div>
<div id="tab_History" class="tab inactive"><span class="content"><i class="fa fa-history">&nbsp;</i>Completed</span></div>
<div id="tab_JobDetails" class="tab inactive" style="display:none"><span class="content"><i class="fa fa-pie-chart">&nbsp;</i>Job Details</span></div>
<div id="tab_MyAccount" class="tab inactive"><span class="content"><i class="mdi mdi-account mdi-lg">&nbsp;</i>My Account</span></div>
<div id="tab_Admin" class="tab inactive" style="display:none"><span class="content"><i class="mdi mdi-lock mdi-lg">&nbsp;</i>Admin</span></div>
<div id="d_tab_master" class="tab_widget" onMouseUp="app.toggleMasterSwitch()"></div>
<div id="d_tab_time" class="tab_widget"><i class="fa fa-clock-o">&nbsp;</i><span></span></div>
<div class="clear"></div>
</div>
<div id="main" class="main">
<div id="page_Home" style="display:none"></div>
<div id="page_Schedule" style="display:none"></div>
<div id="page_History" style="display:none"></div>
<div id="page_JobDetails" style="display:none"></div>
<div id="page_MyAccount" style="display:none"></div>
<div id="page_Admin" style="display:none"></div>
<div id="page_Login" style="display:none"></div>
</div>
</div>
<div id="d_footer">
<div class="left">
<a href="https://github.com/jhuckaby/Cronicle" target="_blank">Cronicle</a> is
&copy; 2015 - 2020 by <a href="http://pixlcore.com" target="_blank">PixlCore</a>.
Released under the <a href="https://github.com/jhuckaby/Cronicle/blob/master/LICENSE.md" target="_blank">MIT License</a>.
</div>
<div id="d_footer_version" class="right">
</div>
<div class="clear"></div>
</div>
</div>
<script src="js/external/jquery.min.js"></script>
<script src="js/external/moment.min.js"></script>
<script src="js/external/moment-timezone-with-data.min.js"></script>
<script src="js/external/Chart.min.js"></script>
<script src="js/external/jstz.min.js"></script>
<!-- BUILD: COMBINE_SCRIPT_START -->
<script src="js/common/md5.js"></script>
<script src="js/common/oop.js"></script>
<script src="js/common/xml.js"></script>
<script src="js/common/tools.js"></script>
<script src="js/common/datetime.js"></script>
<script src="js/common/page.js"></script>
<script src="js/common/dialog.js"></script>
<script src="js/common/base.js"></script>
<script src="js/app.js"></script>
<script src="js/pages/Base.class.js"></script>
<script src="js/pages/Home.class.js"></script>
<script src="js/pages/Login.class.js"></script>
<script src="js/pages/Schedule.class.js"></script>
<script src="js/pages/History.class.js"></script>
<script src="js/pages/JobDetails.class.js"></script>
<script src="js/pages/MyAccount.class.js"></script>
<script src="js/pages/Admin.class.js"></script>
<script src="js/pages/admin/Categories.js"></script>
<script src="js/pages/admin/Servers.js"></script>
<script src="js/pages/admin/Users.js"></script>
<script src="js/pages/admin/Plugins.js"></script>
<script src="js/pages/admin/Activity.js"></script>
<script src="js/pages/admin/APIKeys.js"></script>
<!-- BUILD: COMBINE_SCRIPT_END -->
<script src="/socket.io/socket.io.js"></script>
<script src="/api/app/config?callback=app.receiveConfig"></script>
</body>
</html>

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,113 @@
// Cronicle App
// Author: Joseph Huckaby
// Copyright (c) 2015 - 2018 Joseph Huckaby and PixlCore.com
// MIT License
// Worker thread for the Home tab
// Processes schedule to predict upcoming jobs
var window = {};
importScripts(
'external/moment.min.js',
'external/moment-timezone-with-data.min.js',
'common/xml.js',
'common/tools.js',
'common/datetime.js'
);
onmessage = function(e) {
// process schedule and cursors, find out which events run in the next 24 hours
var data = e.data;
var default_tz = data.default_tz;
var schedule = data.schedule;
var state = data.state;
var cursors = state.cursors;
var categories = data.categories;
var plugins = data.plugins;
var events = [];
var max_events = 10000;
var now = normalize_time( time_now(), { sec: 0 } );
var max_epoch = now + 86400 + 3600;
var time_start = hires_time_now();
for (var idx = 0, len = schedule.length; idx < len; idx++) {
var item = schedule[idx];
// if item is disabled, skip entirely
if (!item.enabled) continue;
// check category for disabled flag as well
var cat = find_object( categories, { id: item.category } );
if (cat && !cat.enabled) continue;
// check plugin for disabled flag as well
var plugin = find_object( plugins, { id: item.plugin } );
if (plugin && !plugin.enabled) continue;
// start at item cursor
var min_epoch = (cursors[item.id] || now) + 60;
// if item is not in catch-up mode, force cursor to now + 60
if (!item.catch_up) min_epoch = now + 60;
// setup moment, and floor to the hour
var margs = moment.tz(min_epoch * 1000, item.timezone || default_tz);
margs.minutes(0).seconds(0).milliseconds(0);
for (var epoch = min_epoch; epoch < max_epoch; epoch += 3600) {
if (item.timing && check_event_hour(item.timing, margs)) {
// item will run at least one time this hour
// so we can use the timing.minutes to populate events directly
var hour_start = margs.unix();
if (item.timing.minutes && item.timing.minutes.length) {
// item runs on specific minutes
for (var idy = 0, ley = item.timing.minutes.length; idy < ley; idy++) {
var min = item.timing.minutes[idy];
var actual = hour_start + (min * 60);
if ((actual >= min_epoch) && (actual < max_epoch)) {
events.push({ epoch: actual, id: item.id });
if (events.length >= max_events) { idy = ley; epoch = max_epoch; idx = len; }
}
} // foreach minute
} // individual minutes
else {
// item runs EVERY minute in the hour (unusual)
for (var idy = 0; idy < 60; idy++) {
var actual = hour_start + (idy * 60);
if ((actual >= min_epoch) && (actual < max_epoch)) {
events.push({ epoch: actual, id: item.id });
if (events.length >= max_events) { idy = 60; epoch = max_epoch; idx = len; }
}
} // foreach minute
} // every minute
} // item runs in the hour
// advance moment.js by one hour
margs.add( 1, "hours" );
// make sure we don't run amok (3s max run time)
if (hires_time_now() - time_start >= 3.0) { epoch = max_epoch; idx = len; }
} // foreach hour
} // foreach schedule item
postMessage(
events.sort(
function(a, b) { return (a.epoch < b.epoch) ? -1 : 1; }
)
);
};
function check_event_hour(timing, margs) {
// check if event needs to run, up to the hour (do not check minute)
if (!timing) 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;
};

View file

@ -0,0 +1,104 @@
Class.subclass( Page.Base, "Page.Admin", {
usernames: null,
default_sub: 'activity',
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;
},
onDataUpdate: function(key, value) {
// recieved data update (websocket), see if sub-page cares about it
switch (key) {
case 'users':
if (this.args.sub == 'users') this.gosub_users(this.args);
break;
case 'categories':
if (this.args.sub == 'categories') this.gosub_categories(this.args);
break;
case 'server_groups':
case 'servers':
case 'nearby':
if (this.args.sub == 'servers') this.gosub_servers(this.args);
break;
case 'plugins':
if (this.args.sub == 'plugins') this.gosub_plugins(this.args);
break;
case 'state':
case 'schedule':
if (this.args.sub == 'servers') this.gosub_servers(this.args);
break;
case 'api_keys':
if (this.args.sub == 'api_keys') this.gosub_api_keys(this.args);
break;
}
},
onStatusUpdate: function(data) {
// received status update (websocket), update sub-page if needed
if (data.jobs_changed && (this.args.sub == 'servers')) this.gosub_servers(this.args);
if (data.servers_changed && (this.args.sub == 'servers')) this.gosub_servers(this.args);
},
onResizeDelay: function(size) {
// called 250ms after latest window resize
// so we can run more expensive redraw operations
switch (this.args.sub) {
case 'users':
if (this.lastUsersResp) {
this.receive_users(this.lastUsersResp);
}
break;
case 'categories':
this.gosub_categories(this.args);
break;
case 'servers':
this.gosub_servers(this.args);
break;
case 'plugins':
this.gosub_plugins(this.args);
break;
case 'api_keys':
if (this.lastAPIKeysResp) {
this.receive_keys( this.lastAPIKeysResp );
}
break;
case 'activity':
if (this.lastActivityResp) {
this.receive_activity( this.lastActivityResp );
}
break;
}
},
onDeactivate: function() {
// called when page is deactivated
// this.div.html( '' );
return true;
}
} );

View file

@ -0,0 +1,426 @@
Class.subclass( Page, "Page.Base", {
graph_colors: ["0,0,255", "138,43,226", "0,128,0", "255,20,147", "0,191,255", "210,105,30", "100,149,237", "220,20,60", "0,139,139", "128,128,128"],
requireLogin: function(args) {
// user must be logged into to continue
var self = this;
if (!app.user) {
// require login
app.navAfterLogin = this.ID;
if (args && num_keys(args)) app.navAfterLogin += compose_query_string(args);
this.div.hide();
var session_id = app.getPref('session_id') || '';
if (session_id) {
Debug.trace("User has cookie, recovering session: " + session_id);
app.api.post( 'user/resume_session', {
session_id: session_id
},
function(resp) {
if (resp.user) {
Debug.trace("User Session Resume: " + resp.username + ": " + resp.session_id);
app.hideProgress();
app.doUserLogin( resp );
Nav.refresh();
}
else {
Debug.trace("User cookie is invalid, redirecting to login page");
// Nav.go('Login');
self.setPref('session_id', '');
self.requireLogin(args);
}
} );
}
else if (app.config.external_users) {
Debug.trace("User is not logged in, querying external user API");
app.doExternalLogin();
}
else {
Debug.trace("User is not logged in, redirecting to login page (will return to " + this.ID + ")");
setTimeout( function() { Nav.go('Login'); }, 1 );
}
return false;
}
return true;
},
isAdmin: function() {
// return true if user is logged in and admin, false otherwise
// Note: This is used for UI decoration ONLY -- all privileges are checked on the server
return( app.user && app.user.privileges && app.user.privileges.admin );
},
getNiceJob: function(id) {
if (!id) return '(None)';
if (typeof(id) == 'object') id = id.id;
return '<div style="white-space:nowrap;"><i class="fa fa-pie-chart">&nbsp;</i>' + id + '</div>';
},
getNiceEvent: function(title, width) {
if (!width) width = 500;
if (!title) return '(None)';
var icon_class = 'fa fa-clock-o';
if (typeof(title) == 'object') {
if (title.chain || title.chain_error) icon_class = 'fa fa-link';
title = title.title;
}
return '<div class="ellip" style="max-width:'+width+'px;"><i class="' + icon_class + '">&nbsp;</i>' + title + '</div>';
},
getNiceCategory: function(cat, width) {
if (!width) width = 500;
if (!cat) return '(None)';
var title = cat.title;
if (!cat.enabled) title += ' (Disabled)';
return '<div class="ellip" style="max-width:'+width+'px;"><i class="fa fa-folder-open-o">&nbsp;</i>' + title + '</div>';
},
getNiceGroup: function(group, target, width) {
if (!width) width = 500;
if (!group && !target) return '(None)';
if (group) {
var title = group.title;
if (group.multiplex) title += '&nbsp;(<i class="fa fa-bolt" title="Multiplexed"></i>)';
return '<div class="ellip" style="max-width:'+width+'px;"><i class="mdi mdi-server-network">&nbsp;</i>' + title + '</div>';
}
else {
return '<div class="ellip" style="max-width:'+width+'px;" title="'+target+'"><i class="mdi mdi-desktop-tower mdi-lg">&nbsp;</i>' + target.replace(/\.[\w\-]+\.\w+$/, '') + '</div>';
}
},
getNicePlugin: function(plugin, width) {
if (!width) width = 500;
if (!plugin) return '(None)';
var title = plugin.title;
if (!plugin.enabled) title += ' (Disabled)';
return '<div class="ellip" style="max-width:'+width+'px;"><i class="fa fa-plug">&nbsp;</i>' + title + '</div>';
},
getNiceAPIKey: function(item, link, width) {
if (!item) return 'n/a';
if (!width) width = 500;
var key = item.api_key || item.key;
var title = item.api_title || item.title;
var html = '<div class="ellip" style="max-width:'+width+'px;">';
if (link && key) html += '<a href="#Admin?sub=edit_api_key&id='+item.id+'">';
html += '<i class="mdi mdi-key-variant">&nbsp;</i>' + title;
if (link && key) html += '</a>';
html += '</div>';
return html;
},
getNiceUsername: function(user, link, width) {
if (!user) return 'n/a';
if ((typeof(user) == 'object') && (user.key || user.api_title)) {
return this.getNiceAPIKey(user, link, width);
}
if (!width) width = 500;
var username = user.username ? user.username : user;
if (!username || (typeof(username) != 'string')) return 'n/a';
var html = '<div class="ellip" style="max-width:'+width+'px;">';
if (link) html += '<a href="#Admin?sub=edit_user&username='+username+'">';
html += '<i class="fa fa-user">&nbsp;&nbsp;</i>' + username;
if (link) html += '</a>';
html += '</div>';
return html;
},
setGroupVisible: function(group, visible) {
// set web groups of form fields visible or invisible,
// according to master checkbox for each section
var selector = 'tr.' + group + 'group';
if (visible) {
if ($(selector).hasClass('collapse')) {
$(selector).hide().removeClass('collapse');
}
$(selector).show(250);
}
else $(selector).hide(250);
return this; // for chaining
},
checkUserExists: function(pre) {
// check if user exists, update UI checkbox
// called after field changes
var username = trim($('#fe_'+pre+'_username').val().toLowerCase());
var $elem = $('#d_'+pre+'_valid');
if (username.match(/^[\w\-\.]+$/)) {
// check with server
// $elem.css('color','#444').html('<span class="fa fa-spinner fa-spin fa-lg">&nbsp;</span>');
app.api.get('app/check_user_exists', { username: username }, function(resp) {
if (resp.user_exists) {
// username taken
$elem.css('color','red').html('<span class="fa fa-exclamation-triangle fa-lg">&nbsp;</span>Username Taken');
}
else {
// username is valid and available!
$elem.css('color','green').html('<span class="fa fa-check-circle fa-lg">&nbsp;</span>Available');
}
} );
}
else if (username.length) {
// bad username
$elem.css('color','red').html('<span class="fa fa-exclamation-triangle fa-lg">&nbsp;</span>Bad Username');
}
else {
// empty
$elem.html('');
}
},
check_add_remove_me: function($elem) {
// check if user's e-mail is contained in text field or not
var value = $elem.val().toLowerCase();
var email = app.user.email.toLowerCase();
var regexp = new RegExp( "\\b" + escape_regexp(email) + "\\b" );
return !!value.match(regexp);
},
update_add_remove_me: function($elems) {
// update add/remove me text based on if user's e-mail is contained in text field
var self = this;
$elems.each( function() {
var $elem = $(this);
var $span = $elem.next();
if (self.check_add_remove_me($elem)) $span.html( '&raquo; Remove me' );
else $span.html( '&laquo; Add me' );
} );
},
add_remove_me: function($elem) {
// toggle user's e-mail in/out of text field
var value = trim( $elem.val().replace(/\,\s*\,/g, ',').replace(/^\s*\,\s*/, '').replace(/\s*\,\s*$/, '') );
if (this.check_add_remove_me($elem)) {
// remove e-mail
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*$/, '');
$elem.val( trim(value) );
}
else {
// add email
if (value) value += ', ';
$elem.val( value + app.user.email );
}
this.update_add_remove_me($elem);
},
get_custom_combo_unit_box: function(id, value, items, class_name) {
// get HTML for custom combo text/menu, where menu defines units of measurement
// items should be array for use in render_menu_options(), with an increasing numerical value
if (!class_name) class_name = 'std_combo_unit_table';
var units = 0;
var value = parseInt( value || 0 );
for (var idx = items.length - 1; idx >= 0; idx--) {
var max = items[idx][0];
if ((value >= max) && (value % max == 0)) {
units = max;
value = Math.floor( value / units );
idx = -1;
}
}
if (!units) {
// no exact match, so default to first unit in list
units = items[0][0];
value = Math.floor( value / units );
}
return (
'<table cellspacing="0" cellpadding="0" class="'+class_name+'"><tr>' +
'<td style="padding:0"><input type="text" id="'+id+'" style="width:30px;" value="'+value+'"/></td>' +
'<td style="padding:0"><select id="'+id+'_units">' + render_menu_options(items, units) + '</select></td>' +
'</tr></table>'
);
},
get_relative_time_combo_box: function(id, value, class_name, inc_seconds) {
// get HTML for combo textfield/menu for a relative time based input
// provides Minutes, Hours and Days units
var unit_items = [[60,'Minutes'], [3600,'Hours'], [86400,'Days']];
if (inc_seconds) unit_items.unshift( [1,'Seconds'] );
return this.get_custom_combo_unit_box( id, value, unit_items, class_name );
},
get_relative_size_combo_box: function(id, value, class_name) {
// get HTML for combo textfield/menu for a relative size based input
// provides MB, GB and TB units
var TB = 1024 * 1024 * 1024 * 1024;
var GB = 1024 * 1024 * 1024;
var MB = 1024 * 1024;
var KB = 1024;
return this.get_custom_combo_unit_box( id, value, [[KB, 'KB'], [MB,'MB'], [GB,'GB'], [TB,'TB']], class_name );
},
expand_fieldset: function($span) {
// expand neighboring fieldset, and hide click control
var $div = $span.parent();
var $fieldset = $div.next();
$fieldset.show( 350 );
$div.hide( 350 );
},
collapse_fieldset: function($legend) {
// collapse fieldset, and show click control again
var $fieldset = $legend.parent();
var $div = $fieldset.prev();
$fieldset.hide( 350 );
$div.show( 350 );
},
choose_date_time: function(args) {
// show dialog for selecting a date/time
// args: {
// when: default date/time (epoch or Date object, defaults to now)
// timezone: custom timezone (defaults to app.tz)
// title: dialog title
// description: optional description
// button: optional button label ("Select")
// callback: fired when complete, passed new date/time
// }
var self = this;
var html = '';
if (!args.when) args.when = time_now();
if (!args.timezone) args.timezone = app.tz;
if (!args.title) args.title = "Select Date/Time";
if (!args.button) args.button = "Select";
if (args.description) {
html += '<div style="font-size:12px; color:#777; margin-bottom:20px;">' + args.description + '</div>';
}
// var dargs = get_date_args( args.when );
var margs = moment.tz(args.when * 1000, args.timezone);
html += '<center><table><tr>';
// years
var year_items = [];
for (var idx = margs.year() - 10; idx <= margs.year() + 10; idx++) {
year_items.push(idx);
}
html += '<td align="left"><fieldset class="dt_fs"><legend>Year</legend>';
html += '<select id="fe_dt_year">' + render_menu_options(year_items, margs.year()) + '</select>';
html += '</fieldset></td>';
// months
html += '<td align="left"><fieldset class="dt_fs" style="margin-left:5px;"><legend>Month</legend>';
html += '<select id="fe_dt_month">' + render_menu_options(_months, margs.month() + 1) + '</select>';
html += '</fieldset></td>';
// days
html += '<td align="left"><fieldset class="dt_fs" style="margin-left:5px;"><legend>Day</legend>';
html += '<select id="fe_dt_day">' + render_menu_options(_days, margs.date()) + '</select>';
html += '</fieldset></td>';
// hours
var hour_items = _hour_names.map( function(value, idx) {
return [idx, value.toUpperCase().replace(/^(\d+)(\w+)$/, '$1 $2')];
} );
html += '<td align="left"><fieldset class="dt_fs" style="margin-left:5px;"><legend>Hour</legend>';
html += '<select id="fe_dt_hour">' + render_menu_options(hour_items, margs.hour()) + '</select>';
html += '</fieldset></td>';
// minutes
var min_items = [];
for (var idx = 0; idx < 60; idx++) {
min_items.push([ idx, (idx < 10) ? ('0'+idx) : (''+idx) ]);
}
html += '<td align="left"><fieldset class="dt_fs" style="margin-left:5px;"><legend>Minute</legend>';
html += '<select id="fe_dt_minute">' + render_menu_options(min_items, margs.minute()) + '</select>';
html += '</fieldset></td>';
html += '</tr>';
html += '<tr><td align="left" colspan="5">';
html += '<div class="caption">Timezone: ' + args.timezone + '</div>';
html += '</td></tr>';
html += '</table></center>';
app.confirm( '<i class="fa fa-calendar">&nbsp;</i>' + args.title, html, args.button, function(result) {
app.clearError();
if (result) {
Dialog.hide();
margs.year( parseInt( $('#fe_dt_year').val() ) );
margs.month( parseInt( $('#fe_dt_month').val() ) - 1 );
margs.date( parseInt( $('#fe_dt_day').val() ) );
margs.hour( parseInt( $('#fe_dt_hour').val() ) );
margs.minute( parseInt( $('#fe_dt_minute').val() ) );
margs.second( 0 );
args.callback( margs.unix() );
}
} ); // app.confirm
},
render_target_menu_options: function(value) {
// render menu items for server group (target)
// including optgroups for both server group and individual servers
var html = '';
var server_groups = app.server_groups.sort( function(a, b) {
// return (b.title < a.title) ? 1 : -1;
return a.title.toLowerCase().localeCompare( b.title.toLowerCase() );
} )
.filter( function(group) {
if (!app.user.privileges.grp_limit) return true; // user is not limited by groups
return app.hasPrivilege( 'grp_' + group.id );
});
html += '<optgroup label="Groups:">' + render_menu_options(server_groups, value, false) + '</optgroup>';
if (find_object(server_groups, { id: value })) value = '';
// trim hostname suffixes
var hostnames = hash_keys_to_array(app.servers).sort();
if (value && !app.servers[value]) hostnames.push( value );
// filter hostnames by server group privilege
if (app.user.privileges.grp_limit) {
hostnames = hostnames.filter(function(hostname) {
var groups = server_groups.filter( function(group) {
return hostname.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.hasPrivilege(priv_id);
if (result) return true;
}
return false;
});
} // grp_limit
var short_hostnames = [];
for (var idx = 0, len = hostnames.length; idx < len; idx++) {
short_hostnames.push([ hostnames[idx], hostnames[idx].replace(/\.[\w\-]+\.\w+$/, '') ]);
}
html += '<optgroup label="Servers:">' + render_menu_options(short_hostnames, value, false) + '</optgroup>';
return html;
}
} );

View file

@ -0,0 +1,777 @@
// Cronicle History Page
Class.subclass( Page.Base, "Page.History", {
default_sub: 'history',
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 = {};
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;
},
gosub_history: function(args) {
// show history
app.setWindowTitle( "History" );
var html = '';
// html += '<div style="padding:5px 15px 15px 15px;">';
html += '<div style="padding:20px 20px 30px 20px">';
html += '<div class="subtitle">';
html += 'All Completed Jobs';
// html += '<div class="subtitle_widget"><span class="link" onMouseUp="$P().refresh_user_list()"><b>Refresh</b></span></div>';
// html += '<div class="subtitle_widget"><i class="fa fa-search">&nbsp;</i><input type="text" id="fe_ul_search" size="15" placeholder="Find username..." style="border:0px;"/></div>';
var sorted_events = app.schedule.sort( function(a, b) {
return a.title.toLowerCase().localeCompare( b.title.toLowerCase() );
} );
html += '<div class="subtitle_widget"><i class="fa fa-chevron-down">&nbsp;</i><select id="fe_hist_event" class="subtitle_menu" onChange="$P().jump_to_event_history()"><option value="">Filter by Event</option>' + render_menu_options( sorted_events, '', false ) + '</select></div>';
html += '<div class="clear"></div>';
html += '</div>';
html += '<div id="d_history_table"></div>';
html += '</div>'; // padding
this.div.html( html );
this.get_history();
},
get_history: function() {
var args = this.args;
if (!args.offset) args.offset = 0;
if (!args.limit) args.limit = 25;
app.api.post( 'app/get_history', copy_object(args), this.receive_history.bind(this) );
},
receive_history: function(resp) {
// receive page of history from server, render it
this.lastHistoryResp = resp;
var html = '';
this.div.removeClass('loading');
var size = get_inner_window_size();
var col_width = Math.floor( ((size.width * 0.9) - 50) / 8 );
this.events = [];
if (resp.rows) this.events = resp.rows;
var cols = ['Job ID', 'Event Name', 'Category', 'Plugin', 'Hostname', 'Result', 'Start Date/Time', 'Elapsed Time'];
var self = this;
var num_visible_items = 0;
html += this.getPaginatedTable( resp, cols, 'event', function(job, idx) {
/*var actions = [
'<a href="#JobDetails?id='+job.id+'"><b>Job&nbsp;Details</b></a>',
'<a href="#History?sub=event_history&id='+job.event+'"><b>Event&nbsp;History</b></a>'
];*/
// suppress row view if job was deleted
if (job.action != 'job_complete') return null;
num_visible_items++;
var event = find_object( app.schedule, { id: job.event } );
var event_link = '(None)';
if (event && job.id) {
event_link = '<div class="td_big"><a href="#History?sub=event_history&id='+job.event+'">' + self.getNiceEvent('<b>' + (event.title || job.event) + '</b>', col_width + 40) + '</a></div>';
}
else if (job.event_title) {
event_link = self.getNiceEvent(job.event_title, col_width + 40);
}
var cat = job.category ? find_object( app.categories, { id: job.category } ) : null;
if (!cat && job.category_title) cat = { id: job.category, title: job.category_title };
var plugin = job.plugin ? find_object( app.plugins, { id: job.plugin } ) : null;
if (!plugin && job.plugin_title) plugin = { id: job.plugin, title: job.plugin_title };
var job_link = '<div class="td_big">--</div>';
if (job.id) job_link = '<div class="td_big"><a href="#JobDetails?id='+job.id+'">' + self.getNiceJob('<b>' + job.id + '</b>') + '</a></div>';
var tds = [
job_link,
event_link,
self.getNiceCategory( cat, col_width ),
self.getNicePlugin( plugin, col_width ),
self.getNiceGroup( null, job.hostname, col_width ),
(job.code == 0) ? '<span class="color_label green"><i class="fa fa-check">&nbsp;</i>Success</span>' : '<span class="color_label red"><i class="fa fa-warning">&nbsp;</i>Error</span>',
get_nice_date_time( job.time_start, false, true ),
get_text_from_seconds( job.elapsed, true, false )
// actions.join(' | ')
];
if (!job.id) tds.className = 'disabled';
if (cat && cat.color) {
if (tds.className) tds.className += ' '; else tds.className = '';
tds.className += cat.color;
}
return tds;
} );
if (resp.rows && resp.rows.length && !num_visible_items) {
html += '<tr><td colspan="'+cols.length+'" align="center" style="padding-top:10px; padding-bottom:10px; font-weight:bold;">';
html += 'All items were deleted on this page.';
html += '</td></tr>';
}
this.div.find('#d_history_table').html( html );
},
jump_to_event_history: function() {
// make a selection from the event filter menu
var id = $('#fe_hist_event').val();
if (id) Nav.go( '#History?sub=event_history&id=' + id );
},
gosub_event_stats: function(args) {
// request event stats
if (!args.offset) args.offset = 0;
if (!args.limit) args.limit = 50;
app.api.post( 'app/get_event_history', copy_object(args), this.receive_event_stats.bind(this) );
},
receive_event_stats: function(resp) {
// render event stats page
this.lastEventStatsResp = resp;
var html = '';
var args = this.args;
var rows = this.rows = resp.rows;
var size = get_inner_window_size();
var col_width = Math.floor( ((size.width * 0.9) - 300) / 4 );
var event = find_object( app.schedule, { id: args.id } ) || null;
if (!event) return app.doError("Could not locate event in schedule: " + args.id);
var cat = event.category ? find_object( app.categories, { id: event.category } ) : null;
var group = event.target ? find_object( app.server_groups, { id: event.target } ) : null;
var plugin = event.plugin ? find_object( app.plugins, { id: event.plugin } ) : null;
if (group && event.multiplex) {
group = copy_object(group);
group.multiplex = 1;
}
app.setWindowTitle( "Event Stats: " + event.title );
this.div.removeClass('loading');
html += this.getSidebarTabs( 'event_stats',
[
['history', "All Completed"],
['event_stats', "Event Stats"],
['event_history&id=' + args.id, "Event History"]
]
);
html += '<div style="padding:20px 20px 30px 20px">';
html += '<fieldset style="margin-top:0px; margin-right:0px; padding-top:10px;"><legend>Event Stats</legend>';
html += '<div style="float:left; width:25%;">';
html += '<div class="info_label">EVENT NAME</div>';
html += '<div class="info_value"><a href="#Schedule?sub=edit_event&id='+event.id+'">' + this.getNiceEvent(event.title, col_width) + '</a></div>';
html += '<div class="info_label">CATEGORY NAME</div>';
html += '<div class="info_value">' + this.getNiceCategory(cat, col_width) + '</div>';
html += '<div class="info_label">EVENT TIMING</div>';
html += '<div class="info_value">' + (event.enabled ? summarize_event_timing(event.timing, event.timezone) : '(Disabled)') + '</div>';
html += '</div>';
html += '<div style="float:left; width:25%;">';
html += '<div class="info_label">USERNAME</div>';
html += '<div class="info_value">' + this.getNiceUsername(event, false, col_width) + '</div>';
html += '<div class="info_label">PLUGIN NAME</div>';
html += '<div class="info_value">' + this.getNicePlugin(plugin, col_width) + '</div>';
html += '<div class="info_label">EVENT TARGET</div>';
html += '<div class="info_value">' + this.getNiceGroup(group, event.target, col_width) + '</div>';
html += '</div>';
var total_elapsed = 0;
var total_cpu = 0;
var total_mem = 0;
var total_success = 0;
var total_log_size = 0;
var count = 0;
for (var idx = 0, len = rows.length; idx < len; idx++) {
var job = rows[idx];
if (job.action != 'job_complete') continue;
count++;
total_elapsed += (job.elapsed || 0);
if (job.cpu && job.cpu.total) total_cpu += (job.cpu.total / (job.cpu.count || 1));
if (job.mem && job.mem.total) total_mem += (job.mem.total / (job.mem.count || 1));
if (job.code == 0) total_success++;
total_log_size += (job.log_file_size || 0);
}
if (!count) count = 1;
var nice_last_result = 'n/a';
if (rows.length > 0) {
var job = find_object( rows, { action: 'job_complete' } );
if (job) nice_last_result = (job.code == 0) ? '<span class="color_label green"><i class="fa fa-check">&nbsp;</i>Success</span>' : '<span class="color_label red"><i class="fa fa-warning">&nbsp;</i>Error</span>';
}
html += '<div style="float:left; width:25%;">';
html += '<div class="info_label">AVG. ELAPSED</div>';
html += '<div class="info_value">' + get_text_from_seconds(total_elapsed / count, true, false) + '</div>';
html += '<div class="info_label">AVG. CPU</div>';
html += '<div class="info_value">' + short_float(total_cpu / count) + '%</div>';
html += '<div class="info_label">AVG. MEMORY</div>';
html += '<div class="info_value">' + get_text_from_bytes( total_mem / count ) + '</div>';
html += '</div>';
html += '<div style="float:left; width:25%;">';
html += '<div class="info_label">SUCCESS RATE</div>';
html += '<div class="info_value">' + pct(total_success, count) + '</div>';
html += '<div class="info_label">LAST RESULT</div>';
html += '<div class="info_value" style="position:relative; top:1px;">' + nice_last_result + '</div>';
html += '<div class="info_label">AVG. LOG SIZE</div>';
html += '<div class="info_value">' + get_text_from_bytes( total_log_size / count ) + '</div>';
html += '</div>';
html += '<div class="clear"></div>';
html += '</fieldset>';
// graph containers
html += '<div style="margin-top:15px;">';
html += '<div class="graph-title">Performance History</div>';
html += '<div id="d_graph_hist_perf" style="position:relative; width:100%; height:300px; overflow:hidden;"><canvas id="c_graph_hist_perf"></canvas></div>';
html += '</div>';
html += '<div style="margin-top:10px; margin-bottom:20px; height:1px; background:#ddd;"></div>';
// cpu / mem graphs
html += '<div style="margin-top:0px;">';
html += '<div style="float:left; width:50%;">';
html += '<div class="graph-title">CPU Usage History</div>';
html += '<div id="d_graph_hist_cpu" style="position:relative; width:100%; margin-right:5px; height:225px; overflow:hidden;"><canvas id="c_graph_hist_cpu"></canvas></div>';
html += '</div>';
html += '<div style="float:left; width:50%;">';
html += '<div class="graph-title">Memory Usage History</div>';
html += '<div id="d_graph_hist_mem" style="position:relative; width:100%; margin-left:5px; height:225px; overflow:hidden;"><canvas id="c_graph_hist_mem"></canvas></div>';
html += '</div>';
html += '<div class="clear"></div>';
html += '</div>';
html += '</div>'; // padding
html += '</div>'; // sidebar tabs
this.div.html( html );
// graphs
this.render_perf_line_chart();
this.render_cpu_line_chart();
this.render_mem_line_chart();
},
render_perf_line_chart: function() {
// event perf over time
var rows = this.rows;
var perf_keys = {};
var perf_data = [];
var perf_times = [];
// build perf data for chart
// read backwards as server data is unshifted (descending by date, newest first)
for (var idx = rows.length - 1; idx >= 0; idx--) {
var job = rows[idx];
if (job.action != 'job_complete') continue;
if (!job.perf) job.perf = { total: job.elapsed };
if (!isa_hash(job.perf)) job.perf = parse_query_string( job.perf.replace(/\;/g, '&') );
var pscale = 1;
if (job.perf.scale) {
pscale = job.perf.scale;
}
var perf = deep_copy_object( job.perf.perf ? job.perf.perf : job.perf );
delete perf.scale;
// remove counters from pie
for (var key in perf) {
if (key.match(/^c_/)) delete perf[key];
}
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;
}
}
}
// divide everything by scale, so we get seconds
for (var key in perf) {
perf[key] /= pscale;
}
perf_data.push( perf );
for (var key in perf) {
perf_keys[key] = 1;
}
// track times as well
perf_times.push( job.time_end || (job.time_start + job.elapsed) );
} // foreach row
// build up timestamp data
var tstamp_col = [];
for (var idy = 0, ley = perf_times.length; idy < ley; idy++) {
tstamp_col.push( perf_times[idy] * 1000 );
} // foreach row
var sorted_keys = hash_keys_to_array(perf_keys).sort();
var datasets = [];
for (var idx = 0, len = sorted_keys.length; idx < len; idx++) {
var perf_key = sorted_keys[idx];
var clr = 'rgb(' + this.graph_colors[ idx % this.graph_colors.length ] + ')';
var dataset = {
label: perf_key,
backgroundColor: clr,
borderColor: clr,
fill: false,
data: []
};
for (var idy = 0, ley = perf_data.length; idy < ley; idy++) {
var perf = perf_data[idy];
var value = Math.max( 0, perf[perf_key] || 0 );
dataset.data.push({ x: tstamp_col[idy], y: short_float(value) });
} // foreach row
datasets.push( dataset );
} // foreach key
this.charts.perf = new Chart( $('#c_graph_hist_perf').get(0).getContext('2d'), {
type: 'line',
data: { datasets: datasets },
options: {
animation: {
duration: 0
},
responsive: true,
responsiveAnimationDuration: 0,
maintainAspectRatio: false,
legend: {
display: true,
position: 'bottom',
labels: {
fontStyle: 'bold',
padding: 15
}
},
title:{
display: false,
text: ""
},
scales: {
xAxes: [{
type: "time",
display: true,
time: {
parser: 'MM/DD/YYYY HH:mm',
round: 'minute',
tooltipFormat: 'll hh:mm a'
},
scaleLabel: {
display: false,
labelString: 'Date'
}
}, ],
yAxes: [{
ticks: {
beginAtZero: true,
callback: function(value, index, values) {
if (value < 0) return '';
return '' + get_text_from_seconds_round_custom(value, true);
}
},
scaleLabel: {
display: true,
// labelString: 'value'
}
}]
},
tooltips: {
mode: 'nearest',
intersect: false,
callbacks: {
label: function(tooltip, data) {
var value = short_float(tooltip.yLabel);
if (value >= 60) value = get_text_from_seconds( Math.floor(value), 1, 0 ).replace(/&nbsp\;/ig, ' ');
else value = '' + value + " sec";
return " " + datasets[tooltip.datasetIndex].label + ": " + value;
}
}
}
}
});
},
render_cpu_line_chart: function() {
// event cpu usage over time
var rows = this.rows;
var color = Chart.helpers.color;
var col_avg = [];
var col_max = [];
// build data for chart
// read backwards as server data is unshifted (descending by date, newest first)
for (var idx = rows.length - 1; idx >= 0; idx--) {
var job = rows[idx];
if (job.action != 'job_complete') continue;
if (!job.cpu) job.cpu = {};
var x = (job.time_end || (job.time_start + job.elapsed)) * 1000;
col_avg.push({
x: x,
y: short_float( (job.cpu.total || 0) / (job.cpu.count || 1) )
});
col_max.push({
x: x,
y: short_float( job.cpu.max || 0 )
});
} // foreach row
var datasets = [
{
label: "CPU Peak",
borderColor: '#888888',
fill: false,
data: col_max
},
{
label: "CPU Avg",
borderColor: '#3f7ed5',
backgroundColor: color('#3f7ed5').alpha(0.5).rgbString(),
data: col_avg
}
];
this.charts.cpu = new Chart( $('#c_graph_hist_cpu').get(0).getContext('2d'), {
type: 'line',
data: { datasets: datasets },
options: {
animation: {
duration: 0
},
responsive: true,
responsiveAnimationDuration: 0,
maintainAspectRatio: false,
legend: {
display: true,
position: 'bottom',
labels: {
fontStyle: 'bold',
padding: 15
}
},
title:{
display: false,
text: ""
},
scales: {
xAxes: [{
type: "time",
display: true,
time: {
parser: 'MM/DD/YYYY HH:mm',
round: 'minute',
tooltipFormat: 'll hh:mm a'
},
scaleLabel: {
display: false,
labelString: 'Date'
}
}, ],
yAxes: [{
ticks: {
beginAtZero: true,
callback: function(value, index, values) {
return '' + Math.round(value) + '%';
}
},
scaleLabel: {
display: true,
// labelString: 'value'
}
}]
},
tooltips: {
mode: 'index',
intersect: false,
callbacks: {
label: function(tooltip, data) {
return " " + datasets[tooltip.datasetIndex].label + ": " + short_float(tooltip.yLabel) + '%';
}
}
}
}
});
},
render_mem_line_chart: function() {
// event mem usage over time
var rows = this.rows;
var color = Chart.helpers.color;
var col_avg = [];
var col_max = [];
// build data for chart
// read backwards as server data is unshifted (descending by date, newest first)
for (var idx = rows.length - 1; idx >= 0; idx--) {
var job = rows[idx];
if (job.action != 'job_complete') continue;
if (!job.mem) job.mem = {};
var x = (job.time_end || (job.time_start + job.elapsed)) * 1000;
col_avg.push({
x: x,
y: short_float( (job.mem.total || 0) / (job.mem.count || 1) )
});
col_max.push({
x: x,
y: short_float( job.mem.max || 0 )
});
} // foreach row
var datasets = [
{
label: "Mem Peak",
borderColor: '#888888',
fill: false,
data: col_max
},
{
label: "Mem Avg",
borderColor: '#279321',
backgroundColor: color('#279321').alpha(0.5).rgbString(),
data: col_avg
}
];
this.charts.mem = new Chart( $('#c_graph_hist_mem').get(0).getContext('2d'), {
type: 'line',
data: { datasets: datasets },
options: {
animation: {
duration: 0
},
responsive: true,
responsiveAnimationDuration: 0,
maintainAspectRatio: false,
legend: {
display: true,
position: 'bottom',
labels: {
fontStyle: 'bold',
padding: 15
}
},
title:{
display: false,
text: ""
},
scales: {
xAxes: [{
type: "time",
display: true,
time: {
parser: 'MM/DD/YYYY HH:mm',
round: 'minute',
tooltipFormat: 'll hh:mm a'
},
scaleLabel: {
display: false,
labelString: 'Date'
}
}, ],
yAxes: [{
ticks: {
beginAtZero: true,
callback: function(value, index, values) {
return '' + get_text_from_bytes(value, 1);
}
},
scaleLabel: {
display: true,
// labelString: 'value'
}
}]
},
tooltips: {
mode: 'index',
intersect: false,
callbacks: {
label: function(tooltip, data) {
return " " + datasets[tooltip.datasetIndex].label + ": " + get_text_from_bytes(tooltip.yLabel);
}
}
}
}
});
},
gosub_event_history: function(args) {
// show table of all history for a single event
if (!args.offset) args.offset = 0;
if (!args.limit) args.limit = 25;
app.api.post( 'app/get_event_history', copy_object(args), this.receive_event_history.bind(this) );
},
receive_event_history: function(resp) {
// render event history
this.lastEventHistoryResp = resp;
var html = '';
var args = this.args;
var rows = this.rows = resp.rows;
var size = get_inner_window_size();
var col_width = Math.floor( ((size.width * 0.9) - 300) / 7 );
var event = find_object( app.schedule, { id: args.id } ) || null;
if (!event) return app.doError("Could not locate event in schedule: " + args.id);
app.setWindowTitle( "Event History: " + event.title );
this.div.removeClass('loading');
html += this.getSidebarTabs( 'event_history',
[
['history', "All Completed"],
['event_stats&id=' + args.id, "Event Stats"],
['event_history', "Event History"]
]
);
html += '<div style="padding:20px 20px 30px 20px">';
var cols = ['Job ID', 'Hostname', 'Result', 'Start Date/Time', 'Elapsed Time', 'Avg CPU', 'Avg Mem'];
html += '<div class="subtitle">';
html += 'Event History: ' + event.title;
html += '<div class="clear"></div>';
html += '</div>';
var self = this;
var num_visible_items = 0;
html += this.getPaginatedTable( resp, cols, 'event', function(job, idx) {
if (job.action != 'job_complete') return null;
num_visible_items++;
var cpu_avg = 0;
var mem_avg = 0;
if (job.cpu) cpu_avg = short_float( (job.cpu.total || 0) / (job.cpu.count || 1) );
if (job.mem) mem_avg = short_float( (job.mem.total || 0) / (job.mem.count || 1) );
var tds = [
'<div class="td_big" style="white-space:nowrap;"><a href="#JobDetails?id='+job.id+'"><i class="fa fa-pie-chart">&nbsp;</i><b>' + job.id.substring(0, 11) + '</b></span></div>',
self.getNiceGroup( null, job.hostname, col_width ),
(job.code == 0) ? '<span class="color_label green"><i class="fa fa-check">&nbsp;</i>Success</span>' : '<span class="color_label red"><i class="fa fa-warning">&nbsp;</i>Error</span>',
get_nice_date_time( job.time_start, false, true ),
get_text_from_seconds( job.elapsed, true, false ),
'' + cpu_avg + '%',
get_text_from_bytes(mem_avg)
// actions.join(' | ')
];
return tds;
} );
if (resp.rows && resp.rows.length && !num_visible_items) {
html += '<tr><td colspan="'+cols.length+'" align="center" style="padding-top:10px; padding-bottom:10px; font-weight:bold;">';
html += 'All items were deleted on this page.';
html += '</td></tr>';
}
html += '</div>'; // padding
html += '</div>'; // sidebar tabs
this.div.html( html );
},
onStatusUpdate: function(data) {
// received status update (websocket), update sub-page if needed
if (data.jobs_changed && (this.args.sub == 'history')) this.get_history();
},
onResizeDelay: function(size) {
// called 250ms after latest window resize
// so we can run more expensive redraw operations
switch (this.args.sub) {
case 'history':
if (this.lastHistoryResp) {
this.receive_history( this.lastHistoryResp );
}
break;
case 'event_stats':
if (this.lastEventStatsResp) {
this.receive_event_stats( this.lastEventStatsResp );
}
break;
case 'event_history':
if (this.lastEventHistoryResp) {
this.receive_event_history( this.lastEventHistoryResp );
}
break;
}
},
onDeactivate: function() {
// called when page is deactivated
for (var key in this.charts) {
this.charts[key].destroy();
}
this.charts = {};
delete this.rows;
if (this.args && (this.args.sub == 'event_stats')) this.div.html( '' );
return true;
}
});

View file

@ -0,0 +1,634 @@
Class.subclass( Page.Base, "Page.Home", {
bar_width: 100,
onInit: function() {
// called once at page load
this.worker = new Worker('js/home-worker.js');
this.worker.onmessage = this.render_upcoming_events.bind(this);
var html = '';
html += '<div style="padding:10px 20px 20px 20px">';
// header stats
html += '<div id="d_home_header_stats"></div>';
html += '<div style="height:20px;"></div>';
// active jobs
html += '<div class="subtitle">';
html += 'Active Jobs';
html += '<div class="clear"></div>';
html += '</div>';
html += '<div id="d_home_active_jobs"></div>';
html += '<div style="height:20px;"></div>';
// queued jobs
html += '<div id="d_home_queue_container" style="display:none">';
html += '<div class="subtitle">';
html += 'Event Queues';
html += '<div class="clear"></div>';
html += '</div>';
html += '<div id="d_home_queued_jobs"></div>';
html += '<div style="height:20px;"></div>';
html += '</div>';
// upcoming events
html += '<div id="d_home_upcoming_header" class="subtitle">';
html += '</div>';
html += '<div id="d_home_upcoming_events" class="loading"></div>';
html += '</div>'; // 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 += '<div class="subtitle_widget"><i class="fa fa-search">&nbsp;</i><input type="text" id="fe_home_keywords" size="10" placeholder="Find events..." style="border:0px;" value="' + escape_text_field_value( args.keywords ) + '"/></div>';
html += '<div class="subtitle_widget"><i class="fa fa-chevron-down">&nbsp;</i><select id="fe_home_target" class="subtitle_menu" style="width:75px;" onChange="$P().set_search_filters()"><option value="">All Servers</option>' + this.render_target_menu_options( args.target ) + '</select></div>';
html += '<div class="subtitle_widget"><i class="fa fa-chevron-down">&nbsp;</i><select id="fe_home_plugin" class="subtitle_menu" style="width:75px;" onChange="$P().set_search_filters()"><option value="">All Plugins</option>' + render_menu_options( app.plugins, args.plugin, false ) + '</select></div>';
html += '<div class="subtitle_widget"><i class="fa fa-chevron-down">&nbsp;</i><select id="fe_home_cat" class="subtitle_menu" style="width:95px;" onChange="$P().set_search_filters()"><option value="">All Categories</option>' + render_menu_options( app.categories, args.category, false ) + '</select></div>';
html += '<div class="clear"></div>';
$('#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_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 || {};
html += '<fieldset style="margin-top:0px; margin-right:0px; padding-top:10px;"><legend>Server Stats</legend>';
html += '<div style="float:left; width:25%;">';
var active_events = find_objects( app.schedule, { enabled: 1 } );
html += '<div class="info_label">TOTAL EVENTS</div>';
html += '<div class="info_value">' + commify( active_events.length ) + '</div>';
html += '<div class="info_label">TOTAL CATEGORIES</div>';
html += '<div class="info_value">' + commify( app.categories.length ) + '</div>';
html += '<div class="info_label">TOTAL PLUGINS</div>';
html += '<div class="info_value">' + commify( app.plugins.length ) + '</div>';
html += '</div>';
html += '<div style="float:left; width:25%;">';
html += '<div class="info_label">JOBS COMPLETED TODAY</div>';
html += '<div class="info_value">' + commify( stats.jobs_completed || 0 ) + '</div>';
html += '<div class="info_label">JOBS FAILED TODAY</div>';
html += '<div class="info_value">' + commify( stats.jobs_failed || 0 ) + '</div>';
html += '<div class="info_label">JOB SUCCESS RATE</div>';
html += '<div class="info_value">' + pct( (stats.jobs_completed || 0) - (stats.jobs_failed || 0), stats.jobs_completed || 1 ) + '</div>';
html += '</div>';
html += '<div style="float:left; width:25%;">';
html += '<div class="info_label">TOTAL SERVERS</div>';
html += '<div class="info_value">' + commify( num_keys(servers) ) + '</div>';
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 += '<div class="info_label">TOTAL CPU IN USE</div>';
html += '<div class="info_value">' + short_float(total_cpu) + '%</div>';
html += '<div class="info_label">TOTAL RAM IN USE</div>';
html += '<div class="info_value">' + get_text_from_bytes(total_mem) + '</div>';
html += '</div>';
html += '<div style="float:left; width:25%;">';
var mserver = servers[ app.masterHostname ] || {};
html += '<div class="info_label">MASTER SERVER UPTIME</div>';
html += '<div class="info_value">' + get_text_from_seconds( mserver.uptime || 0, false, true ) + '</div>';
var job_avg = (stats.jobs_elapsed || 0) / (stats.jobs_completed || 1);
html += '<div class="info_label">AVERAGE JOB DURATION</div>';
html += '<div class="info_value">' + get_text_from_seconds( job_avg, false, true ) + '</div>';
var log_size_avg = (stats.jobs_log_size || 0) / (stats.jobs_completed || 1);
html += '<div class="info_label">AVERAGE JOB LOG SIZE</div>';
html += '<div class="info_value">' + get_text_from_bytes(log_size_avg) + '</div>';
html += '</div>';
html += '<div class="clear"></div>';
html += '</fieldset>';
$('#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 elapsed = now - this.worker_start_time;
delete this.worker_start_time;
Debug.trace('Home', "Worker elapsed time: " + elapsed + ' sec for ' + app.schedule.length + ' events.');*/
// apply filters
var events = [];
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 } ) || {};
// 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 = [
'<a href="#Schedule?sub=edit_event&id='+item.id+'"><b>Edit Event</b></a>'
];
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 tds = [
'<div class="td_big" style="white-space:nowrap;"><a href="#Schedule?sub=edit_event&id='+item.id+'">' + self.getNiceEvent('<b>' + item.title + '</b>', col_width) + '</a></div>',
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 );
},
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 = [
// '<span class="link" onMouseUp="$P().go_job_details('+idx+')"><b>Details</b></span>',
'<span class="link" onMouseUp="$P().abort_job('+idx+')"><b>Abort Job</b></span>'
];
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 = [
'<div class="td_big"><span class="link" onMouseUp="$P().go_job_details('+idx+')">' + self.getNiceJob(job.id) + '</span></div>',
self.getNiceEvent( job.event_title, col_width ),
self.getNiceCategory( cat, col_width ),
// self.getNicePlugin( plugin ),
self.getNiceGroup( null, job.hostname, col_width ),
'<div id="d_home_jt_elapsed_'+job.id+'">' + self.getNiceJobElapsedTime(job) + '</div>',
'<div id="d_home_jt_progress_'+job.id+'">' + self.getNiceJobPendingText(job) + '</div>',
'n/a',
actions.join(' | ')
];
}
else if (job.pending) {
// multiplex stagger delay
tds = [
'<div class="td_big">' + self.getNiceJob(job.id) + '</div>',
self.getNiceEvent( job.event_title, col_width ),
self.getNiceCategory( cat, col_width ),
// self.getNicePlugin( plugin ),
self.getNiceGroup( null, job.hostname, col_width ),
'n/a',
'<div id="d_home_jt_progress_'+job.id+'">' + self.getNiceJobPendingText(job) + '</div>',
'n/a',
actions.join(' | ')
];
} // pending job
else {
// active job
tds = [
'<div class="td_big"><span class="link" onMouseUp="$P().go_job_details('+idx+')">' + self.getNiceJob(job.id) + '</span></div>',
self.getNiceEvent( job.event_title, col_width ),
self.getNiceCategory( cat, col_width ),
// self.getNicePlugin( plugin ),
self.getNiceGroup( null, job.hostname, col_width ),
'<div id="d_home_jt_elapsed_'+job.id+'">' + self.getNiceJobElapsedTime(job) + '</div>',
'<div id="d_home_jt_progress_'+job.id+'">' + self.getNiceJobProgressBar(job) + '</div>',
'<div id="d_home_jt_remaining_'+job.id+'">' + self.getNiceJobRemainingTime(job) + '</div>',
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 = [
'<span class="link" onMouseUp="$P().flush_event_queue('+idx+')"><b>Flush Queue</b></span>'
];
var tds = [
'<div class="td_big" style="white-space:nowrap;"><a href="#Schedule?sub=edit_event&id='+item.id+'">' + self.getNiceEvent('<b>' + item.title + '</b>', col_width) + '</a></div>',
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( '<span style="color:red">Abort Job</span>', "Are you sure you want to abort the job &ldquo;<b>"+job.id+"</b>&rdquo;?</br>(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( '<span style="color:red">Flush Event Queue</span>', "Are you sure you want to flush the queue for event &ldquo;<b>"+stub.title+"</b>&rdquo;?", "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 += '<div class="progress_bar_container '+extra_classes+'" style="width:'+this.bar_width+'px; margin:0;" '+extra_attribs+'>';
html += '<div class="progress_bar_inner" style="width:'+cx+'px;"></div>';
html += '</div>';
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':
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_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;
}
} );

File diff suppressed because it is too large Load diff

View file

@ -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 += '<iframe name="i_login" id="i_login" src="blank.html" width="1" height="1" style="display:none"></iframe>';
// html += '<form id="f_login" method="post" action="/api/user/login?format=jshtml&callback=window.parent.%24P%28%29.doFrameLogin" target="i_login">';
html += '<div class="inline_dialog_container">';
html += '<div class="dialog_title shade-light">User Login</div>';
html += '<div class="dialog_content">';
html += '<center><table style="margin:0px;">';
html += '<tr>';
html += '<td align="right" class="table_label">Username:</td>';
html += '<td align="left" class="table_value"><div><input type="text" name="username" id="fe_login_username" size="30" spellcheck="false" value="'+(app.getPref('username') || '')+'"/></div></td>';
html += '</tr>';
html += '<tr><td colspan="2"><div class="table_spacer"></div></td></tr>';
html += '<tr>';
html += '<td align="right" class="table_label">Password:</td>';
html += '<td align="left" class="table_value"><div><input type="' + app.get_password_type() + '" name="password" id="fe_login_password" size="30" spellcheck="false" value=""/>' + app.get_password_toggle_html() + '</div></td>';
html += '</tr>';
html += '<tr><td colspan="2"><div class="table_spacer"></div></td></tr>';
html += '</table></center>';
html += '</div>';
html += '<div class="dialog_buttons"><center><table><tr>';
if (config.free_accounts) {
html += '<td><div class="button" style="width:120px; font-weight:normal;" onMouseUp="$P().navCreateAccount()">Create Account...</div></td>';
html += '<td width="20">&nbsp;</td>';
}
html += '<td><div class="button" style="width:120px; font-weight:normal;" onMouseUp="$P().navPasswordRecovery()">Forgot Password...</div></td>';
html += '<td width="20">&nbsp;</td>';
html += '<td><div class="button" style="width:120px;" onMouseUp="$P().doLogin()"><i class="fa fa-sign-in">&nbsp;&nbsp;</i>Login</div></td>';
html += '</tr></table></center></div>';
html += '</div>';
// html += '<input type="submit" value="Login" style="position:absolute; left:-9999px; top:0px;">';
html += '</form>';
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 += '<div class="inline_dialog_container">';
html += '<div class="dialog_title shade-light">Create Account</div>';
html += '<div class="dialog_content">';
html += '<center><table style="margin:0px;">';
html += get_form_table_row( 'Username:',
'<table cellspacing="0" cellpadding="0"><tr>' +
'<td><input type="text" id="fe_ca_username" size="20" style="font-size:14px;" value="" spellcheck="false" onChange="$P().checkUserExists(\'ca\')"/></td>' +
'<td><div id="d_ca_valid" style="margin-left:5px; font-weight:bold;"></div></td>' +
'</tr></table>'
);
html += get_form_table_caption('Choose a unique alphanumeric username for your account.') +
get_form_table_spacer() +
get_form_table_row('Password:', '<input type="' + app.get_password_type() + '" id="fe_ca_password" size="30" value="" spellcheck="false"/>' + 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:', '<input type="text" id="fe_ca_fullname" size="30" value="" spellcheck="false"/>') +
get_form_table_caption('This is used for display purposes only.') +
get_form_table_spacer() +
get_form_table_row('Email Address:', '<input type="text" id="fe_ca_email" size="30" value="" spellcheck="false"/>') +
get_form_table_caption('This is used only to recover your password should you lose it.');
html += '</table></center>';
html += '</div>';
html += '<div class="dialog_buttons"><center><table><tr>';
html += '<td><div class="button" style="width:120px; font-weight:normal;" onMouseUp="$P().cancel()">Cancel</div></td>';
html += '<td width="50">&nbsp;</td>';
html += '<td><div class="button" style="width:120px;" onMouseUp="$P().doCreateAccount()"><i class="fa fa-user-plus">&nbsp;&nbsp;</i>Create</div></td>';
html += '</tr></table></center></div>';
html += '</div>';
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\-\.]+$/)) {
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( '<span style="color:red">Insecure Password Warning</span>', 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 += '<div class="inline_dialog_container">';
html += '<div class="dialog_title shade-light">Forgot Password</div>';
html += '<div class="dialog_content">';
html += '<center><table style="margin:0px;">';
html += get_form_table_row('Username:', '<input type="text" id="fe_pr_username" size="30" value="" spellcheck="false"/>') +
get_form_table_spacer() +
get_form_table_row('Email Address:', '<input type="text" id="fe_pr_email" size="30" value="" spellcheck="false"/>');
html += '</table></center>';
html += '<div class="caption" style="margin-top:15px;">Please enter the username and e-mail address associated with your account, and we will send you instructions for resetting your password.</div>';
html += '</div>';
html += '<div class="dialog_buttons"><center><table><tr>';
html += '<td><div class="button" style="width:120px; font-weight:normal;" onMouseUp="$P().cancel()">Cancel</div></td>';
html += '<td width="50">&nbsp;</td>';
html += '<td><div class="button" style="width:120px;" onMouseUp="$P().doSendRecoveryEmail()"><i class="fa fa-envelope-o">&nbsp;&nbsp;</i>Send Email</div></td>';
html += '</tr></table></center></div>';
html += '</div>';
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 += '<div class="inline_dialog_container">';
html += '<div class="dialog_title shade-light">Reset Password</div>';
html += '<div class="dialog_content">';
html += '<center><table style="margin:0px;">';
html += '<tr>';
html += '<td align="right" class="table_label">Username:</td>';
html += '<td align="left" class="table_value"><div><input type="text" name="username" id="fe_reset_username" size="30" spellcheck="false" value="'+args.u+'" disabled="disabled"/></div></td>';
html += '</tr>';
html += '<tr><td colspan="2"><div class="table_spacer"></div></td></tr>';
html += '<tr>';
html += '<td align="right" class="table_label">New Password:</td>';
html += '<td align="left" class="table_value"><div><input type="' + app.get_password_type() + '" name="password" id="fe_reset_password" size="30" spellcheck="false" value=""/>' + app.get_password_toggle_html() + '</div></td>';
html += '</tr>';
html += '<tr><td colspan="2"><div class="table_spacer"></div></td></tr>';
html += '</table></center>';
html += '</div>';
html += '<div class="dialog_buttons"><center><table><tr>';
html += '<td><div class="button" style="width:130px;" onMouseUp="$P().doResetPassword()"><i class="fa fa-key">&nbsp;&nbsp;</i>Reset Password</div></td>';
html += '</tr></table></center></div>';
html += '</div>';
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( '<span style="color:red">Insecure Password Warning</span>', 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;
}
} );

View file

@ -0,0 +1,181 @@
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 += '<div style="padding:50px 20px 50px 20px">';
html += '<center>';
html += '<table><tr>';
html += '<td valign="top" style="vertical-align:top">';
html += '<table style="margin:0;">';
// user id
html += get_form_table_row( 'Username', '<div style="font-size: 14px;"><b>' + app.username + '</b></div>' );
html += get_form_table_caption( "Your username cannot be changed." );
html += get_form_table_spacer();
// full name
html += get_form_table_row( 'Full Name', '<input type="text" id="fe_ma_fullname" size="30" value="'+escape_text_field_value(user.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', '<input type="text" id="fe_ma_email" size="30" value="'+escape_text_field_value(user.email)+'"/>' );
html += get_form_table_caption( "This is used to generate your profile pic, and to<br/>recover your password if you forget it." );
html += get_form_table_spacer();
// current password
html += get_form_table_row( 'Current Password', '<input type="' + app.get_password_type() + '" id="fe_ma_old_password" size="30" value="" spellcheck="false"/>' + 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', '<input type="' + app.get_password_type() + '" id="fe_ma_new_password" size="30" value="" spellcheck="false"/>' + 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 += '<tr><td colspan="2" align="center">';
html += '<div style="height:30px;"></div>';
html += '<table><tr>';
html += '<td><div class="button" style="width:130px; font-weight:normal;" onMouseUp="$P().show_delete_account_dialog()">Delete Account...</div></td>';
html += '<td width="80">&nbsp;</td>';
html += '<td><div class="button" style="width:130px;" onMouseUp="$P().save_changes()"><i class="fa fa-floppy-o">&nbsp;&nbsp;</i>Save Changes</div></td>';
html += '</tr></table>';
html += '</td></tr>';
html += '</table>';
html += '</center>';
html += '</td>';
html += '<td valign="top" align="left" style="vertical-align:top; text-align:left;">';
// gravar profile image and edit button
html += '<fieldset style="width:150px; margin-left:40px; background:white; border:1px solid #ddd; box-shadow:none;"><legend>Profile Picture</legend>';
if (app.config.external_users) {
html += '<div id="d_ma_image" style="width:128px; height:128px; margin:5px auto 0 auto; background-image:url('+app.getUserAvatarURL(128)+'); cursor:default;"></div>';
}
else {
html += '<div id="d_ma_image" style="width:128px; height:128px; margin:5px auto 0 auto; background-image:url('+app.getUserAvatarURL(128)+'); cursor:pointer;" onMouseUp="$P().edit_gravatar()"></div>';
html += '<div class="button mini" style="margin:10px auto 5px auto;" onMouseUp="$P().edit_gravatar()">Edit Image...</div>';
html += '<div style="font-size:11px; color:#888; text-align:center; margin-bottom:5px;">Image services provided by <a href="https://en.gravatar.com/connect/" target="_blank">Gravatar.com</a>.</div>';
}
html += '</fieldset>';
html += '</td>';
html += '</tr></table>';
html += '</div>'; // 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( '<span style="color:red">Insecure Password Warning</span>', 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 <b>permanently delete</b> 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;
}
} );

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,340 @@
// 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"],
['api_keys', "API Keys"],
['categories', "Categories"],
['plugins', "Plugins"],
['servers', "Servers"],
['users', "Users"]
]
);
var cols = ['App Title', 'API Key', 'Status', 'Author', 'Created', 'Actions'];
html += '<div style="padding:20px 20px 30px 20px">';
html += '<div class="subtitle">';
html += 'API Keys';
html += '<div class="clear"></div>';
html += '</div>';
var self = this;
html += this.getBasicTable( this.api_keys, cols, 'key', function(item, idx) {
var actions = [
'<span class="link" onMouseUp="$P().edit_api_key('+idx+')"><b>Edit</b></span>',
'<span class="link" onMouseUp="$P().delete_api_key('+idx+')"><b>Delete</b></span>'
];
return [
'<div class="td_big">' + self.getNiceAPIKey(item, true, col_width) + '</div>',
'<div style="">' + encode_entities(item.key) + '</div>',
item.active ? '<span class="color_label green"><i class="fa fa-check">&nbsp;</i>Active</span>' : '<span class="color_label red"><i class="fa fa-warning">&nbsp;</i>Suspended</span>',
self.getNiceUsername(item.username, true, col_width),
'<span title="'+get_nice_date_time(item.created, true)+'">'+get_nice_date(item.created, true)+'</span>',
actions.join(' | ')
];
} );
html += '<div style="height:30px;"></div>';
html += '<center><table><tr>';
html += '<td><div class="button" style="width:130px;" onMouseUp="$P().edit_api_key(-1)"><i class="fa fa-plus-circle">&nbsp;&nbsp;</i>Add API Key...</div></td>';
html += '</tr></table></center>';
html += '</div>'; // padding
html += '</div>'; // 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"],
['api_keys', "API Keys"],
['new_api_key', "New API Key"],
['categories', "Categories"],
['plugins', "Plugins"],
['servers', "Servers"],
['users', "Users"]
]
);
html += '<div style="padding:20px;"><div class="subtitle">New API Key</div></div>';
html += '<div style="padding:0px 20px 50px 20px">';
html += '<center><table style="margin:0;">';
this.api_key = { privileges: {}, key: get_unique_id() };
html += this.get_api_key_edit_html();
// buttons at bottom
html += '<tr><td colspan="2" align="center">';
html += '<div style="height:30px;"></div>';
html += '<table><tr>';
html += '<td><div class="button" style="width:120px; font-weight:normal;" onMouseUp="$P().cancel_api_key_edit()">Cancel</div></td>';
html += '<td width="50">&nbsp;</td>';
html += '<td><div class="button" style="width:120px;" onMouseUp="$P().do_new_api_key()"><i class="fa fa-plus-circle">&nbsp;&nbsp;</i>Create Key</div></td>';
html += '</tr></table>';
html += '</td></tr>';
html += '</table></center>';
html += '</div>'; // table wrapper div
html += '</div>'; // 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"],
['api_keys', "API Keys"],
['edit_api_key', "Edit API Key"],
['categories', "Categories"],
['plugins', "Plugins"],
['servers', "Servers"],
['users', "Users"]
]
);
html += '<div style="padding:20px;"><div class="subtitle">Editing API Key &ldquo;' + (this.api_key.title) + '&rdquo;</div></div>';
html += '<div style="padding:0px 20px 50px 20px">';
html += '<center>';
html += '<table style="margin:0;">';
html += this.get_api_key_edit_html();
html += '<tr><td colspan="2" align="center">';
html += '<div style="height:30px;"></div>';
html += '<table><tr>';
html += '<td><div class="button" style="width:130px; font-weight:normal;" onMouseUp="$P().cancel_api_key_edit()">Cancel</div></td>';
html += '<td width="50">&nbsp;</td>';
html += '<td><div class="button" style="width:130px; font-weight:normal;" onMouseUp="$P().show_delete_api_key_dialog()">Delete Key...</div></td>';
html += '<td width="50">&nbsp;</td>';
html += '<td><div class="button" style="width:130px;" onMouseUp="$P().do_save_api_key()"><i class="fa fa-floppy-o">&nbsp;&nbsp;</i>Save Changes</div></td>';
html += '</tr></table>';
html += '</td></tr>';
html += '</table>';
html += '</center>';
html += '</div>'; // table wrapper div
html += '</div>'; // 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( '<span style="color:red">Delete API Key</span>', "Are you sure you want to <b>permanently delete</b> 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', '<input type="text" id="fe_ak_key" size="35" value="'+escape_text_field_value(api_key.key)+'" spellcheck="false"/>&nbsp;<span class="link addme" onMouseUp="$P().generate_key()">&laquo; Generate Random</span>' );
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', '<select id="fe_ak_status">' + render_menu_options([[1,'Active'], [0,'Disabled']], api_key.active) + '</select>' );
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', '<input type="text" id="fe_ak_title" size="30" value="'+escape_text_field_value(api_key.title)+'" spellcheck="false"/>' );
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', '<textarea id="fe_ak_desc" style="width:550px; height:50px; resize:vertical;">'+escape_text_field_value(api_key.description)+'</textarea>');
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 += '<div style="margin-top:4px; margin-bottom:4px;">';
priv_html += '<input type="checkbox" id="fe_ak_priv_'+priv.id+'" value="1" '+(has_priv ? 'checked="checked"' : '')+'>';
priv_html += '<label for="fe_ak_priv_'+priv.id+'">'+priv.title+'</label>';
priv_html += '</div>';
}
}
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() );
}
});

View file

@ -0,0 +1,246 @@
// Cronicle Admin Page -- Activity Log
Class.add( Page.Admin, {
activity_types: {
'^cat': '<i class="fa fa-folder-open-o">&nbsp;</i>Category',
'^group': '<i class="mdi mdi-server-network">&nbsp;</i>Group',
'^plugin': '<i class="fa fa-plug">&nbsp;</i>Plugin',
// '^apikey': '<i class="fa fa-key">&nbsp;</i>API Key',
'^apikey': '<i class="mdi mdi-key-variant">&nbsp;</i>API Key',
'^event': '<i class="fa fa-clock-o">&nbsp;</i>Event',
'^user': '<i class="fa fa-user">&nbsp;&nbsp;</i>User',
'server': '<i class="mdi mdi-desktop-tower mdi-lg">&nbsp;</i>Server',
'^job': '<i class="fa fa-pie-chart">&nbsp;</i>Job',
'^state': '<i class="mdi mdi-calendar-clock">&nbsp;</i>Scheduler', // mdi-lg
'^error': '<i class="fa fa-exclamation-triangle">&nbsp;</i>Error',
'^warning': '<i class="fa fa-exclamation-circle">&nbsp;</i>Warning'
},
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;
var html = '';
this.div.removeClass('loading');
html += this.getSidebarTabs( 'activity',
[
['activity', "Activity Log"],
['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 += '<div style="padding:20px 20px 30px 20px">';
html += '<div class="subtitle">';
html += 'Activity Log';
// html += '<div class="clear"></div>';
html += '</div>';
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: <b>' + item.cat.title + '</b>';
break;
case 'cat_update':
desc = 'Category updated: <b>' + item.cat.title + '</b>';
break;
case 'cat_delete':
desc = 'Category deleted: <b>' + item.cat.title + '</b>';
break;
// groups
case 'group_create':
desc = 'New server group created: <b>' + item.group.title + '</b>';
break;
case 'group_update':
desc = 'Server group updated: <b>' + item.group.title + '</b>';
break;
case 'group_delete':
desc = 'Server group deleted: <b>' + item.group.title + '</b>';
break;
// plugins
case 'plugin_create':
desc = 'New Plugin created: <b>' + item.plugin.title + '</b>';
break;
case 'plugin_update':
desc = 'Plugin updated: <b>' + item.plugin.title + '</b>';
break;
case 'plugin_delete':
desc = 'Plugin deleted: <b>' + item.plugin.title + '</b>';
break;
// api keys
case 'apikey_create':
desc = 'New API Key created: <b>' + item.api_key.title + '</b> (Key: ' + item.api_key.key + ')';
actions.push( '<a href="#Admin?sub=edit_api_key&id='+item.api_key.id+'">Edit Key</a>' );
break;
case 'apikey_update':
desc = 'API Key updated: <b>' + item.api_key.title + '</b> (Key: ' + item.api_key.key + ')';
actions.push( '<a href="#Admin?sub=edit_api_key&id='+item.api_key.id+'">Edit Key</a>' );
break;
case 'apikey_delete':
desc = 'API Key deleted: <b>' + item.api_key.title + '</b> (Key: ' + item.api_key.key + ')';
break;
// events
case 'event_create':
desc = 'New event added: <b>' + item.event.title + '</b>';
desc += " (" + summarize_event_timing(item.event.timing, item.event.timezone) + ")";
actions.push( '<a href="#Schedule?sub=edit_event&id='+item.event.id+'">Edit Event</a>' );
break;
case 'event_update':
desc = 'Event updated: <b>' + item.event.title + '</b>';
actions.push( '<a href="#Schedule?sub=edit_event&id='+item.event.id+'">Edit Event</a>' );
break;
case 'event_delete':
desc = 'Event deleted: <b>' + item.event.title + '</b>';
break;
// users
case 'user_create':
desc = 'New user account created: <b>' + item.user.username + "</b> (" + item.user.full_name + ")";
actions.push( '<a href="#Admin?sub=edit_user&username='+item.user.username+'">Edit User</a>' );
break;
case 'user_update':
desc = 'User account updated: <b>' + item.user.username + "</b> (" + item.user.full_name + ")";
actions.push( '<a href="#Admin?sub=edit_user&username='+item.user.username+'">Edit User</a>' );
break;
case 'user_delete':
desc = 'User account deleted: <b>' + item.user.username + "</b> (" + item.user.full_name + ")";
break;
case 'user_login':
desc = "User logged in: <b>" + item.user.username + "</b> (" + item.user.full_name + ")";
break;
// servers
case 'add_server': // legacy
case 'server_add': // current
desc = 'Server '+(item.manual ? 'manually ' : '')+'added to cluster: <b>' + item.hostname + '</b>';
break;
case 'remove_server': // legacy
case 'server_remove': // current
desc = 'Server '+(item.manual ? 'manually ' : '')+'removed from cluster: <b>' + item.hostname + '</b>';
break;
case 'master_server': // legacy
case 'server_master': // current
desc = 'Server has become primary: <b>' + item.hostname + '</b>';
break;
case 'server_restart':
desc = 'Server restarted: <b>' + item.hostname + '</b>';
break;
case 'server_shutdown':
desc = 'Server shut down: <b>' + item.hostname + '</b>';
break;
case 'server_disable':
desc = 'Lost connectivity to server: <b>' + item.hostname + '</b>';
color = 'yellow';
break;
case 'server_enable':
desc = 'Reconnected to server: <b>' + item.hostname + '</b>';
break;
// jobs
case 'job_run':
var event = find_object( app.schedule, { id: item.event } ) || { title: 'Unknown Event' };
desc = 'Job <b>#'+item.id+'</b> ('+event.title+') manually started';
actions.push( '<a href="#JobDetails?id='+item.id+'">Job Details</a>' );
break;
case 'job_complete':
var event = find_object( app.schedule, { id: item.event } ) || { title: 'Unknown Event' };
if (!item.code) {
desc = 'Job <b>#'+item.id+'</b> ('+event.title+') on server <b>'+item.hostname.replace(/\.[\w\-]+\.\w+$/, '')+'</b> completed successfully';
}
else {
desc = 'Job <b>#'+item.id+'</b> ('+event.title+') on server <b>'+item.hostname.replace(/\.[\w\-]+\.\w+$/, '')+'</b> failed with error: ' + encode_entities(item.description || 'Unknown Error');
if (desc.match(/\n/)) desc = desc.split(/\n/).shift() + "...";
color = 'red';
}
actions.push( '<a href="#JobDetails?id='+item.id+'">Job Details</a>' );
break;
case 'job_delete':
var event = find_object( app.schedule, { id: item.event } ) || { title: 'Unknown Event' };
desc = 'Job <b>#'+item.id+'</b> ('+event.title+') manually deleted';
break;
// scheduler
case 'state_update':
desc = 'Scheduler was <b>' + (item.enabled ? 'enabled' : 'disabled') + '</b>';
break;
// errors
case 'error':
desc = encode_entities( item.description );
color = 'red';
break;
// warnings
case 'warning':
desc = encode_entities( item.description );
color = 'yellow';
break;
} // action
var tds = [
'<div style="white-space:nowrap;">' + get_nice_date_time( item.epoch || 0, false, true ) + '</div>',
'<div class="td_big" style="white-space:nowrap; font-size:12px; font-weight:normal;">' + item_type + '</div>',
'<div class="activity_desc">' + desc + '</div>',
'<div style="white-space:nowrap;">' + self.getNiceUsername(item, true) + '</div>',
(item.ip || 'n/a').replace(/^\:\:ffff\:(\d+\.\d+\.\d+\.\d+)$/, '$1'),
'<div style="white-space:nowrap;">' + actions.join(' | ') + '</div>'
];
if (color) tds.className = color;
return tds;
} );
html += '</div>'; // padding
html += '</div>'; // sidebar tabs
this.div.html( html );
}
});

View file

@ -0,0 +1,495 @@
// 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"],
['api_keys', "API Keys"],
['categories', "Categories"],
['plugins', "Plugins"],
['servers', "Servers"],
['users', "Users"]
]
);
var cols = ['Title', 'Description', 'Assigned Events', 'Max Concurrent', 'Actions'];
html += '<div style="padding:20px 20px 30px 20px">';
html += '<div class="subtitle">';
html += 'Event Categories';
// html += '<div class="clear"></div>';
html += '</div>';
// 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 = [
'<span class="link" onMouseUp="$P().edit_category('+idx+')"><b>Edit</b></span>',
'<span class="link" onMouseUp="$P().delete_category('+idx+')"><b>Delete</b></span>'
];
var cat_events = find_objects( app.schedule, { category: cat.id } );
var num_events = cat_events.length;
var tds = [
'<div class="td_big"><span class="link" onMouseUp="$P().edit_category('+idx+')">' + self.getNiceCategory(cat, col_width) + '</span></div>',
'<div class="ellip" style="max-width:'+col_width+'px;">' + encode_entities(cat.description || '(No description)') + '</div>',
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 += '<div style="height:30px;"></div>';
html += '<center><table><tr>';
html += '<td><div class="button" style="width:130px;" onMouseUp="$P().edit_category(-1)"><i class="fa fa-plus-circle">&nbsp;&nbsp;</i>Add Category...</div></td>';
html += '</tr></table></center>';
html += '</div>'; // padding
html += '</div>'; // 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"],
['api_keys', "API Keys"],
['categories', "Categories"],
['new_category', "New Category"],
['plugins', "Plugins"],
['servers', "Servers"],
['users', "Users"]
]
);
html += '<div style="padding:20px;"><div class="subtitle">Add New Category</div></div>';
html += '<div style="padding:0px 20px 50px 20px">';
html += '<center><table style="margin:0;">';
this.category = {
title: "",
description: "",
max_children: 0,
enabled: 1
};
html += this.get_category_edit_html();
// buttons at bottom
html += '<tr><td colspan="2" align="center">';
html += '<div style="height:30px;"></div>';
html += '<table><tr>';
html += '<td><div class="button" style="width:120px; font-weight:normal;" onMouseUp="$P().cancel_category_edit()">Cancel</div></td>';
html += '<td width="50">&nbsp;</td>';
html += '<td><div class="button" style="width:120px;" onMouseUp="$P().do_new_category()"><i class="fa fa-plus-circle">&nbsp;&nbsp;</i>Add Category</div></td>';
html += '</tr></table>';
html += '</td></tr>';
html += '</table></center>';
html += '</div>'; // table wrapper div
html += '</div>'; // 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"],
['api_keys', "API Keys"],
['categories', "Categories"],
['edit_category', "Edit Category"],
['plugins', "Plugins"],
['servers', "Servers"],
['users', "Users"]
]
);
html += '<div style="padding:20px;"><div class="subtitle">Editing Category &ldquo;' + (this.category.title) + '&rdquo;</div></div>';
html += '<div style="padding:0px 20px 50px 20px">';
html += '<center>';
html += '<table style="margin:0;">';
html += this.get_category_edit_html();
html += '<tr><td colspan="2" align="center">';
html += '<div style="height:30px;"></div>';
html += '<table><tr>';
html += '<td><div class="button" style="width:130px; font-weight:normal;" onMouseUp="$P().cancel_category_edit()">Cancel</div></td>';
html += '<td width="50">&nbsp;</td>';
html += '<td><div class="button" style="width:130px; font-weight:normal;" onMouseUp="$P().show_delete_category_dialog()">Delete Category...</div></td>';
html += '<td width="50">&nbsp;</td>';
html += '<td><div class="button" style="width:130px;" onMouseUp="$P().do_save_category()"><i class="fa fa-floppy-o">&nbsp;&nbsp;</i>Save Changes</div></td>';
html += '</tr></table>';
html += '</td></tr>';
html += '</table>';
html += '</center>';
html += '</div>'; // table wrapper div
html += '</div>'; // 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( '<span style="color:red">Abort Jobs</span>', "There " + ((jobs.length != 1) ? 'are' : 'is') + " currently still " + jobs.length + " active " + pluralize('job', jobs.length) + " using the disabled category <b>"+category.title+"</b>. 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( '<span style="color:red">Delete Category</span>', "Are you sure you want to delete the category <b>"+cat.title+"</b>? 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', '<div style="font-size:14px;">' + cat.id + '</div>' );
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:', '<input type="text" id="fe_ec_title" size="25" value="'+escape_text_field_value(cat.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', '<input type="checkbox" id="fe_ec_enabled" value="1" ' + (cat.enabled ? 'checked="checked"' : '') + '/><label for="fe_ec_enabled">Category Enabled</label>' );
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:', '<textarea id="fe_ec_desc" style="width:500px; height:50px; resize:vertical;">'+escape_text_field_value(cat.description)+'</textarea>') +
get_form_table_caption("Optionally enter a description for the category.") +
get_form_table_spacer();
// max concurrent
html += get_form_table_row('Max Concurrent:', '<select id="fe_ec_max_children">' + render_menu_options([ [0,'No Limit'], 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32 ], cat.max_children, true) + '</select>') +
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 += '<div class="swatch ' + color + ' ' + ((current_color == color) ? 'active' : '') + '" onMouseUp="$P().select_color(\''+color+'\')"></div>';
}
swatch_html += '<div class="clear"></div>';
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',
'<div style="font-size:13px;'+(notif_expanded ? 'display:none;' : '')+'"><span class="link addme" onMouseUp="$P().expand_fieldset($(this))"><i class="fa fa-plus-square-o">&nbsp;</i>Default Notification Options</span></div>' +
'<fieldset style="padding:10px 10px 0 10px; margin-bottom:5px;'+(notif_expanded ? '' : 'display:none;')+'"><legend class="link addme" onMouseUp="$P().collapse_fieldset($(this))"><i class="fa fa-minus-square-o">&nbsp;</i>Default Notification Options</legend>' +
'<div class="plugin_params_label">Default Email on Success:</div>' +
'<div class="plugin_params_content"><input type="text" id="fe_ec_notify_success" size="50" value="'+escape_text_field_value(cat.notify_success)+'" placeholder="email@sample.com" spellcheck="false" onChange="$P().update_add_remove_me($(this))"/><span class="link addme" onMouseUp="$P().add_remove_me($(this).prev())"></span></div>' +
'<div class="plugin_params_label">Default Email on Failure:</div>' +
'<div class="plugin_params_content"><input type="text" id="fe_ec_notify_fail" size="50" value="'+escape_text_field_value(cat.notify_fail)+'" placeholder="email@sample.com" spellcheck="false" onChange="$P().update_add_remove_me($(this))"/><span class="link addme" onMouseUp="$P().add_remove_me($(this).prev())"></span></div>' +
'<div class="plugin_params_label">Default Web Hook URL:</div>' +
'<div class="plugin_params_content"><input type="text" id="fe_ec_web_hook" size="60" value="'+escape_text_field_value(cat.web_hook)+'" placeholder="http://" spellcheck="false"/></div>' +
'</fieldset>'
);
html += get_form_table_caption( "Optionally enter default e-mail addresses for notification, and/or a web hook URL.<br/>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',
'<div style="font-size:13px;'+(res_expanded ? 'display:none;' : '')+'"><span class="link addme" onMouseUp="$P().expand_fieldset($(this))"><i class="fa fa-plus-square-o">&nbsp;</i>Default Resource Limits</span></div>' +
'<fieldset style="padding:10px 10px 0 10px; margin-bottom:5px;'+(res_expanded ? '' : 'display:none;')+'"><legend class="link addme" onMouseUp="$P().collapse_fieldset($(this))"><i class="fa fa-minus-square-o">&nbsp;</i>Default Resource Limits</legend>' +
'<div class="plugin_params_label">Default CPU Limit:</div>' +
'<div class="plugin_params_content"><table cellspacing="0" cellpadding="0" class="fieldset_params_table"><tr>' +
'<td style="padding-right:2px"><input type="checkbox" id="fe_ec_cpu_enabled" value="1" '+(cat.cpu_limit ? 'checked="checked"' : '')+' /></td>' +
'<td><label for="fe_ec_cpu_enabled">Abort job if CPU exceeds</label></td>' +
'<td><input type="text" id="fe_ec_cpu_limit" style="width:30px;" value="'+(cat.cpu_limit || 0)+'"/>%</td>' +
'<td>for</td>' +
'<td>' + this.get_relative_time_combo_box( 'fe_ec_cpu_sustain', cat.cpu_sustain, 'fieldset_params_table' ) + '</td>' +
'</tr></table></div>' +
'<div class="plugin_params_label">Default Memory Limit:</div>' +
'<div class="plugin_params_content"><table cellspacing="0" cellpadding="0" class="fieldset_params_table"><tr>' +
'<td style="padding-right:2px"><input type="checkbox" id="fe_ec_memory_enabled" value="1" '+(cat.memory_limit ? 'checked="checked"' : '')+' /></td>' +
'<td><label for="fe_ec_memory_enabled">Abort job if memory exceeds</label></td>' +
'<td>' + this.get_relative_size_combo_box( 'fe_ec_memory_limit', cat.memory_limit, 'fieldset_params_table' ) + '</td>' +
'<td>for</td>' +
'<td>' + this.get_relative_time_combo_box( 'fe_ec_memory_sustain', cat.memory_sustain, 'fieldset_params_table' ) + '</td>' +
'</tr></table></div>' +
'<div class="plugin_params_label">Default Log Size Limit:</div>' +
'<div class="plugin_params_content"><table cellspacing="0" cellpadding="0" class="fieldset_params_table"><tr>' +
'<td style="padding-right:2px"><input type="checkbox" id="fe_ec_log_enabled" value="1" '+(cat.log_max_size ? 'checked="checked"' : '')+' /></td>' +
'<td><label for="fe_ec_log_enabled">Abort job if log file exceeds</label></td>' +
'<td>' + this.get_relative_size_combo_box( 'fe_ec_log_limit', cat.log_max_size, 'fieldset_params_table' ) + '</td>' +
'</tr></table></div>' +
'</fieldset>'
);
html += get_form_table_caption(
"Optionally set default CPU load, memory usage and log size limits for the category.<br/>Note that events can override any of these limits."
);
html += get_form_table_spacer();
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.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;
}
});

View file

@ -0,0 +1,686 @@
// 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"],
['api_keys', "API Keys"],
['categories', "Categories"],
['plugins', "Plugins"],
['servers', "Servers"],
['users', "Users"]
]
);
var cols = ['Plugin Name', 'Author', '# of Events', 'Created', 'Modified', 'Actions'];
// html += '<div style="padding:5px 15px 15px 15px;">';
html += '<div style="padding:20px 20px 30px 20px">';
html += '<div class="subtitle">';
html += 'Plugins';
// html += '<div class="clear"></div>';
html += '</div>';
// 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 = [
'<span class="link" onMouseUp="$P().edit_plugin('+idx+')"><b>Edit</b></span>',
'<span class="link" onMouseUp="$P().delete_plugin('+idx+')"><b>Delete</b></span>'
];
var plugin_events = find_objects( app.schedule, { plugin: plugin.id } );
var num_events = plugin_events.length;
var tds = [
'<div class="td_big"><a href="#Admin?sub=edit_plugin&id='+plugin.id+'">' + self.getNicePlugin(plugin, col_width) + '</a></div>',
self.getNiceUsername(plugin, true, col_width),
num_events ? commify( num_events ) : '(None)',
'<span title="'+get_nice_date_time(plugin.created, true)+'">'+get_nice_date(plugin.created, true)+'</span>',
'<span title="'+get_nice_date_time(plugin.modified, true)+'">'+get_nice_date(plugin.modified, true)+'</span>',
actions.join(' | ')
];
if (!plugin.enabled) {
if (tds.className) tds.className += ' '; else tds.className = '';
tds.className += 'disabled';
}
return tds;
} );
html += '<div style="height:30px;"></div>';
html += '<center><table><tr>';
html += '<td><div class="button" style="width:140px;" onMouseUp="$P().edit_plugin(-1)"><i class="fa fa-plus-circle">&nbsp;&nbsp;</i>Add New Plugin...</div></td>';
html += '</tr></table></center>';
html += '</div>'; // padding
html += '</div>'; // 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( '<span style="color:red">Delete Plugin</span>', "Are you sure you want to delete the plugin <b>"+plugin.title+"</b>? 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"],
['api_keys', "API Keys"],
['categories', "Categories"],
['plugins', "Plugins"],
['new_plugin', "Add New Plugin"],
['servers', "Servers"],
['users', "Users"]
]
);
html += '<div style="padding:20px;"><div class="subtitle">Add New Plugin</div></div>';
html += '<div style="padding:0px 20px 50px 20px">';
html += '<center><table style="margin:0;">';
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 += '<tr><td colspan="2" align="center">';
html += '<div style="height:30px;"></div>';
html += '<table><tr>';
html += '<td><div class="button" style="width:120px; font-weight:normal;" onMouseUp="$P().cancel_plugin_edit()">Cancel</div></td>';
html += '<td width="50">&nbsp;</td>';
html += '<td><div class="button" style="width:120px;" onMouseUp="$P().do_new_plugin()"><i class="fa fa-plus-circle">&nbsp;&nbsp;</i>Create Plugin</div></td>';
html += '</tr></table>';
html += '</td></tr>';
html += '</table></center>';
html += '</div>'; // table wrapper div
html += '</div>'; // 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"],
['api_keys', "API Keys"],
['categories', "Categories"],
['plugins', "Plugins"],
['edit_plugin', "Edit Plugin"],
['servers', "Servers"],
['users', "Users"]
]
);
html += '<div style="padding:20px;"><div class="subtitle">Editing Plugin &ldquo;' + plugin.title + '&rdquo;</div></div>';
html += '<div style="padding:0px 20px 50px 20px">';
html += '<center>';
html += '<table style="margin:0;">';
html += this.get_plugin_edit_html();
html += '<tr><td colspan="2" align="center">';
html += '<div style="height:30px;"></div>';
html += '<table><tr>';
html += '<td><div class="button" style="width:120px; font-weight:normal;" onMouseUp="$P().cancel_plugin_edit()">Cancel</div></td>';
html += '<td width="50">&nbsp;</td>';
html += '<td><div class="button" style="width:120px; font-weight:normal;" onMouseUp="$P().show_delete_plugin_dialog()">Delete Plugin...</div></td>';
html += '<td width="50">&nbsp;</td>';
html += '<td><div class="button" style="width:120px; font-weight:normal;" onMouseUp="$P().do_copy_plugin()">Copy Plugin...</div></td>';
html += '<td width="50">&nbsp;</td>';
html += '<td><div class="button" style="width:130px;" onMouseUp="$P().do_save_plugin()"><i class="fa fa-floppy-o">&nbsp;&nbsp;</i>Save Changes</div></td>';
html += '</tr></table>';
html += '</td></tr>';
html += '</table>';
html += '</center>';
html += '</div>'; // table wrapper div
html += '</div>'; // 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( '<span style="color:red">Abort Jobs</span>', "There " + ((jobs.length != 1) ? 'are' : 'is') + " currently still " + jobs.length + " active " + pluralize('job', jobs.length) + " using the disabled plugin <b>"+plugin.title+"</b>. 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', '<div style="font-size:14px;">' + plugin.id + '</div>' );
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', '<input type="text" id="fe_ep_title" size="35" value="'+escape_text_field_value(plugin.title)+'" spellcheck="false"/>' );
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', '<input type="checkbox" id="fe_ep_enabled" value="1" ' + (plugin.enabled ? 'checked="checked"' : '') + '/><label for="fe_ep_enabled">Plugin Enabled</label>' );
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:', '<textarea id="fe_ep_command" style="width:550px; height:50px; resize:vertical;" spellcheck="false" onkeydown="return $P().stopEnter(this,event)">'+escape_text_field_value(plugin.command)+'</textarea>');
html += get_form_table_caption(
'Enter the filesystem path to your executable, including any command-line arguments.<br/>' +
'Do not include any pipes or redirects -- for those, please use the <b>Shell Plugin</b>.'
);
html += get_form_table_spacer();
// params editor
html += get_form_table_row( 'Parameters:', '<div id="d_ep_params">' + this.get_plugin_params_html() + '</div>' );
html += get_form_table_caption(
'<div style="margin-top:5px;">Parameters are passed to your Plugin via JSON, and as environment variables.<br/>' +
'For example, you can use this to customize the PATH variable, if your Plugin requires it.</div>'
);
html += get_form_table_spacer();
// advanced options
var adv_expanded = !!(plugin.cwd || plugin.uid);
html += get_form_table_row( 'Advanced',
'<div style="font-size:13px;'+(adv_expanded ? 'display:none;' : '')+'"><span class="link addme" onMouseUp="$P().expand_fieldset($(this))"><i class="fa fa-plus-square-o">&nbsp;</i>Advanced Options</span></div>' +
'<fieldset style="padding:10px 10px 0 10px; margin-bottom:5px;'+(adv_expanded ? '' : 'display:none;')+'"><legend class="link addme" onMouseUp="$P().collapse_fieldset($(this))"><i class="fa fa-minus-square-o">&nbsp;</i>Advanced Options</legend>' +
'<div class="plugin_params_label">Working Directory (CWD):</div>' +
'<div class="plugin_params_content"><input type="text" id="fe_ep_cwd" size="50" value="'+escape_text_field_value(plugin.cwd)+'" placeholder="" spellcheck="false"/></div>' +
'<div class="plugin_params_label">Run as User (UID):</div>' +
'<div class="plugin_params_content"><input type="text" id="fe_ep_uid" size="20" value="'+escape_text_field_value(plugin.uid)+'" placeholder="" spellcheck="false"/></div>' +
'</fieldset>'
);
html += get_form_table_caption(
"Optionally enter a working directory path, and/or a custom UID for the Plugin.<br>" +
"The UID may be either numerical or a string ('root', 'wheel', etc.)."
);
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 += '<table class="data_table" width="100%">';
html += '<tr><th>' + cols.join('</th><th>').replace(/\s+/g, '&nbsp;') + '</th></tr>';
for (var idx = 0, len = params.length; idx < len; idx++) {
var param = params[idx];
var actions = [
'<span class="link" onMouseUp="$P().edit_plugin_param('+idx+')"><b>Edit</b></span>',
'<span class="link" onMouseUp="$P().delete_plugin_param('+idx+')"><b>Delete</b></span>'
];
html += '<tr>';
html += '<td><span class="link" style="font-family:monospace; font-weight:bold; white-space:nowrap;" onMouseUp="$P().edit_plugin_param('+idx+')"><i class="fa fa-cog">&nbsp;&nbsp;</i>' + param.id + '</span></td>';
// html += '<td><span class="link" style="font-weight:bold" onMouseUp="$P().edit_plugin_param('+idx+')">' + param.title + '</span></td>';
if (param.title) html += '<td><b>&ldquo;' + param.title + '&rdquo;</b></td>';
else html += '<td>(n/a)</td>';
html += '<td>' + ctype_labels[param.type] + '</td>';
var pairs = [];
switch (param.type) {
case 'text':
pairs.push([ 'Size', param.size ]);
if ('value' in param) pairs.push([ 'Default', '&ldquo;' + param.value + '&rdquo;' ]);
break;
case 'textarea':
pairs.push([ 'Rows', param.rows ]);
break;
case 'checkbox':
pairs.push([ 'Default', param.value ? 'Checked' : 'Unchecked' ]);
break;
case 'hidden':
pairs.push([ 'Value', '&ldquo;' + param.value + '&rdquo;' ]);
break;
case 'select':
pairs.push([ 'Items', '(' + param.items.join(', ') + ')' ]);
if ('value' in param) pairs.push([ 'Default', '&ldquo;' + param.value + '&rdquo;' ]);
break;
}
for (var idy = 0, ley = pairs.length; idy < ley; idy++) {
pairs[idy] = '<b>' + pairs[idy][0] + ':</b> ' + pairs[idy][1];
}
html += '<td>' + pairs.join(', ') + '</td>';
html += '<td>' + actions.join(' | ') + '</td>';
html += '</tr>';
} // foreach param
if (!params.length) {
html += '<tr><td colspan="'+cols.length+'" align="center" style="padding-top:10px; padding-bottom:10px; font-weight:bold;">';
html += 'No params found.';
html += '</td></tr>';
}
html += '</table>';
html += '<div class="button mini" style="width:110px; margin:10px 0 0 0" onMouseUp="$P().edit_plugin_param(-1)">Add Parameter...</div>';
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 += '<table>' +
get_form_table_row('Parameter ID:', '<input type="text" id="fe_epp_id" size="20" value="'+escape_text_field_value(param.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:', '<input type="text" id="fe_epp_title" size="35" value="'+escape_text_field_value(param.title)+'"/>') +
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:', '<select id="fe_epp_ctype" onChange="$P().change_plugin_control_type()">' + render_menu_options(ctype_options, param.type, false) + '</select>') +
// get_form_table_caption("Select the type of control you want to display.") +
'</table>';
html += '<fieldset style="margin-top:20px;">';
html += '<legend><table cellspacing="0" cellpadding="0"><tr><td>Control&nbsp;Type:&nbsp;</td><td><select id="fe_epp_ctype" onChange="$P().change_plugin_control_type()">' + render_menu_options(ctype_options, param.type, false) + '</select></td></tr></table></legend>';
html += '<div id="d_epp_editor" style="margin:5px 10px 5px 10px;">' + this.get_plugin_param_editor_html() + '</div>';
html += '</fieldset>';
app.confirm( '<i class="fa fa-cog">&nbsp;&nbsp;</i>' + (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 = '<table>';
switch (param.type) {
case 'text':
html += get_form_table_row('Size:', '<input type="text" id="fe_epp_text_size" size="5" value="'+escape_text_field_value(param.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:', '<input type="text" id="fe_epp_text_value" size="35" value="'+escape_text_field_value(param.value)+'" spellcheck="false"/>');
html += get_form_table_caption("Enter the default value for the text field.");
break;
case 'textarea':
html += get_form_table_row('Rows:', '<input type="text" id="fe_epp_textarea_rows" size="5" value="'+escape_text_field_value(param.rows || 5)+'"/>');
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:', '<textarea id="fe_epp_textarea_value" style="width:99%; height:60px; resize:none;" spellcheck="false">'+escape_text_field_value(param.value)+'</textarea>');
html += get_form_table_caption("Optionally enter default text for the text box.");
break;
case 'checkbox':
html += get_form_table_row('Default State:', '<select id="fe_epp_checkbox_value">' + render_menu_options([[0,'Unchecked'], [1,'Checked']], param.value, false) + '</select>');
html += get_form_table_caption("Select whether the checkbox should be initially checked or unchecked.");
break;
case 'hidden':
html += get_form_table_row('Value:', '<input type="text" id="fe_epp_hidden_value" size="35" value="'+escape_text_field_value(param.value)+'" spellcheck="false"/>');
html += get_form_table_caption("Enter the value for the hidden field.");
break;
case 'select':
html += get_form_table_row('Menu Items:', '<input type="text" id="fe_epp_select_items" size="35" value="'+escape_text_field_value(param.items ? param.items.join(', ') : '')+'" spellcheck="false"/>');
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:', '<input type="text" id="fe_epp_select_value" size="20" value="'+escape_text_field_value(param.value)+'" spellcheck="false"/>');
html += get_form_table_caption("Optionally enter an item to be selected by default.");
break;
} // switch type
html += '</table>';
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() );
if (plugin.uid.match(/^\d+$/)) plugin.uid = parseInt( plugin.uid );
return plugin;
}
});

View file

@ -0,0 +1,391 @@
// 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"],
['api_keys', "API Keys"],
['categories', "Categories"],
['plugins', "Plugins"],
['servers', "Servers"],
['users', "Users"]
]
);
html += '<div style="padding:20px 20px 30px 20px">';
// Active Server Cluster
var cols = ['Hostname', 'IP Address', 'Groups', 'Status', 'Active Jobs', 'Uptime', 'CPU', 'Mem', 'Actions'];
html += '<div class="subtitle">';
html += 'Server Cluster';
// html += '<div class="clear"></div>';
html += '</div>';
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 = [
'<div class="td_big" style="font-weight:normal"><div class="ellip" style="max-width:'+col_width+'px;"><i class="fa fa-eye">&nbsp;</i>' + server.hostname.replace(/\.[\w\-]+\.\w+$/, '') + '</div></div>',
(server.ip || 'n/a').replace(/^\:\:ffff\:(\d+\.\d+\.\d+\.\d+)$/, '$1'),
'-', '(Nearby)', '-', '-', '-', '-',
'<span class="link" onMouseUp="$P().add_server_from_list('+idx+')"><b>Add Server</b></span>'
];
tds.className = 'blue';
return tds;
} // nearby
var actions = [
'<span class="link" onMouseUp="$P().restart_server('+idx+')"><b>Restart</b></span>',
'<span class="link" onMouseUp="$P().shutdown_server('+idx+')"><b>Shutdown</b></span>'
];
if (server.disabled) actions = [];
if (!server.master) {
actions.push( '<span class="link" onMouseUp="$P().remove_server('+idx+')"><b>Remove</b></span>' );
}
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.master) 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 = [
'<div class="td_big">' + self.getNiceGroup(null, server.hostname, col_width) + '</div>',
(server.ip || 'n/a').replace(/^\:\:ffff\:(\d+\.\d+\.\d+\.\d+)$/, '$1'),
group_names.length ? group_names.join(', ') : '(None)',
server.master ? '<span class="color_label green"><i class="fa fa-check">&nbsp;</i>Primary</span>' : (eligible ? '<span class="color_label purple">Backup</span>' : '<span class="color_label blue">Worker</span>'),
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 += '<div style="height:25px;"></div>';
html += '<center><table><tr>';
html += '<td><div class="button" style="width:130px;" onMouseUp="$P().add_server()"><i class="fa fa-plus-circle">&nbsp;&nbsp;</i>Add Server...</div></td>';
html += '</tr></table></center>';
html += '<div style="height:30px;"></div>';
// 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 += '<div class="subtitle">';
html += 'Server Groups';
// html += '<div class="clear"></div>';
html += '</div>';
// 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 = [
'<span class="link" onMouseUp="$P().edit_group('+idx+')"><b>Edit</b></span>',
'<span class="link" onMouseUp="$P().delete_group('+idx+')"><b>Delete</b></span>'
];
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 [
'<div class="td_big" style="white-space:nowrap;"><span class="link" onMouseUp="$P().edit_group('+idx+')">' + self.getNiceGroup(group, null, col_width) + '</span></div>',
'<div class="ellip" style="font-family:monospace; max-width:'+col_width+'px;">/' + encode_entities(group.regexp) + '/</div>',
// group.description || '(No description)',
num_servers ? commify( num_servers) : '(None)',
num_events ? commify( num_events ) : '(None)',
group.master ? '<b>Primary Eligible</b>' : 'Worker Only',
actions.join(' | ')
];
} );
html += '<div style="height:25px;"></div>';
html += '<center><table><tr>';
html += '<td><div class="button" style="width:130px;" onMouseUp="$P().edit_group(-1)"><i class="fa fa-plus-circle">&nbsp;&nbsp;</i>Add Group...</div></td>';
html += '</tr></table></center>';
html += '</div>'; // padding
html += '</div>'; // 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 += '<div style="font-size:12px; color:#777; margin-bottom:15px;">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.</div>';
// html += '<div style="font-size:12px; color:#777; margin-bottom:20px;">Note that the new server cannot already be a master server, nor part of another '+app.name+' server cluster, and the current master server must be able to reach it.</div>';
html += '<center><table>' +
// get_form_table_spacer() +
get_form_table_row('Hostname or IP:', '<input type="text" id="fe_as_hostname" style="width:280px" value="" spellcheck="false"/>') +
get_form_table_caption("Enter the hostname or IP of the server you want to add.") +
'</table></center>';
app.confirm( '<i class="mdi mdi-desktop-tower mdi-lg">&nbsp;&nbsp;</i>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( '<span style="color:red">Remove Server</span>', "Are you sure you want to remove the server <b>"+server.hostname+"</b>?", "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: "",
master: 0
};
var edit = (idx > -1) ? true : false;
var html = '';
html += '<table>';
// Internal ID
if (edit && this.isAdmin()) {
html += get_form_table_row( 'Group ID', '<div style="font-size:14px;">' + group.id + '</div>' );
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:', '<input type="text" id="fe_eg_title" size="25" value="'+escape_text_field_value(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:', '<input type="text" id="fe_eg_regexp" size="30" style="font-family:monospace; font-size:13px;" value="'+escape_text_field_value(group.regexp)+'" spellcheck="false"/>') +
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:', '<select id="fe_eg_master">' + render_menu_options([ [1,'Primary Eligible'], [0,'Worker Only'] ], group.master, false) + '</select>') +
get_form_table_caption("Select whether servers in the group are eligible to become the primary server, or run as workers only.") +
'</table>';
app.confirm( '<i class="mdi mdi-server-network">&nbsp;&nbsp;</i>' + (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.master = parseInt( $('#fe_eg_master').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 master group
if (group.master) {
var num_masters = 0;
for (var idx = 0, len = this.server_groups.length; idx < len; idx++) {
if (this.server_groups[idx].master) num_masters++;
}
if (num_masters == 1) {
return app.doError("Sorry, you cannot delete the last Primary 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( '<span style="color:red">Delete Server Group</span>', "Are you sure you want to delete the server group <b>"+group.title+"</b>? 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( '<span style="color:red">Restart Server</span>', "Are you sure you want to restart the server <b>"+server.hostname+"</b>? 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( '<span style="color:red">Shutdown Server</span>', "Are you sure you want to shutdown the server <b>"+server.hostname+"</b>? 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);
} );
}
} );
}
});

View file

@ -0,0 +1,590 @@
// 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"],
['api_keys', "API Keys"],
['categories', "Categories"],
['plugins', "Plugins"],
['servers', "Servers"],
['users', "Users"]
]
);
var cols = ['Username', 'Full Name', 'Email Address', 'Status', 'Type', 'Created', 'Actions'];
// html += '<div style="padding:5px 15px 15px 15px;">';
html += '<div style="padding:20px 20px 30px 20px">';
html += '<div class="subtitle">';
html += 'User Accounts';
// html += '<div class="subtitle_widget"><span class="link" onMouseUp="$P().refresh_user_list()"><b>Refresh</b></span></div>';
html += '<div class="subtitle_widget"><i class="fa fa-search">&nbsp;</i><input type="text" id="fe_ul_search" size="15" placeholder="Find username..." style="border:0px;"/></div>';
html += '<div class="clear"></div>';
html += '</div>';
var self = this;
html += this.getPaginatedTable( resp, cols, 'user', function(user, idx) {
var actions = [
'<span class="link" onMouseUp="$P().edit_user('+idx+')"><b>Edit</b></span>',
'<span class="link" onMouseUp="$P().delete_user('+idx+')"><b>Delete</b></span>'
];
return [
'<div class="td_big">' + self.getNiceUsername(user, true, col_width) + '</div>',
'<div class="ellip" style="max-width:'+col_width+'px;">' + encode_entities(user.full_name) + '</div>',
'<div class="ellip" style="max-width:'+col_width+'px;"><a href="mailto:'+user.email+'">'+encode_entities(user.email)+'</a></div>',
user.active ? '<span class="color_label green"><i class="fa fa-check">&nbsp;</i>Active</span>' : '<span class="color_label red"><i class="fa fa-warning">&nbsp;</i>Suspended</span>',
user.privileges.admin ? '<span class="color_label purple"><i class="fa fa-lock">&nbsp;</i>Admin</span>' : '<span class="color_label gray">Standard</span>',
'<span title="'+get_nice_date_time(user.created, true)+'">'+get_nice_date(user.created, true)+'</span>',
actions.join(' | ')
];
} );
html += '<div style="height:30px;"></div>';
html += '<center><table><tr>';
html += '<td><div class="button" style="width:130px;" onMouseUp="$P().edit_user(-1)"><i class="fa fa-user-plus">&nbsp;&nbsp;</i>Add User...</div></td>';
html += '</tr></table></center>';
html += '</div>'; // padding
html += '</div>'; // 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"],
['api_keys', "API Keys"],
['categories', "Categories"],
['plugins', "Plugins"],
['servers', "Servers"],
['users', "Users"],
['new_user', "Add New User"]
]
);
html += '<div style="padding:20px;"><div class="subtitle">Add New User</div></div>';
html += '<div style="padding:0px 20px 50px 20px">';
html += '<center><table style="margin:0;">';
this.user = {
privileges: copy_object( config.default_privileges )
};
html += this.get_user_edit_html();
// notify user
html += get_form_table_row( 'Notify', '<input type="checkbox" id="fe_eu_send_email" value="1" checked="checked"/><label for="fe_eu_send_email">Send Welcome Email</label>' );
html += get_form_table_caption( "Select notification options for the new user." );
html += get_form_table_spacer();
// buttons at bottom
html += '<tr><td colspan="2" align="center">';
html += '<div style="height:30px;"></div>';
html += '<table><tr>';
html += '<td><div class="button" style="width:120px; font-weight:normal;" onMouseUp="$P().cancel_user_edit()">Cancel</div></td>';
html += '<td width="50">&nbsp;</td>';
if (config.debug) {
html += '<td><div class="button" style="width:120px; font-weight:normal;" onMouseUp="$P().populate_random_user()">Randomize...</div></td>';
html += '<td width="50">&nbsp;</td>';
}
html += '<td><div class="button" style="width:120px;" onMouseUp="$P().do_new_user()"><i class="fa fa-user-plus">&nbsp;&nbsp;</i>Create User</div></td>';
html += '</tr></table>';
html += '</td></tr>';
html += '</table></center>';
html += '</div>'; // table wrapper div
html += '</div>'; // 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 (!user.username.length) {
return app.badField('#fe_eu_username', "Please enter a username for the new account.");
}
if (!user.username.match(/^[\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"],
['api_keys', "API Keys"],
['categories', "Categories"],
['plugins', "Plugins"],
['servers', "Servers"],
['users', "Users"],
['edit_user', "Edit User"]
]
);
html += '<div style="padding:20px;"><div class="subtitle">Editing User &ldquo;' + (this.args.username) + '&rdquo;</div></div>';
html += '<div style="padding:0px 20px 50px 20px">';
html += '<center>';
html += '<table style="margin:0;">';
this.user = resp.user;
html += this.get_user_edit_html();
html += '<tr><td colspan="2" align="center">';
html += '<div style="height:30px;"></div>';
html += '<table><tr>';
html += '<td><div class="button" style="width:130px; font-weight:normal;" onMouseUp="$P().cancel_user_edit()">Cancel</div></td>';
html += '<td width="50">&nbsp;</td>';
html += '<td><div class="button" style="width:130px; font-weight:normal;" onMouseUp="$P().show_delete_account_dialog()">Delete Account...</div></td>';
html += '<td width="50">&nbsp;</td>';
html += '<td><div class="button" style="width:130px;" onMouseUp="$P().do_save_user()"><i class="fa fa-floppy-o">&nbsp;&nbsp;</i>Save Changes</div></td>';
html += '</tr></table>';
html += '</td></tr>';
html += '</table>';
html += '</center>';
html += '</div>'; // table wrapper div
html += '</div>'; // sidebar tabs
this.div.html( html );
setTimeout( function() {
$('#fe_eu_username').attr('disabled', true);
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 <b>permanently delete</b> 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( '<span style="color:red">Delete Account</span>', 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',
'<table cellspacing="0" cellpadding="0"><tr>' +
'<td><input type="text" id="fe_eu_username" size="20" style="font-size:14px;" value="'+escape_text_field_value(user.username)+'" spellcheck="false" onChange="$P().checkUserExists(\'eu\')"/></td>' +
'<td><div id="d_eu_valid" style="margin-left:5px; font-weight:bold;"></div></td>' +
'</tr></table>'
);
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', '<select id="fe_eu_status">' + render_menu_options([[1,'Active'], [0,'Suspended']], user.active) + '</select>' );
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', '<input type="text" id="fe_eu_fullname" size="30" value="'+escape_text_field_value(user.full_name)+'" spellcheck="false"/>' );
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', '<input type="text" id="fe_eu_email" size="30" value="'+escape_text_field_value(user.email)+'" spellcheck="false"/>' );
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
html += get_form_table_row( user.password ? 'Change Password' : 'Password', '<input type="text" id="fe_eu_password" size="20" value="" spellcheck="false"/>&nbsp;<span class="link addme" onMouseUp="$P().generate_password()">&laquo; Generate Random</span>' );
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_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 += '<div class="'+priv_class+'" style="margin-top:4px; margin-bottom:4px; '+(priv_visible ? '' : 'display:none;')+'">';
priv_html += '<input type="checkbox" id="fe_eu_priv_'+priv.id+'" value="1" ' +
(has_priv ? 'checked="checked" ' : '') + ((priv.id == 'admin') ? 'onChange="$P().change_admin_checkbox()"' : '') + '>';
priv_html += '<label for="fe_eu_priv_'+priv.id+'">'+priv.title+'</label>';
priv_html += '</div>';
}
// 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 += '<div class="priv_group_other" style="margin-top:4px; margin-bottom:4px; '+(priv_visible ? '' : 'display:none;')+'">';
priv_html += '<input type="checkbox" id="fe_eu_priv_'+priv.id+'" value="1" ' +
(has_priv ? 'checked="checked" ' : '') + 'onChange="$P().change_cat_checkbox()"' + '>';
priv_html += '<label for="fe_eu_priv_'+priv.id+'">'+priv.title+'</label>';
priv_html += '</div>';
priv_html += '<div class="priv_group_other">';
// 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 += '<div class="priv_group_cat" style="margin-top:4px; margin-bottom:4px; margin-left:20px; '+(priv_visible ? '' : 'display:none;')+'">';
priv_html += '<input type="checkbox" id="fe_eu_priv_'+priv.id+'" value="1" ' +
(has_priv ? 'checked="checked" ' : '') + '>';
priv_html += '<label for="fe_eu_priv_'+priv.id+'" style="font-weight:normal"><i class="fa fa-folder-open-o">&nbsp;</i>'+priv.title+'</label>';
priv_html += '</div>';
}
priv_html += '</div>';
// 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 += '<div class="priv_group_other" style="margin-top:4px; margin-bottom:4px; '+(priv_visible ? '' : 'display:none;')+'">';
priv_html += '<input type="checkbox" id="fe_eu_priv_'+priv.id+'" value="1" ' +
(has_priv ? 'checked="checked" ' : '') + 'onChange="$P().change_grp_checkbox()"' + '>';
priv_html += '<label for="fe_eu_priv_'+priv.id+'">'+priv.title+'</label>';
priv_html += '</div>';
priv_html += '<div class="priv_group_other">';
// 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 += '<div class="priv_group_grp" style="margin-top:4px; margin-bottom:4px; margin-left:20px; '+(priv_visible ? '' : 'display:none;')+'">';
priv_html += '<input type="checkbox" id="fe_eu_priv_'+priv.id+'" value="1" ' +
(has_priv ? 'checked="checked" ' : '') + '>';
priv_html += '<label for="fe_eu_priv_'+priv.id+'" style="font-weight:normal"><i class="fa fa-folder-open-o">&nbsp;</i>'+priv.title+'</label>';
priv_html += '</div>';
}
priv_html += '</div>';
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(),
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) );
}
});

View file

@ -0,0 +1,489 @@
// Cronicle API Layer
// 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({
__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')
],
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\-\.]+$/],
web_hook: [RE_TYPE_STRING, /(^$|https?\:\/\/\S+)/i]
};
if (!this.validateOptionalParams(event, rules, callback)) return false;
// make sure title doesn't contain HTML metacharacters
if (event.title && event.title.match(/[<>]/)) {
this.doError('api', "Malformed title parameter: Cannot contain HTML metacharacters", 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);
}
},
requireMaster: function(args, callback) {
// make sure we are the master server
// otherwise throw an API error and return false
if (this.multi.master) return true;
var status = "200 OK";
var headers = {};
if (this.multi.masterHostname) {
// we know who master 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.masterHostname : this.multi.masterIP);
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.masterHostname : this.multi.masterIP);
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 master server.";
// this.logError( 'master', msg );
callback( { code: 'master', 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;
},
loadSession: function(args, callback) {
// Load user session or validate API Key
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) {
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.params.api_key || args.query.api_key;
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 );
}
});

View file

@ -0,0 +1,346 @@
// 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");
module.exports = Class.create({
//
// Servers / Master 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 master to a potential remote slave)
var self = this;
var params = args.params;
if (!this.requireParams(params, {
master: /\S/,
now: /^\d+$/,
token: /\S/
}, callback)) return;
if (params.token != Tools.digestHex( params.master + 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.master) {
return this.doError('server', "Server is already a master server, controlling its own cluster.", callback);
}
if (this.multi.masterHostname && (this.multi.masterHostname != params.master)) {
return this.doError('server', "Server is already a member of a cluster (Primary: " + this.multi.masterHostname + ")", 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.requireMaster(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.slaves[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 = {
master: 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.slaves[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.slaves) {
var slave = self.slaves[key];
if (slave.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 slave 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.requireMaster(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 master
if (hostname == self.server.hostname) {
return self.doError('server', "Cannot remove current master server: " + hostname, callback);
}
var slave = self.slaves[hostname];
if (!slave) {
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 slave 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.requireMaster(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.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 slave = self.slaves[ params.hostname ];
if (slave && slave.socket) {
self.logDebug(6, "Sending remote restart command to: " + slave.hostname);
slave.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.requireMaster(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.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 slave = self.slaves[ params.hostname ];
if (slave && slave.socket) {
self.logDebug(6, "Sending remote shutdown command to: " + slave.hostname);
slave.socket.emit( 'shutdown_server', { reason: reason } );
callback({ code: 0 });
}
else {
callback({ code: 1, description: "Could not locate server: " + params.hostname });
}
}
} );
},
api_update_master_state: function(args, callback) {
// update master state (i.e. scheduler enabled)
var self = this;
var params = args.params;
if (!this.requireMaster(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 master 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
}
} );

View file

@ -0,0 +1,190 @@
// Cronicle API Layer - API 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");
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.requireMaster(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.requireMaster(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_create_api_key: function(args, callback) {
// add new API Key
var self = this;
var params = args.params;
if (!this.requireMaster(args, callback)) return;
if (!this.requireParams(params, {
title: /\S/,
key: /\S/
}, callback)) return;
// make sure title doesn't contain HTML metacharacters
if (params.title && params.title.match(/[<>]/)) {
return this.doError('api', "Malformed title parameter: Cannot contain HTML metacharacters", callback);
}
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.requireMaster(args, callback)) return;
if (!this.requireParams(params, {
id: /^\w+$/
}, callback)) return;
// make sure title doesn't contain HTML metacharacters
if (params.title && params.title.match(/[<>]/)) {
return this.doError('api', "Malformed title parameter: Cannot contain HTML metacharacters", callback);
}
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.requireMaster(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: {} } );
} );
} );
}
} );

View file

@ -0,0 +1,218 @@
// 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.requireMaster(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.requireMaster(args, callback)) return;
if (!this.requireParams(cat, {
title: /\S/,
max_children: /^\d+$/
}, callback)) return;
// make sure title doesn't contain HTML metacharacters
if (cat.title && cat.title.match(/[<>]/)) {
return this.doError('api', "Malformed title parameter: Cannot contain HTML metacharacters", callback);
}
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.requireMaster(args, callback)) return;
if (!this.requireParams(params, {
id: /^\w+$/
}, callback)) return;
// make sure title doesn't contain HTML metacharacters
if (params.title && params.title.match(/[<>]/)) {
return this.doError('api', "Malformed title parameter: Cannot contain HTML metacharacters", callback);
}
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.requireMaster(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
}
} );

View file

@ -0,0 +1,57 @@
// 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 master server, this has to fail (will be polled for retries)
if (!this.multi.masterHostname) {
return callback({ code: 'master', description: "No master 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,
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'),
master_hostname: this.multi.masterHostname
};
// if we're master, then return our ip for websocket connect
if (this.multi.master) {
resp.servers = {};
resp.servers[ this.server.hostname ] = {
hostname: this.server.hostname,
ip: this.server.ip
};
}
callback(resp);
}
} );

View file

@ -0,0 +1,547 @@
// Cronicle API Layer - Events
// 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({
//
// 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.requireMaster(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.requireMaster(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.requireMaster(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, 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;
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.requireMaster(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, 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 }, function(err, event) {
if (err || !event) {
return self.doError('event', "Failed to locate event: " + params.id, callback);
}
// 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 } );
}
self.logDebug(6, "Successfully updated event: " + event.id + " (" + event.title + ")");
self.logTransaction('event_update', event.title, self.getClientInfo(args, { event: params }));
self.logActivity('event_update', { 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.requireMaster(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.requireMaster(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) return self.doError('session', err.message, callback);
if (!self.requireValidUser(session, user, callback)) return;
if (!self.requirePrivilege(user, "run_events", callback)) return;
args.user = user;
args.session = session;
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 (!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 &params/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];
}
}
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 {
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.requireMaster(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
}
} );

View file

@ -0,0 +1,160 @@
// 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.requireMaster(args, callback)) return;
if (!this.requireParams(group, {
title: /\S/,
regexp: /\S/
}, callback)) return;
// make sure title doesn't contain HTML metacharacters
if (group.title && group.title.match(/[<>]/)) {
return this.doError('api', "Malformed title parameter: Cannot contain HTML metacharacters", callback);
}
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 slave servers about the change as well
// this may have changed their master server eligibility
self.slaveNotifyGroupChange();
} ); // list insert
} ); // load session
},
api_update_server_group: function(args, callback) {
// update existing server group
var self = this;
var params = args.params;
if (!this.requireMaster(args, callback)) return;
if (!this.requireParams(params, {
id: /^\w+$/
}, callback)) return;
// make sure title doesn't contain HTML metacharacters
if (params.title && params.title.match(/[<>]/)) {
return this.doError('api', "Malformed title parameter: Cannot contain HTML metacharacters", callback);
}
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 slave servers about the change as well
// this may have changed their master server eligibility
self.slaveNotifyGroupChange();
} );
} );
},
api_delete_server_group: function(args, callback) {
// delete existing server group
var self = this;
var params = args.params;
if (!this.requireMaster(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 slave servers about the change as well
// this may have changed their master server eligibility
self.slaveNotifyGroupChange();
} ); // listFindDelete (group)
} ); // listFind (schedule)
} ); // load session
}
} );

View file

@ -0,0 +1,558 @@
// Cronicle API Layer - Jobs
// 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({
//
// Jobs
//
/*api_upload_job_log: function(args, callback) {
// server-to-server log upload
var self = this;
var params = args.params;
var files = args.files;
if (!this.requireMaster(args, callback)) return;
if (!this.requireParams(params, {
path: /^[\w\-\.\/]+$/,
auth: /^\w+$/
}, callback)) return;
if (params.auth != Tools.digestHex(params.path + this.server.config.get('secret_key'))) {
return callback( "403 Forbidden", {}, "Authentication failure.\n" );
}
if (!files.file1 || !files.file1.path) {
return callback( "400 Bad Request", {}, "No upload data found.\n" );
}
fs.readFile( files.file1.path, { encoding: null }, function(err, data) {
if (err) {
return callback( "400 Bad Request", {}, "Could not read upload data: " + err + "\n" );
}
self.storage.put( params.path, data, function(err) {
if (err) {
self.logError('storage', "Failed to store job log: " + params.path + ": " + err);
return;
}
callback( "200 OK", {}, "Success.\n" );
} ); // storage put
} );
},*/
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_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/plain" };
// 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 slave, hence might not have storage)
var self = this;
var params = args.params;
if (!this.requireMaster(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 = null;
// due to a race condition, the job may not be registered yet
async.retry( { times: 20, interval: 250 },
async.ensureAsync( function(callback) {
job = self.findJob(params);
return job ? callback() : callback("NOPE");
} ),
function(err) {
if (err) return self.doError('job', "Failed to locate job for log watch auth: " + 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 });
}
); // async.retry
} );
},
api_update_job: function(args, callback) {
// update running job
var self = this;
var params = args.params;
if (!this.requireMaster(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.requireMaster(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.requireMaster(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.requireMaster(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.requireMaster(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;
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.requireMaster(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...
// Slave server may have removed job from activeJobs, and synced with master,
// but before master 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.requireMaster(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.requireMaster(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;
return callback({
code: 0,
jobs: self.getAllActiveJobs()
});
} ); // session
}
} );

View file

@ -0,0 +1,235 @@
// 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.requireMaster(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.requireMaster(args, callback)) return;
if (!this.requireParams(plugin, {
title: /\S/,
command: /\S/
}, callback)) return;
// make sure title doesn't contain HTML metacharacters
if (plugin.title && plugin.title.match(/[<>]/)) {
return this.doError('api', "Malformed title parameter: Cannot contain HTML metacharacters", callback);
}
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.requireMaster(args, callback)) return;
if (!this.requireParams(params, {
id: /^\w+$/
}, callback)) return;
// make sure title doesn't contain HTML metacharacters
if (params.title && params.title.match(/[<>]/)) {
return this.doError('api', "Malformed title parameter: Cannot contain HTML metacharacters", callback);
}
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);
// 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.requireMaster(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;
}
} );

View file

@ -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({
slaves: null,
sockets: null,
setupCluster: function() {
// establish communication channel with all slaves
var self = this;
// slaves are servers the master can send jobs to
this.slaves = {};
// we're a slave too (but no socket needed)
this.slaves[ this.server.hostname ] = {
master: 1,
hostname: this.server.hostname
};
// add any registered slaves
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.slaves[ server.hostname ]) return;
this.logDebug(5, "Adding slave to cluster: " + server.hostname + " (" + (server.ip || 'n/a') + ")");
var slave = {
hostname: server.hostname,
ip: server.ip || ''
};
// connect via socket.io
this.connectToSlave(slave);
// add slave to cluster
this.slaves[ slave.hostname ] = slave;
// notify clients of the server change
this.authSocketEmit( 'update', { servers: this.getAllServers() } );
// log activity for new server
this.logActivity( 'server_add', { hostname: slave.hostname, ip: slave.ip || '' }, args );
},
connectToSlave: function(slave) {
// establish communication with slave 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://' + slave.hostname + ':' + port;
}
else {
url = 'http://' + (slave.ip || slave.hostname) + ':' + port;
}
this.logDebug(8, "Connecting to slave via socket.io: " + url);
var socket = new SocketIOClient( url, {
multiplex: false,
forceNew: true,
reconnection: false,
// reconnectionDelay: 1000,
// reconnectionDelayMax: 1000,
// reconnectionDelayMax: this.server.config.get('master_ping_freq') * 1000,
// reconnectionAttempts: Infinity,
// randomizationFactor: 0,
timeout: 5000
} );
socket.on('connect', function() {
self.logDebug(6, "Successfully connected to slave: " + slave.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,
master_hostname: self.server.hostname
} );
// remove disabled flag, in case this is a reconnect
if (slave.disabled) {
delete slave.disabled;
self.logDebug(5, "Marking slave as enabled: " + slave.hostname);
// log activity for this
self.logActivity( 'server_enable', { hostname: slave.hostname, ip: slave.ip || '' } );
// notify clients of the server change
self.authSocketEmit( 'update', { servers: self.getAllServers() } );
} // disabled
// reset reconnect delay
delete slave.socketReconnectDelay;
} );
/*socket.on('reconnectingDISABLED', function(err) {
self.logDebug(6, "Reconnecting to slave: " + slave.hostname);
// mark slave as disabled to avoid sending it new jobs
if (!slave.disabled) {
slave.disabled = true;
self.logDebug(5, "Marking slave as disabled: " + slave.hostname);
// notify clients of the server change
self.authSocketEmit( 'update', { servers: self.getAllServers() } );
// if slave had active jobs, move them to limbo
if (slave.active_jobs) {
for (var id in slave.active_jobs) {
self.logDebug(5, "Moving job to limbo: " + id);
self.deadJobs[id] = slave.active_jobs[id];
self.deadJobs[id].time_dead = Tools.timeNow(true);
}
delete slave.active_jobs;
}
} // not disabled yet
} );*/
socket.on('disconnect', function() {
if (!socket._pixl_disconnected) {
self.logError('server', "Slave disconnected unexpectedly: " + slave.hostname);
self.reconnectToSlave(slave);
}
else {
self.logDebug(5, "Slave disconnected: " + slave.hostname, socket.id);
}
} );
socket.on('error', function(err) {
self.logError('server', "Slave socket error: " + slave.hostname + ": " + err);
} );
socket.on('connect_error', function(err) {
self.logError('server', "Slave connection failed: " + slave.hostname + ": " + err);
if (!socket._pixl_disconnected) self.reconnectToSlave(slave);
} );
socket.on('connect_timeout', function() {
self.logError('server', "Slave connection timeout: " + slave.hostname);
} );
/*socket.on('reconnect_error', function(err) {
self.logError('server', "Slave reconnection failed: " + slave.hostname + ": " + err);
} );
socket.on('reconnect_failed', function() {
self.logError('server', "Slave retries exhausted: " + slave.hostname);
} );*/
// Custom commands:
socket.on('status', function(status) {
self.logDebug(10, "Got status from slave: " + slave.hostname, status);
Tools.mergeHashInto( slave, status );
self.checkServerClock(slave);
self.checkServerJobs(slave);
// sanity check (should never happen)
if (slave.master) self.masterConflict(slave);
} );
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 slave: " + slave.hostname + " ("+data.description+")";
self.logError('server', err_msg);
self.logActivity('error', { description: err_msg } );
self.removeServer( slave );
} );
slave.socket = socket;
},
reconnectToSlave: function(slave) {
// reconnect to slave after socket error
var self = this;
// mark slave as disabled to avoid sending it new jobs
if (!slave.disabled) {
slave.disabled = true;
self.logDebug(5, "Marking slave as disabled: " + slave.hostname);
// log activity for this
self.logActivity( 'server_disable', { hostname: slave.hostname, ip: slave.ip || '' } );
// notify clients of the server change
self.authSocketEmit( 'update', { servers: self.getAllServers() } );
// if slave had active jobs, move them to limbo
if (slave.active_jobs) {
for (var id in slave.active_jobs) {
self.logDebug(5, "Moving job to limbo: " + id);
self.deadJobs[id] = slave.active_jobs[id];
self.deadJobs[id].time_dead = Tools.timeNow(true);
}
delete slave.active_jobs;
}
} // not disabled yet
// slowly back off retries to N sec to avoid spamming the logs too much
if (!slave.socketReconnectDelay) slave.socketReconnectDelay = 0;
if (slave.socketReconnectDelay < this.server.config.get('master_ping_freq')) slave.socketReconnectDelay++;
slave.socketReconnectTimer = setTimeout( function() {
delete slave.socketReconnectTimer;
if (!self.server.shut) {
self.logDebug(6, "Reconnecting to slave: " + slave.hostname);
self.connectToSlave(slave);
}
}, slave.socketReconnectDelay * 1000 );
},
checkServerClock: function(slave) {
// make sure slave clock is close to ours
if (!slave.clock_drift) slave.clock_drift = 0;
var now = Tools.timeNow();
var drift = Math.abs( now - slave.epoch );
if ((drift >= 10) && (slave.clock_drift < 10)) {
var err_msg = "Server clock is " + Tools.shortFloat(drift) + " seconds out of sync: " + slave.hostname;
this.logError('server', err_msg);
this.logActivity('error', { description: err_msg } );
}
slave.clock_drift = drift;
},
checkServerJobs: function(slave) {
// remove any slave jobs from limbo, if applicable
if (slave.active_jobs) {
for (var id in slave.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 slave = this.slaves[ server.hostname ];
if (!slave) return;
this.logDebug(5, "Removing slave from cluster: " + slave.hostname + " (" + (slave.ip || 'n/a') + ")");
// Deal with active jobs that were on the lost server
// Stick them in limbo with a short timeout
if (slave.active_jobs) {
for (var id in slave.active_jobs) {
this.logDebug(5, "Moving job to limbo: " + id);
this.deadJobs[id] = slave.active_jobs[id];
this.deadJobs[id].time_dead = Tools.timeNow(true);
}
delete slave.active_jobs;
}
if (slave.socket) {
slave.socket._pixl_disconnected = true;
slave.socket.off('disconnect');
slave.socket.disconnect();
delete slave.socket;
}
if (slave.socketReconnectTimer) {
clearTimeout( slave.socketReconnectTimer );
delete slave.socketReconnectTimer;
}
delete this.slaves[ slave.hostname ];
// notify clients of the server change
this.authSocketEmit( 'update', { servers: this.getAllServers() } );
// log activity for lost server
this.logActivity( 'server_remove', { hostname: slave.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.master_hostname && params.now && params.token) {
// master-to-slave connection (we are the slave)
var correct_token = Tools.digestHex( params.master_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_master = true;
// force multi-server init (quick startup: to skip waiting for the tock)
self.logDebug(3, "Master server is: " + params.master_hostname);
// set some flags
self.multi.cluster = true;
self.multi.masterHostname = params.master_hostname;
self.multi.masterIP = ip;
self.multi.master = false;
self.multi.lastPingReceived = Tools.timeNow(true);
if (!self.multi.slave) self.goSlave();
// need to recheck this
self.checkMasterEligibility();
} // 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 master server eligibility
self.logDebug(4, "Server groups have changed, rechecking master eligibility");
self.checkMasterEligibility();
} );
socket.on('logout', function(args) {
// user wants out? okay then
socket._pixl_auth = false;
socket._pixl_master = false;
} );
socket.on('master_ping', function(args) {
// master has given dobby a ping!
self.logDebug(10, "Received ping from master 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 + ")");
} );
},
sendMasterPings: function() {
// send a ping to all slaves
this.slaveBroadcastAll('master_ping');
},
slaveNotifyGroupChange: function() {
// notify all slaves that server groups have changed
this.slaveBroadcastAll('groups_changed');
},
slaveBroadcastAll: function(key, data) {
// broadcast message to all slaves
if (!this.multi.master) return;
for (var hostname in this.slaves) {
var slave = this.slaves[hostname];
if (slave.socket) {
slave.socket.emit(key, data || {});
}
}
},
getAllServers: function() {
// get combo hash of all UDP-managed servers, and any manually added slaves
if (!this.multi.master) return null;
var servers = {};
var now = Tools.timeNow(true);
// add us first (the master)
servers[ this.server.hostname ] = {
hostname: this.server.hostname,
ip: this.server.ip,
master: 1,
uptime: now - (this.server.started || now),
data: this.multi.data || {},
disabled: 0
};
// then add all slaves
for (var hostname in this.slaves) {
var slave = this.slaves[hostname];
if (!servers[hostname]) {
servers[hostname] = {
hostname: hostname,
ip: slave.ip || '',
master: 0,
uptime: slave.uptime || 0,
data: slave.data || {},
disabled: slave.disabled || 0
};
} // unique hostname
} // foreach slave
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.master) {
for (var hostname in this.slaves) {
var slave = this.slaves[hostname];
if (slave.socket) {
this.logDebug(9, "Closing slave connection: " + slave.hostname, slave.socket.id);
slave.socket._pixl_disconnected = true;
slave.socket.off('disconnect');
slave.socket.disconnect();
delete slave.socket;
}
if (slave.socketReconnectTimer) {
clearTimeout( slave.socketReconnectTimer );
delete slave.socketReconnectTimer;
}
}
this.slaves = {};
} // master
}
});

View file

@ -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('master_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('master_ping_timeout')) {
delete this.nearbyServers[hostname];
if (this.multi.master) {
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.master) {
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();
}
}
});

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,38 @@
#!/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");
}
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('pixl-server-user'),
require('./engine.js')
]
});
server.startup( function() {
// server startup complete
process.title = server.__name + ' Server';
} );

View file

@ -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;
}
}
});

View file

@ -0,0 +1,505 @@
// 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 slaves to check-in before we start launching jobs
// (important for calculating max concurrents -- master 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) {
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;
// var cargs = Tools.getDateArgs(cursor);
var margs = moment.tz(cursor * 1000, item.timezone || self.tz);
if (item.timing && self.checkEventTimingMoment(item.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];
}
// custom http options for web hook
var hook_opts = self.server.config.get('web_hook_custom_opts') || {};
if (item.web_hook) {
self.logDebug(9, "Firing web hook for job launch failure: " + item.web_hook);
self.request.json( item.web_hook, hook_data, hook_opts, function(err, resp, data) {
// log response
if (err) self.logDebug(9, "Web Hook Error: " + item.web_hook + ": " + err);
else self.logDebug(9, "Web Hook Response: " + item.web_hook + ": HTTP " + resp.statusCode + " " + resp.statusMessage);
} );
}
if (self.server.config.get('universal_web_hook')) {
self.logDebug(9, "Firing universal web hook for job launch failure: " + self.server.config.get('universal_web_hook'));
self.request.json( self.server.config.get('universal_web_hook'), hook_data, hook_opts, function(err, resp, data) {
// log response
if (err) self.logDebug(9, "Universal Web Hook Error: " + err);
else self.logDebug(9, "Universal Web Hook Response: HTTP " + resp.statusCode + " " + resp.statusMessage);
} );
} // universal_web_hook
} // 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);
},
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 master to do this
if (!this.multi.master) {
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 master to do this
if (!this.multi.master) {
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.master) {
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();
} );
}
} );

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,63 @@
{
"name": "Cronicle",
"version": "0.8.61",
"description": "A simple, distributed task scheduler and runner with a web based UI.",
"author": "Joseph Huckaby <jhuckaby@gmail.com>",
"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": {
"async": "2.6.0",
"socket.io": "1.7.3",
"socket.io-client": "1.7.3",
"mkdirp": "0.5.1",
"glob": "5.0.15",
"uglify-js": "2.8.22",
"zxcvbn": "3.5.0",
"jquery": "3.5.0",
"font-awesome": "4.7.0",
"mdi": "1.9.33",
"chart.js": "2.9.4",
"moment": "2.22.1",
"moment-timezone": "0.5.32",
"jstimezonedetect": "1.0.6",
"netmask": "2.0.1",
"shell-quote": "1.6.1",
"bcrypt-node": "0.1.0",
"uncatch": "1.0.0",
"pixl-args": "1.0.3",
"pixl-cli": "1.0.8",
"pixl-config": "1.0.5",
"pixl-webapp": "1.0.17",
"pixl-class": "1.0.2",
"pixl-tools": "1.0.23",
"pixl-logger": "1.0.14",
"pixl-json-stream": "1.0.6",
"pixl-request": "1.0.36",
"pixl-mail": "1.0.11",
"pixl-perf": "1.0.5",
"pixl-server": "1.0.26",
"pixl-server-storage": "2.0.10",
"pixl-server-web": "1.1.7",
"pixl-server-api": "1.0.2",
"pixl-server-user": "1.0.9"
},
"devDependencies": {
"pixl-unit": "1.0.10"
}
}

View file

@ -0,0 +1,137 @@
{
"base_app_url": "http://localhost:3012",
"email_from": "jobs@cronicle.com",
"smtp_hostname": "localhost",
"smtp_port": 25,
"secret_key": "CHANGE_ME",
"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,
"copy_job_logs_to": "",
"queue_dir": "queue",
"pid_file": "logs/cronicled.pid",
"debug_level": 9,
"maintenance": "04:00",
"list_row_max": 10000,
"job_data_expire_days": 180,
"child_kill_timeout": 10,
"dead_job_timeout": 120,
"master_ping_freq": 20,
"master_ping_timeout": 60,
"udp_broadcast_port": 3014,
"scheduler_startup_grace": 10,
"universal_web_hook": "",
"track_manual_jobs": false,
"max_jobs": 0,
"server_comm_use_hostnames": false,
"web_direct_connect": false,
"web_socket_use_hostnames": false,
"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": "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]"
},
"client": {
"name": "Cronicle",
"debug": 0,
"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
}
},
"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,
"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"
},
"free_accounts": 1,
"default_privileges": {
"admin": 1,
"create_events": 1,
"edit_events": 1,
"delete_events": 1,
"run_events": 1,
"abort_events": 1,
"state_update": 1
}
}
}

View file

@ -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

View file

@ -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

View file

@ -0,0 +1,36 @@
To: [/notify_fail]
From: [/config/email_from]
Subject: ⚠️ Cronicle Job Failed: [/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

View file

@ -0,0 +1,35 @@
To: [/notify_success]
From: [/config/email_from]
Subject: ✅ Cronicle Job Completed Successfully: [/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

View file

@ -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.
[/self_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

View file

@ -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:
[/self_url]
Regards,
The Cronicle Team

View file

@ -0,0 +1,174 @@
{
"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": "#!/bin/sh\n\n# Enter your shell script code here" },
{ "id":"annotate", "type":"checkbox", "title":"Add Date/Time Stamps to Log", "value": 0 },
{ "id":"json", "type":"checkbox", "title":"Interpret JSON in Output", "value": 0 }
]
} ],
[ "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.",
"max_children": 0
} ],
[ "listCreate", "global/server_groups", {} ],
[ "listPush", "global/server_groups", {
"id": "maingrp",
"title": "Primary Group",
"regexp": "_HOSTNAME_",
"master": 1
} ],
[ "listPush", "global/server_groups", {
"id": "allgrp",
"title": "All Servers",
"regexp": ".+",
"master": 0
} ],
[ "listCreate", "global/servers", {} ],
[ "listPush", "global/servers", {
"hostname": "_HOSTNAME_",
"ip": "_IP_"
} ],
[ "listCreate", "global/schedule", {} ],
[ "listCreate", "global/api_keys", {} ]
],
"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" ],
{
"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": "<script src=\"js/_combo.js\"></script>"
},
{
"action": "bundleCompress",
"strip_source_maps": true,
"dest_bundle": "htdocs/css/_combo.css",
"html_file": "htdocs/index.html",
"match_key": "COMBINE_STYLE",
"dest_bundle_tag": "<link rel=\"stylesheet\" href=\"css/_combo.css\">"
},
{
"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 master 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!"
]
}
]
}
}

View file

@ -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-----

View file

@ -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-----

View file

@ -0,0 +1,54 @@
ARG docker_registry=docker.io/alnoda
ARG image_tag=18.04-0.5
FROM ${docker_registry}/ubuntu-workspace:${image_tag}
USER root
################################################################# TOOLS: cronicle, filebrowser, ungit, static server
COPY Cronicle-0.8.61 /opt/cronicle
RUN echo "------------------------------------------------------ cronicle" \
&& mkdir -p /opt/cronicle \
&& cd /opt/cronicle && nodeenv --node=12.18.3 --npm=6.0.0 env \
&& cd /opt/cronicle && . env/bin/activate && npm install; node bin/build.js dist
COPY cronicle-config.json /opt/cronicle/conf/config.json
COPY supervisord-workspace-base.conf /etc/supervisord/
COPY filebrowser.json /opt/filebrowser/.filebrowser.json
COPY mkdocs /home/docs
COPY README.md /home/docs/docs/get-started.md
COPY mkdocs-requirements.txt /home/abc/installed-python-packages/mkdocs-requirements.txt
RUN echo "------------------------------------------------------ filebrowser, ungit, static server" \
&& apt-get -y update \
&& apt-get install -y apache2-utils \
&& mkdir -p -m 777 /opt/filebrowser \
&& curl -fsSL https://raw.githubusercontent.com/filebrowser/get/master/get.sh | bash \
&& apt-get install -y ssh net-tools --no-install-recommends \
&& mkdir -p /opt/ungit \
&& cd /opt/ungit && nodeenv --node=12.18.3 --npm=6.0.0 env \
&& cd /opt/ungit && . env/bin/activate && npm install -g ungit@1.5.9 \
&& mkdir -p /opt/serve \
&& cd /opt/serve && nodeenv --node=12.18.3 --npm=6.0.0 env \
&& cd /opt/serve && . env/bin/activate && npm install -g serve \
&& echo "------------------------------------------------------ mkdocs" \
&& pip install -r /home/abc/installed-python-packages/mkdocs-requirements.txt \
&& echo "------------------------------------------------------ user" \
&& mkdir -p /home/static-server \
&& chown -R abc /opt/cronicle \
&& chown -R abc /opt/filebrowser \
&& chown -R abc /home/static-server \
&& chown -R abc /home/docs \
&& chown -R abc /opt/ungit \
&& chown -R abc /opt/serve \
&& mkdir -p /var/log/cronicle && chown -R abc /var/log/cronicle \
&& mkdir -p /var/log/filebrowser && chown -R abc /var/log/filebrowser \
&& mkdir -p /var/log/ungit && chown -R abc /var/log/ungit \
&& mkdir -p /var/log/static-file-server && chown -R abc /var/log/static-file-server \
&& mkdir -p /var/log/mkdocs && chown -R abc /var/log/mkdocs
USER abc

View file

@ -0,0 +1,387 @@
# Base-workspace
Base-Workspace is an enhanced docker image with Ubuntu and additional tools set up in order to develop applications directly inside docker container,
and have your dependencies, configuration and credential files, ssh keys and data isolated from other environments.
## Contents
* [Use-cases](#use-cases)
* [Features](#features)
* [Launch Workspace](#launch-workspace)
* [Workspace terminal](#workspace-terminal)
* [Multiple workspaces](#multipl-workspaces)
* [Open more ports](#open-more-ports)
* [Run as root](#run-as-root)
* [Docker in docker](#docker-in-docker)
* [Run on remote server](#run-on-remote-server)
* [Use Workspace](#use-workspace)
* [Install new packages](#install-new-packages)
* [Schedule jobs with Cron](#schedule-jobs-with-cron)
* [Python](#python)
* [Node.js](#node.js)
* [Run applications and services inside the workspace](#run-applications-and-service-inside-the-workspace)
* [Manage workspaces](#manage-workspaces)
* [Start and stop containers](#start-and-stop-containers)
* [Create new workspace image](#create-new-workspace-image)
* [Manage workspace images](#manage-workspace-images)
* [Save and load workspace images](#save-and-load-workspace-images)
## Use-cases
Base-Workspace was created as an intermediary step between `ubuntu-workspace` that has only terminal-based tools
and `workspace-in-docker` that includes a set of WEB-UI tools which trannsform docker into the full-power development
environment.
Base-workspace does not include IDE, and serves as a building base for other workspaces with different IDEs.
## Features
Being an extension of [ubuntu-workspace-in-docker](https://github.com/Alnoda/ubuntu-workspace-in-docker) this image has all the features that
ubuntu-workspace has.
Workspace includes several open-source tools with Web GUI:
- [**FileBrowser**](./features.md#filebrowser) - manage files and folders inside the workspace, and exchange data between local environment and the workspace
- [**Cronicle**](./features.md#cronicle) - task scheduler and runner, with a web based front-end UI. It handles both scheduled, repeating and on-demand jobs, targeting any number of worker servers, with real-time stats and live log viewer.
- [**Static File Server**](./features.md#static-file-server) - view any static html sites as easy as if you do it on your local machine. Serve static websites easily.
- [**Ungit**](./features.md#ungit) - rings user friendliness to git without sacrificing the versatility of it.
- [**MkDocs**](./docs.md) - create documentation for your workspace or project with only markdown.
Despite having WEB UI tools, Base-Workspace does not include IDE. This workspace serves eitehr of 2 use-cases:
- for those who prefer coding in terminal-based editors (emacs, vim, nano etc.)
- for customization and adding IDE of chioce or closed-source ide
## Launch Workspace
In order to avoid confusion, the following convention is adopted:
```sh
command to execute outside of the workspace
```
> `command to execute inside the workspace (after entering running docker container)`
To start Base Workspace simply execute in terminal
```sh
docker run --name space-1 -d -p 8020-8030:8020-8030 alnoda/base-workspace
```
### Workspace terminal
enter into the running workspace container
```sh
docker exec -it space-1 /bin/zsh
```
If you don't want to use z-shell
```
docker exec -it space-1 /bin/bash
```
You can work in Ubuntu terminal now. Execute the followinng command to know your workspace user
> `whoami`
### Multiple workspaces
Every workspace requires range of ports. If one workspace is up and running, the ports 8020-8030 are taken.
In order to start another workspace it is necessary either to stop currently runnning workspace, or to run another workspace
on the different port range.
If you are planning to run multiple workspaces at the same time, you can run second workspace with different port range
```sh
docker run --name space-2 -d -p 8040-8050:8020-8030 -e ENTRY_PORT=8040 alnoda/base-workspace
```
Notice that in addition we need to set environmental variable ENTRY_PORT, which should be equal to the first port in the new range.
This is needed for the documentation main page to set up correct links to other tools (Filebrowser, Cronicle etc.)
### Open more ports
We started workspace container with a port range mapped "-p 8020-8030". If you are planning to expose more applications
from inside of a container, add additional port mapping, for example
```sh
docker run --name space-1 -d -p 8020-8030:8020-8030 -p 8080:8080 alnoda/base-workspace
```
You can add multiple port mappings:
```sh
docker run --name space-1 -d -p 8020-8030:8020-8030 -p 8080:8080 -p 443:443 alnoda/base-workspace
```
**NOTE:** It is not a problem if you don't expose any ports, but later on realise you need them -
you will just create new image, and run it exposing the required port (look in the section [Create new image](#create-new-image))
### Run as root
The default user is **abc** with passwordless sudo to install packages. If you'd rather work as root, then you should ssh into running container as
```sh
docker exec -it --user=root space-1 /bin/zsh
```
You can of course open several terminals to the same running containner as both abc and root users at the same time.
### Docker in docker
It is possible to work with docker directly from the workspace.
```
docker run --name space-1 -d -p 8020-8030:8020-8030 -v /var/run/docker.sock:/var/run/docker.sock alnoda/base-workspace
```
NOTE: in order to use docker in docker you need to or enter into the workspace container as root
```sh
docker exec -it --user=root space-1 /bin/zsh
```
### Run on remote server
Because workspace is just a docker image, running it in cloud is as easy as running it on local laptop. There are only 3 steps:
- get virtual server on your favourite cloud (Digital Ocean, Linode, AWS, GC, Azure ...)
- [install docker](https://docs.docker.com/engine/install/) on this server
- ssh to the remote server and start workspace. Add envronmental variable `-e WRK_HOST="<ip-of-your-remote-server>"`
```
docker run --name space-1 -d -p 8020-8030:8020-8030 -e WRK_HOST="<ip-of-your-remote-server>" alnoda/base-workspace
```
if docker-in-docker needed then
```
docker run --name space-1 -d -p 8020-8030:8020-8030 -e WRK_HOST="<ip-of-your-remote-server>" -v /var/run/docker.sock:/var/run/docker.sock alnoda/base-workspace
```
Open in your browser `<ip-of-your-remote-server>:8020`
## Use Workspace
The common actions inside the workspace include
- installation of new applications and runtimes
- edit files, write code, scripts
- build, compile and execute code
- start/stop applications and services
- schedule tasks and scripts
- process data
### Install new packages
Install new packages with ```sudo apt install```. The default abc user is allowed to install packages.
For example, in order to install [Emacs text editor](https://www.gnu.org/software/emacs/) make sure you
have entered running docker container (of the workspace), and execute in terminal
> `sudo apt install emacs`
### Schedule jobs with Cron
Schedule execution of any task with cron - a time-based job scheduler in Unix-like computer operating systems.
In order to create scheduled job enter running docker container, and execute in terminal
> `crontab -e`
*(chose [1] nano as editor on the first time)*
In the end of the opened file add line
> `* * * * * echo $(whoami) >> /home/cron.txt`
This will print every minute username to file */home/cron.txt* . *(Hit Ctrl+X to exit nano)*
Hint: example of cron job definition:
```
.---------------- minute (0 - 59)
| .------------- hour (0 - 23)
| | .---------- day of month (1 - 31)
| | | .------- month (1 - 12) OR jan,feb,mar,apr ...
| | | | .---- day of week (0 - 6) (Sunday=0 or 7) OR sun,mon,tue,wed,thu,fri,sat
| | | | |
* * * * * command to be executed
```
**NOTE** you can disconnect from the image and close terminal - cron will continue working.
> In addition to the commonly known ***cron scheduler*** you can use Cronicle - the tool with Web UI and great features
> which is bundeled together with the Base-Workspace.
### Python
Python and Pip are installed. To start python console, enter running docker container, and execute in terminal
> `python`
install python package with pip, for
> `pip install pandas`
If you are planning to work with python, we recommend to install IPython, that provides a rich toolkit to help
you make the most of using Python interactively. Install and start ipython
> ```pip install ipython```
> `ipython`
### Node.js
We recommend to use nodeenv to create different node environments.
For example, create folder npmgui, and activate environment with node v. 12.18.3 and npm v.6.0.0 (make sure you are inside workspace docker container)
> `cd /home`
> `mkdir npmgui; cd npmgui`
> `nodeenv --node=12.18.3 --npm=6.0.0 env`
Let's install package and start node application
> `. env/bin/activate && npm i -g npm-gui`
> `npm-gui 0.0.0.0:8030`
Open your browser on http://localhost:8030/
**NOTE:** If you close terminal, the application will stop. See how to [start applications that reamin live after closing a workspace terminal](#run-applications-and-services-inside-the-workspace)
### Run applications and services inside the workspace
If you want application to keep running after terminal is closed start it with **"&!"** at the end.
For example, enter into the running workspace container, and start the example node application from the previous section:
> `cd /home/npmgui`
> `. env/bin/activate && npm i -g npm-gui &!`
Now, if you disconnect from the workspace and close terminal, the application will still continue running in the workspace, untill [workspace is stopped](#start-and-stop-workspaces).
If you want application to start automatically each time workspaces is restarted, or the new workspace is created, see [running applications permanently](extend.md#add-applications-and-services)
## Manage workspaces
Workspace is just a docker container. You can start, stop, delete and do anything you can do with docker images and containers.
There are two concepts to keep in mind: **images** and **containers**. Images are workspace blueprints. For example, **alnoda/base-workspace** -
is an image. When you execute this command
```sh
docker run --name space-1 -d -p 8020-8030:8020-8030 alnoda/base-workspace
```
you create container called **space-1** from the image **alnoda/base-workspace**. You can create any number of containers, but you need to
[map different ports to each of them](#multiple-workspaces).
Container - is your workspace. You can start, stop and delete them. You can run multiple workspace containers at the same time, or work with
one workspace at a time.
From the workspace (which is a container) you can create new image. This is called **commit docker image**.
Essentially, this means *"take my workspace and create new image with all the changes I've done in my workspace*"
### Start and stop workspaces
The workspace started in daemon mode will continue working in the background.
See all the running docker containers
```
docker ps
```
Stop workspace
```sh
docker stop space-1
```
Workspace is stopped. All the processes and cron jobs are not running.
See all docker conntainers, including stopped
```
docker ps -a
```
Start workspace again. Processes and cron jobs are resumed.
```sh
docker start space-1
```
Delete workspace container (all work will be lost)
```
docker rm space-1
```
### Create new workspace image
Having made changes, you can commit them creating new image of the workspace. In order to create new workspace image with the
name "space-image" and version "0.2" execute
```
docker commit space-1 space-image:0.2
```
Run new workspace with
```
docker run --name space2 -d space-image:0.2
```
The new workspace accommodates all the changes that you've made in your space-1. Hence you can have versions of your workspaces.
Create different versions before the important changes.
### Manage workspace images
See all docker images
```
docker images
```
Delete workspace image entirely
```
docker rmi -f alnoda/base-workspace
```
**NOTE:** you cannot delete image if there is a running container created from it. Stop container first.
### Save and load workspace images
After you commit workspace container, and create new image out of it, you can push it to your docker registry or save it as a file.
**SAVING IMAGE AS FILE**
Assuming you created new image **space-image:0.4** from your workspace, you can save it as a tar file
```
docker save space-image:0.4 > space-image-0.4.tar
```
We can delete the image with
```
docker rmi -f space-image:0.4
```
And restore it from the tar file
```
docker load < space-image-0.4.tar
```
**PUSHING IMAGE TO YOUR REGISTRY**
A better way to manage images is docker registries. You can use docker registries in multiple clouds. They are cheap annd very convenient.
Check out for example, [Registry in DigitalOcean](https://www.digitalocean.com/products/container-registry/) or in [Scaleway container registry](https://www.scaleway.com/en/container-registry/). There are more.
Pushing image to registry is merely 2 extra commands: 1) tag image; 2) push image
You will be able to pull image on any device, local or cloud.

View file

@ -0,0 +1,137 @@
{
"base_app_url": "http://localhost:3012",
"email_from": "jobs@cronicle.com",
"smtp_hostname": "localhost",
"smtp_port": 25,
"secret_key": "68c55b2c6c578b22df5872e002c9b679",
"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,
"copy_job_logs_to": "",
"queue_dir": "queue",
"pid_file": "logs/cronicled.pid",
"debug_level": 9,
"maintenance": "04:00",
"list_row_max": 10000,
"job_data_expire_days": 180,
"child_kill_timeout": 10,
"dead_job_timeout": 120,
"master_ping_freq": 20,
"master_ping_timeout": 60,
"udp_broadcast_port": 3014,
"scheduler_startup_grace": 10,
"universal_web_hook": "",
"track_manual_jobs": false,
"max_jobs": 0,
"server_comm_use_hostnames": false,
"web_direct_connect": false,
"web_socket_use_hostnames": false,
"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": "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]"
},
"client": {
"name": "Cronicle",
"debug": 0,
"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
}
},
"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": 8023,
"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,
"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"
},
"free_accounts": 1,
"default_privileges": {
"admin": 1,
"create_events": 1,
"edit_events": 1,
"delete_events": 1,
"run_events": 1,
"abort_events": 1,
"state_update": 1
}
}
}

View file

@ -0,0 +1,16 @@
{
"port": 8021,
"baseURL": "",
"address": "0.0.0.0",
"log": "stdout",
"database": "/opt/filebrowser/database.db",
"root": "/home",
"allowEdit": true,
"allowNew": true,
"allowCommands": true,
"commands": ["apt", "ls", "cd", "rm", "cp", "mv", "cat", "echo", "tar", "zip", "unzip"],
"noAuth": true,
"auth": {
"method": "noauth"
}
}

View file

@ -0,0 +1,6 @@
mkdocs==1.2.1
# https://squidfunk.github.io/mkdocs-material/getting-started/#installation
mkdocs-material==7.1.8
# https://github.com/fralau/mkdocs_macros_plugin
mkdocs-macros-plugin==0.5.12

View file

@ -0,0 +1,68 @@
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
env/
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
*.egg-info/
.installed.cfg
*.egg
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*,cover
.hypothesis/
# Translations
*.mo
# Scrapy stuff:
.scrapy
# PyBuilder
target/
# IPython Notebook
.ipynb_checkpoints
# pyenv
.python-version
# virtualenv
venv/
ENV/
# MkDocs documentation
site/

View file

@ -0,0 +1,25 @@
**This is a starting point to create docs for this workspace!**
In order to change this page, simply modify the file `/home/docs/docs/README.md`. Changes will be applied automatically.
In order to add a new doc file, it is enough to create a file in the folder `/home/docs/docs` and add respective entry
to the configuratiion file `/home/docs/mkdcs.yaml`.
For example, [enter the terminal in the workspace](get-started.md#workspace-terminal),
and create new documentation file with some text at your will, and save changes
> `nano /home/docs/docs/new.md`
edit file `mkdcs.yaml`
> `nano /home/docs/mkdcs.yaml`
Add record about the new file to **nav**, and save changes
```yaml
nav:
- Home: pages/home/home.md
- About: README.md
- Get started: get-started.md
- New: new.md
```

View file

@ -0,0 +1,70 @@
<?xml version="1.0" encoding="UTF-8" standalone="no" ?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="1280" height="1024" viewBox="0 0 10000 9600" xml:space="preserve">
<desc>Created with Fabric.js 3.6.3</desc>
<defs>
</defs>
<g transform="matrix(2,0,0,2,640,512)" id="background-logo" >
</g>
<g transform="matrix(2,0,0,2,640,416.3)" id="logo-logo" >
<g transform="matrix(18.9,0,0,24.4,-502.2,-1009.3)" style="" paint-order="stroke" >
<g transform="matrix(0.2,0,0,-0.2,0,-61.6)" >
<path style="fill: rgb(64, 50, 44); fill-rule: nonzero; stroke: none; stroke-width: 1; stroke-linecap: butt; stroke-linejoin: miter; stroke-dasharray: none; stroke-dashoffset: 0; stroke-miterlimit: 4; opacity: 1" paint-order="stroke" transform="translate(-1625,-1745)" d="M 3062 1050.1 c -466.3 107.3 -950.8 107.3 -1417.1 0 c -16.2 -3.7 -26.6 -18.2 -23.3 -32.1 c 3.4 -13.9 19 -22.1 34.9 -18.4 c 458.6 105.5 935.2 105.5 1393.9 0 c 15.9 -3.7 31.5 4.5 34.9 18.4 c 3.4 13.9 -7 28.4 -23.3 32.1" stroke-linecap="round" />
</g>
<g transform="matrix(0.2,0,0,-0.2,-95.7,12.6)" >
<path style="fill: rgb(64, 50, 44); fill-rule: nonzero; stroke: none; stroke-width: 1; stroke-linecap: butt; stroke-linejoin: miter; stroke-dasharray: none; stroke-dashoffset: 0; stroke-miterlimit: 4; opacity: 1" paint-order="stroke" transform="translate(-1210,-1423.3)" d="M 1847.7 878.7 c 0 52.1 44.1 94.1 97 90.6 c 48.2 -3.2 84.6 -45.5 84.6 -93.8 V 490.5 c 0 -14.3 11.6 -25.9 25.9 -25.9 c 14.3 0 25.9 11.6 25.9 25.9 v 384 c 0 76.7 -58.8 142.8 -135.5 146.6 c -81.9 4 -149.8 -61.4 -149.8 -142.5 V 490.5 c 0 -14.3 11.6 -25.9 25.9 -25.9 c 14.3 0 25.9 11.6 25.9 25.9 v 388.2" stroke-linecap="round" />
</g>
<g transform="matrix(0.2,0,0,-0.2,-111.1,21.9)" >
<path style="fill: rgb(81, 173, 229); fill-rule: nonzero; stroke: none; stroke-width: 1; stroke-linecap: butt; stroke-linejoin: miter; stroke-dasharray: none; stroke-dashoffset: 0; stroke-miterlimit: 4; opacity: 1" paint-order="stroke" transform="translate(-1143.2,-1383.2)" d="M 1861.3 912.4 v -419.2 c 0 -5.8 4.7 -10.4 10.4 -10.4 c 5.7 0 10.4 4.7 10.4 10.4 v 419.2 c 0 5.8 -4.6 10.4 -10.4 10.4 c -5.7 0 -10.4 -4.7 -10.4 -10.4" stroke-linecap="round" />
</g>
<g transform="matrix(0.2,0,0,-0.2,-103.4,18.6)" >
<path style="fill: rgb(81, 173, 229); fill-rule: nonzero; stroke: none; stroke-width: 1; stroke-linecap: butt; stroke-linejoin: miter; stroke-dasharray: none; stroke-dashoffset: 0; stroke-miterlimit: 4; opacity: 1" paint-order="stroke" transform="translate(-1176.6,-1397.2)" d="M 1894.7 940.4 v -447.2 c 0 -5.8 4.7 -10.5 10.4 -10.5 c 5.7 0 10.4 4.7 10.4 10.5 v 447.2 c 0 5.8 -4.6 10.4 -10.4 10.4 c -5.7 0 -10.4 -4.7 -10.4 -10.4" stroke-linecap="round" />
</g>
<g transform="matrix(0.2,0,0,-0.2,-95.7,17.5)" >
<path style="fill: rgb(81, 173, 229); fill-rule: nonzero; stroke: none; stroke-width: 1; stroke-linecap: butt; stroke-linejoin: miter; stroke-dasharray: none; stroke-dashoffset: 0; stroke-miterlimit: 4; opacity: 1" paint-order="stroke" transform="translate(-1210,-1401.8)" d="M 1928.1 949.5 v -456.1 c 0 -5.9 4.6 -10.6 10.4 -10.6 c 5.7 0 10.4 4.8 10.4 10.6 v 456.1 c 0 5.9 -4.6 10.7 -10.4 10.7 c -5.7 0 -10.4 -4.8 -10.4 -10.7" stroke-linecap="round" />
</g>
<g transform="matrix(0.2,0,0,-0.2,-88,18.6)" >
<path style="fill: rgb(81, 173, 229); fill-rule: nonzero; stroke: none; stroke-width: 1; stroke-linecap: butt; stroke-linejoin: miter; stroke-dasharray: none; stroke-dashoffset: 0; stroke-miterlimit: 4; opacity: 1" paint-order="stroke" transform="translate(-1243.4,-1397.2)" d="M 1961.5 939.8 v -445.9 c 0 -6.1 4.6 -11.1 10.4 -11.1 c 5.7 0 10.4 5 10.4 11.1 v 445.9 c 0 6.1 -4.7 11.1 -10.4 11.1 c -5.7 0 -10.4 -5 -10.4 -11.1" stroke-linecap="round" />
</g>
<g transform="matrix(0.2,0,0,-0.2,-80.3,21.9)" >
<path style="fill: rgb(81, 173, 229); fill-rule: nonzero; stroke: none; stroke-width: 1; stroke-linecap: butt; stroke-linejoin: miter; stroke-dasharray: none; stroke-dashoffset: 0; stroke-miterlimit: 4; opacity: 1" paint-order="stroke" transform="translate(-1276.8,-1383.2)" d="M 1994.9 912.4 v -419.2 c 0 -5.8 4.6 -10.4 10.4 -10.4 c 5.7 0 10.4 4.7 10.4 10.4 v 419.2 c 0 5.8 -4.7 10.4 -10.4 10.4 c -5.7 0 -10.4 -4.7 -10.4 -10.4" stroke-linecap="round" />
</g>
<g transform="matrix(0.2,0,0,-0.2,0,12.6)" >
<path style="fill: rgb(64, 50, 44); fill-rule: nonzero; stroke: none; stroke-width: 1; stroke-linecap: butt; stroke-linejoin: miter; stroke-dasharray: none; stroke-dashoffset: 0; stroke-miterlimit: 4; opacity: 1" paint-order="stroke" transform="translate(-1625,-1423.3)" d="M 2262.7 878.7 c 0 52.1 44.1 94.1 97 90.6 c 48.2 -3.2 84.6 -45.5 84.6 -93.8 V 490.5 c 0 -14.3 11.6 -25.9 25.9 -25.9 c 14.3 0 25.9 11.6 25.9 25.9 v 384 c 0 76.7 -58.8 142.8 -135.5 146.6 c -81.9 4 -149.8 -61.4 -149.8 -142.5 V 490.5 c 0 -14.3 11.6 -25.9 25.9 -25.9 c 14.3 0 25.9 11.6 25.9 25.9 v 388.2" stroke-linecap="round" />
</g>
<g transform="matrix(0.2,0,0,-0.2,-15.4,21.9)" >
<path style="fill: rgb(191, 27, 44); fill-rule: nonzero; stroke: none; stroke-width: 1; stroke-linecap: butt; stroke-linejoin: miter; stroke-dasharray: none; stroke-dashoffset: 0; stroke-miterlimit: 4; opacity: 1" paint-order="stroke" transform="translate(-1558.2,-1383.2)" d="M 2276.3 912.4 v -419.2 c 0 -5.8 4.7 -10.4 10.4 -10.4 c 5.7 0 10.4 4.7 10.4 10.4 v 419.2 c 0 5.8 -4.6 10.4 -10.4 10.4 c -5.7 0 -10.4 -4.7 -10.4 -10.4" stroke-linecap="round" />
</g>
<g transform="matrix(0.2,0,0,-0.2,-7.7,18.6)" >
<path style="fill: rgb(191, 27, 44); fill-rule: nonzero; stroke: none; stroke-width: 1; stroke-linecap: butt; stroke-linejoin: miter; stroke-dasharray: none; stroke-dashoffset: 0; stroke-miterlimit: 4; opacity: 1" paint-order="stroke" transform="translate(-1591.6,-1397.2)" d="M 2309.7 940.4 v -447.2 c 0 -5.8 4.7 -10.5 10.4 -10.5 c 5.7 0 10.4 4.7 10.4 10.5 v 447.2 c 0 5.8 -4.6 10.4 -10.4 10.4 c -5.7 0 -10.4 -4.7 -10.4 -10.4" stroke-linecap="round" />
</g>
<g transform="matrix(0.2,0,0,-0.2,0,17.5)" >
<path style="fill: rgb(191, 27, 44); fill-rule: nonzero; stroke: none; stroke-width: 1; stroke-linecap: butt; stroke-linejoin: miter; stroke-dasharray: none; stroke-dashoffset: 0; stroke-miterlimit: 4; opacity: 1" paint-order="stroke" transform="translate(-1625,-1401.8)" d="M 2343.1 949.5 v -456.1 c 0 -5.9 4.6 -10.6 10.4 -10.6 c 5.7 0 10.4 4.8 10.4 10.6 v 456.1 c 0 5.9 -4.6 10.7 -10.4 10.7 c -5.7 0 -10.4 -4.8 -10.4 -10.7" stroke-linecap="round" />
</g>
<g transform="matrix(0.2,0,0,-0.2,7.7,18.6)" >
<path style="fill: rgb(191, 27, 44); fill-rule: nonzero; stroke: none; stroke-width: 1; stroke-linecap: butt; stroke-linejoin: miter; stroke-dasharray: none; stroke-dashoffset: 0; stroke-miterlimit: 4; opacity: 1" paint-order="stroke" transform="translate(-1658.4,-1397.2)" d="M 2376.5 939.8 v -445.9 c 0 -6.1 4.6 -11.1 10.4 -11.1 c 5.7 0 10.4 5 10.4 11.1 v 445.9 c 0 6.1 -4.7 11.1 -10.4 11.1 c -5.7 0 -10.4 -5 -10.4 -11.1" stroke-linecap="round" />
</g>
<g transform="matrix(0.2,0,0,-0.2,15.4,21.9)" >
<path style="fill: rgb(191, 27, 44); fill-rule: nonzero; stroke: none; stroke-width: 1; stroke-linecap: butt; stroke-linejoin: miter; stroke-dasharray: none; stroke-dashoffset: 0; stroke-miterlimit: 4; opacity: 1" paint-order="stroke" transform="translate(-1691.8,-1383.2)" d="M 2409.9 912.4 v -419.2 c 0 -5.8 4.6 -10.4 10.4 -10.4 c 5.7 0 10.4 4.7 10.4 10.4 v 419.2 c 0 5.8 -4.7 10.4 -10.4 10.4 c -5.7 0 -10.4 -4.7 -10.4 -10.4" stroke-linecap="round" />
</g>
<g transform="matrix(0.2,0,0,-0.2,95.7,12.6)" >
<path style="fill: rgb(64, 50, 44); fill-rule: nonzero; stroke: none; stroke-width: 1; stroke-linecap: butt; stroke-linejoin: miter; stroke-dasharray: none; stroke-dashoffset: 0; stroke-miterlimit: 4; opacity: 1" paint-order="stroke" transform="translate(-2040,-1423.3)" d="M 2677.7 878.7 c 0 52.1 44.1 94.1 97 90.6 c 48.2 -3.2 84.6 -45.5 84.6 -93.8 V 490.5 c 0 -14.3 11.6 -25.9 25.9 -25.9 c 14.3 0 25.9 11.6 25.9 25.9 v 384 c 0 76.7 -58.8 142.8 -135.5 146.6 c -81.9 4 -149.8 -61.4 -149.8 -142.5 V 490.5 c 0 -14.3 11.6 -25.9 25.9 -25.9 c 14.3 0 25.9 11.6 25.9 25.9 v 388.2" stroke-linecap="round" />
</g>
<g transform="matrix(0.2,0,0,-0.2,80.3,21.9)" >
<path style="fill: rgb(128, 204, 40); fill-rule: nonzero; stroke: none; stroke-width: 1; stroke-linecap: butt; stroke-linejoin: miter; stroke-dasharray: none; stroke-dashoffset: 0; stroke-miterlimit: 4; opacity: 1" paint-order="stroke" transform="translate(-1973.2,-1383.2)" d="M 2691.3 912.4 v -419.2 c 0 -5.8 4.7 -10.4 10.4 -10.4 c 5.7 0 10.4 4.7 10.4 10.4 v 419.2 c 0 5.8 -4.6 10.4 -10.4 10.4 c -5.7 0 -10.4 -4.7 -10.4 -10.4" stroke-linecap="round" />
</g>
<g transform="matrix(0.2,0,0,-0.2,88,18.6)" >
<path style="fill: rgb(128, 204, 40); fill-rule: nonzero; stroke: none; stroke-width: 1; stroke-linecap: butt; stroke-linejoin: miter; stroke-dasharray: none; stroke-dashoffset: 0; stroke-miterlimit: 4; opacity: 1" paint-order="stroke" transform="translate(-2006.6,-1397.2)" d="M 2724.7 940.4 v -447.2 c 0 -5.8 4.7 -10.5 10.4 -10.5 c 5.7 0 10.4 4.7 10.4 10.5 v 447.2 c 0 5.8 -4.6 10.4 -10.4 10.4 c -5.7 0 -10.4 -4.7 -10.4 -10.4" stroke-linecap="round" />
</g>
<g transform="matrix(0.2,0,0,-0.2,95.7,17.5)" >
<path style="fill: rgb(128, 204, 40); fill-rule: nonzero; stroke: none; stroke-width: 1; stroke-linecap: butt; stroke-linejoin: miter; stroke-dasharray: none; stroke-dashoffset: 0; stroke-miterlimit: 4; opacity: 1" paint-order="stroke" transform="translate(-2040,-1401.8)" d="M 2758.1 949.5 v -456.1 c 0 -5.9 4.6 -10.6 10.4 -10.6 c 5.7 0 10.4 4.8 10.4 10.6 v 456.1 c 0 5.9 -4.6 10.7 -10.4 10.7 c -5.7 0 -10.4 -4.8 -10.4 -10.7" stroke-linecap="round" />
</g>
<g transform="matrix(0.2,0,0,-0.2,103.4,18.6)" >
<path style="fill: rgb(128, 204, 40); fill-rule: nonzero; stroke: none; stroke-width: 1; stroke-linecap: butt; stroke-linejoin: miter; stroke-dasharray: none; stroke-dashoffset: 0; stroke-miterlimit: 4; opacity: 1" paint-order="stroke" transform="translate(-2073.4,-1397.2)" d="M 2791.5 939.8 v -445.9 c 0 -6.1 4.6 -11.1 10.4 -11.1 c 5.7 0 10.4 5 10.4 11.1 v 445.9 c 0 6.1 -4.7 11.1 -10.4 11.1 c -5.7 0 -10.4 -5 -10.4 -11.1" stroke-linecap="round" />
</g>
<g transform="matrix(0.2,0,0,-0.2,111.1,21.9)" >
<path style="fill: rgb(128, 204, 40); fill-rule: nonzero; stroke: none; stroke-width: 1; stroke-linecap: butt; stroke-linejoin: miter; stroke-dasharray: none; stroke-dashoffset: 0; stroke-miterlimit: 4; opacity: 1" paint-order="stroke" transform="translate(-2106.8,-1383.2)" d="M 2824.8 912.4 v -419.2 c 0 -5.8 4.6 -10.4 10.4 -10.4 c 5.7 0 10.4 4.7 10.4 10.4 v 419.2 c 0 5.8 -4.7 10.4 -10.4 10.4 c -5.7 0 -10.4 -4.7 -10.4 -10.4" stroke-linecap="round" />
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View file

@ -0,0 +1,16 @@
window.MathJax = {
tex: {
inlineMath: [["\\(", "\\)"]],
displayMath: [["\\[", "\\]"]],
processEscapes: true,
processEnvironments: true
},
options: {
ignoreHtmlClass: ".*|",
processHtmlClass: "arithmatex"
}
};
document$.subscribe(() => {
MathJax.typesetPromise()
})

View file

@ -0,0 +1,99 @@
<style>
/* These styles apply only to this page! */
.md-content__button {
display: none;
}
.md-typeset h1 {
line-height: 0;
margin: 0;
margin-left: -9999px;
}
.quickstart-wrapper {
min-width: 300px;
display: flex;
flex-wrap: wrap;
justify-content: center;
padding-left: -50px;
column-gap: 50px;
row-gap: 50px;
}
.quickstart-wrapper > div {
flex: 300px;
max-width: 300px;
}
.tool-img{
box-shadow: rgba(0, 0, 0, 0.24) 0px 5px 5px;
border-radius: 5px;
}
.tool-caption{
font-family: Roboto, Helvetica, sans-serif;
text-align: center;
margin-top: 10px;
font-size: 1.2rem;
font-weight: bold;
/* font-size: 1.25em;
font-weight: 400; */
letter-spacing: -.02em;
line-height: 1.5;
}
.tool-description{
font-family: Helvetica, sans-serif;
text-align: center;
margin-top: 10px;
font-size: 0.7rem;
font-style: oblique;
/* font-weight: bold; */
}
</style>
{%
set tools = [
{
"env": "FILEBROWSER_URL",
"name": "File Browser",
"image": "Filebrowser.png",
"description": "Browse files inside running docker container. Upload and download files and folders to and from your Workspace, no matter where your Workspace is running: on local or in cloud"
},
{
"env": "CRONICLE_URL",
"name": "Cronicle",
"image": "Cronicle.png",
"description": "Schedule jobs, tasks and bacground scripts. Powerful tool to manage schedules, observe and monitor executions."
},
{
"env": "UNGIT_URL",
"name": "Ungit",
"image": "Ungit.png",
"description": "Manage Git repositories and work flow using beautiful UI"
},
{
"env": "STATICFS_URL",
"name": "Static File Server",
"image": "Static-server.png",
"description": "Serve any static websites like a breeze"
}
]
%}
<div class="quickstart-wrapper">
{% for tool in tools %}
{% set tool_url = get_tool_url(tool.env) %}
<div>
<a href="{{ tool_url }}" target="_blank" rel="noopener noreferrer">
<img src="{{ tool.image }}" class="tool-img"/>
</a>
<a href="{{ tool_url }}">
<div class="tool-caption">{{ tool.name }}</div>
</a>
<div class="tool-description">{{ tool.description }}</div>
</div>
{% endfor %}
</div>

Binary file not shown.

After

Width:  |  Height:  |  Size: 870 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 303 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 652 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 587 KiB

View file

@ -0,0 +1,49 @@
"""
Basic example of a Mkdocs-macros module.
Include this {{ macros_info() }} in any page to get complete macro info
"""
import os
port_increments = {
"DOCS_URL": 0,
"FILEBROWSER_URL": 1,
"STATICFS_URL": 2,
"CRONICLE_URL": 3,
"UNGIT_URL": 4}
# this function name should not be changed
def define_env(env):
"""
This is the hook for defining variables, macros and filters
- variables: the dictionary that contains the environment variables
- macro: a decorator function, to declare a macro.
- filter: a function with one of more arguments,
used to perform a transformation
"""
@env.macro
def get_tool_url(env):
try:
return os.environ[name]
except:
# Get host
host = "localhost"
try:
host = os.environ["WRK_HOST"]
except:
pass
# Entry port - port relative to which other ports will be calculated
entry_port = 8020
try:
entry_port = int(os.environ["ENTRY_PORT"])
except:
pass
# Assign port
try:
port = port_increments[env] + entry_port
except:
port = 80
return f"http://{host}:{port}"

View file

@ -0,0 +1,70 @@
# ===========================================================
# NAVIGATION
# ===========================================================
nav:
- Home: pages/home/home.md
- About: README.md
- Get started: get-started.md
# ===========================================================
# CONFIGURATION
# ===========================================================
site_name: Base Workspace
repo_url: https://github.com/Alnoda/workspaces-in-docker/tree/main/workspaces/base-workspace
site_url: https://alnoda.org
edit_uri: ""
# ===========================================================
# APPEARANCE
# ===========================================================
theme:
name: 'material'
favicon: 'assets/favicon.ico'
logo: 'assets/Alnoda-logo.svg'
custom_dir: overrides
icon:
repo: fontawesome/brands/git-alt
features:
- navigation.instant
palette:
- scheme: default
toggle:
icon: material/toggle-switch-off-outline
name: Switch to light mode
primary: brown
accent: deep orange
- scheme: slate
toggle:
icon: material/toggle-switch
name: Switch to dark mode
primary: orange
accent: red
extra:
# Link to open when your logo is clicked
homepage: https://alnoda.org
host_url: http://localhost
plugins:
- search
# Enable Macros and jinja2 templates
- macros:
module_name: macros/helpers
extra_javascript:
- javascripts/config.js
- https://polyfill.io/v3/polyfill.min.js?features=es6

View file

@ -0,0 +1,44 @@
[program:mkdocs]
directory=/home/docs
command=/bin/sh -c " mkdocs serve -a 0.0.0.0:8020 "
stderr_logfile = /var/log/mkdocs/mkdocs-stderr.log
stdout_logfile = /var/log/mkdocs/mkdocs-stdout.log
logfile_maxbytes = 1024
[program:filebrowser]
directory=/opt/filebrowser
command=/bin/sh -c " filebrowser "
stderr_logfile = /var/log/filebrowser/filebrowser-stderr.log
stdout_logfile = /var/log/filebrowser/filebrowser-stdout.log
logfile_maxbytes = 1024
[program:serve]
directory=/home/static-server
command=/bin/sh -c " cd /opt/serve; . env/bin/activate; serve -p 8022 /home/static-server "
stderr_logfile = /var/log/static-file-server/serve-stderr.log
stdout_logfile = /var/log/static-file-server/serve-stdout.log
logfile_maxbytes = 1024
[program:cronicle]
directory=/opt/cronicle
command=/bin/sh -c " rm /opt/cronicle/logs/cronicled.pid || true; cd /opt/cronicle; . env/bin/activate; /opt/cronicle/bin/control.sh setup; /opt/cronicle/bin/control.sh start "
stderr_logfile = /var/log/cronicle/cronicle-stderr.log
stdout_logfile = /var/log/cronicle/cronicle-stdout.log
logfile_maxbytes = 1024
exitcodes=0
startsecs=0
[program:ungit]
directory=/opt/ungit
command=/bin/sh -c " cd /opt/ungit; . env/bin/activate; ungit --port=8024 --ungitBindIp=0.0.0.0 --launchBrowser=false --autoFetch=false --bugtracking=false --authentication=false "
stderr_logfile = /var/log/ungit/ungit-stderr.log
stdout_logfile = /var/log/ungit/ungit-stdout.log
logfile_maxbytes = 1024

View file

@ -0,0 +1,24 @@
ARG docker_registry=docker.io/alnoda
ARG image_tag=18.04-0.5
FROM ${docker_registry}/base-workspace:${image_tag}
USER root
COPY supervisord-codeserver.conf /etc/supervisord/
RUN echo "------------------------------------------------------ code-server" \
&& mkdir -p -m 777 /opt/codeserver \
&& cd /opt/codeserver && nodeenv --node=12.18.3 --npm=6.0.0 env \
&& cd /opt/codeserver && . env/bin/activate && npm install -g --unsafe-perm code-server@3.10.2 \
&& mkdir -p -m 777 /home/project \
&& mkdir -p -m 777 /opt/codeserver/data \
&& mkdir -p -m 777 /opt/codeserver/extensions \
&& mkdir -p -m 777 /var/log/codeserver \
&& echo "------------------------------------------------------ user" \
&& chown -R abc /home/project \
&& chown -R abc /opt/codeserver \
&& chown -R abc /opt/codeserver/data \
&& chown -R abc /opt/codeserver/extensions
COPY code-server-run.sh /opt/codeserver/code-server-run.sh

View file

@ -0,0 +1,25 @@
#!/usr/bin/bash
if [ -n "${PASSWORD}" ] || [ -n "${HASHED_PASSWORD}" ]; then
AUTH="password"
else
AUTH="none"
echo "starting with no password"
fi
if [ -z ${PROXY_DOMAIN+x} ]; then
PROXY_DOMAIN_ARG=""
else
PROXY_DOMAIN_ARG="--proxy-domain=${PROXY_DOMAIN}"
fi
export SHELL=/bin/zsh
code-server \
--bind-addr 0.0.0.0:8025 \
--user-data-dir /opt/vscode/data \
--extensions-dir /opt/vscode/extensions \
--disable-telemetry \
--auth "${AUTH}" \
"${PROXY_DOMAIN_ARG}" \
/home/project

View file

@ -0,0 +1,6 @@
[program:codeserver]
directory=/opt/codeserver
command=/bin/sh -c " cd /opt/codeserver; . env/bin/activate; bash code-server-run.sh "
stderr_logfile = /var/log/codeserver/serve-stderr.log
stdout_logfile = /var/log/codeserver/serve-stdout.log
logfile_maxbytes = 1024

View file

@ -0,0 +1,23 @@
ARG docker_registry=docker.io/alnoda
ARG image_tag=18.04-03
FROM ${docker_registry}/workspace-in-docker:${image_tag}
USER root
# Change Theia color theme for this magic workspace to stand out
COPY settings.json /home/abc/.theia/settings.json
# More dependencies for mkdocs and markdown
COPY mkdocs-requirements.txt /home/abc/installed-python-packages
RUN apt-get -y update \
&& echo "-------------------------------------------- weasyprint" \
&& apt-get install -y build-essential python3-dev python3-pip python3-setuptools python3-wheel python3-cffi libcairo2 libpango-1.0-0 libpangocairo-1.0-0 libgdk-pixbuf2.0-0 libffi-dev shared-mime-info \
&& echo "-------------------------------------------- mkdocs plugins" \
&& pip install -r /home/abc/installed-python-packages/mkdocs-requirements.txt
USER abc
# Custom docs for this workspace
COPY mkdocs /home/docs
COPY README.md /home/docs/docs/get-started.md

Some files were not shown because too many files have changed in this diff Show more