├── frontend ├── src │ ├── config │ │ ├── modules.pattern │ │ ├── index.js │ │ └── src │ │ │ ├── states.js │ │ │ ├── http.js │ │ │ └── restangular.js │ ├── components │ │ ├── modules.pattern │ │ ├── index.js │ │ └── src │ │ │ ├── databox │ │ │ ├── style.scss │ │ │ ├── template.html │ │ │ └── index.js │ │ │ ├── json-viewer │ │ │ └── index.js │ │ │ ├── tabs │ │ │ ├── style.scss │ │ │ └── index.js │ │ │ ├── header │ │ │ ├── style.scss │ │ │ └── index.js │ │ │ ├── dropdown-button │ │ │ └── index.js │ │ │ └── ui-grid │ │ │ ├── template.html │ │ │ ├── style.scss │ │ │ ├── index.js │ │ │ └── ui-grid.service.js │ ├── directives │ │ ├── modules.pattern │ │ └── index.js │ ├── services │ │ ├── modules.pattern │ │ ├── index.js │ │ └── src │ │ │ ├── task │ │ │ └── index.js │ │ │ ├── output │ │ │ └── index.js │ │ │ ├── parse-error │ │ │ └── index.js │ │ │ ├── filter-enabled │ │ │ └── index.js │ │ │ ├── save-file │ │ │ └── index.js │ │ │ └── notify │ │ │ ├── notify.js │ │ │ └── index.js │ ├── states │ │ ├── modules.states.pattern │ │ ├── modules.components.pattern │ │ ├── src │ │ │ ├── app │ │ │ │ └── state.js │ │ │ ├── app.home │ │ │ │ ├── style.scss │ │ │ │ └── state.js │ │ │ └── app.task │ │ │ │ ├── style.scss │ │ │ │ └── state.js │ │ └── index.js │ ├── app │ │ ├── config.js │ │ └── index.js │ └── boot.js ├── assets │ ├── favicon.ico │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ ├── mstile-150x150.png │ ├── apple-touch-icon.png │ ├── android-chrome-192x192.png │ ├── android-chrome-512x512.png │ ├── browserconfig.xml │ ├── site.webmanifest │ ├── index.html │ └── safari-pinned-tab.svg ├── .gitignore ├── scss │ ├── fonts.scss │ ├── style.scss │ └── global.scss ├── server.js ├── .eslintrc.js ├── package.json ├── webpack.config.js └── Gruntfile.js ├── .dockerignore ├── .gitignore ├── misc └── ticktock.jpg ├── lib └── app │ ├── fs.js │ ├── tasks.js │ ├── random-number.js │ ├── delay.js │ ├── writable-stream.js │ ├── run-task.js │ ├── exec-task.js │ └── base-task.js ├── example ├── notifications │ └── index.js └── config.yml ├── Gruntfile.js ├── di └── services │ ├── cli │ └── index.js │ ├── docker │ └── index.js │ ├── boot │ └── index.js │ ├── ipc-server │ └── index.js │ ├── log │ └── index.js │ ├── knex │ └── index.js │ ├── config │ └── index.js │ ├── notifications │ └── index.js │ ├── task-manager │ └── index.js │ └── api │ └── index.js ├── tasks ├── seed.js ├── rollback.js ├── reset-db.js ├── migrate.js ├── init.js └── create-migration.js ├── start.js ├── migrations └── 20180402165616_outputs.js ├── docker-compose.yml ├── report ├── Dockerfile ├── package.json ├── execute └── README.md /frontend/src/config/modules.pattern: -------------------------------------------------------------------------------- 1 | ./src/* -------------------------------------------------------------------------------- /frontend/src/components/modules.pattern: -------------------------------------------------------------------------------- 1 | ./src/* -------------------------------------------------------------------------------- /frontend/src/directives/modules.pattern: -------------------------------------------------------------------------------- 1 | ./src/* -------------------------------------------------------------------------------- /frontend/src/services/modules.pattern: -------------------------------------------------------------------------------- 1 | ./src/* -------------------------------------------------------------------------------- /frontend/src/states/modules.states.pattern: -------------------------------------------------------------------------------- 1 | ./src/**/state.js -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | example 4 | README.md -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Files 2 | .DS_Store 3 | 4 | # Folders 5 | node_modules -------------------------------------------------------------------------------- /frontend/src/states/modules.components.pattern: -------------------------------------------------------------------------------- 1 | ./src/**/*/components/* -------------------------------------------------------------------------------- /misc/ticktock.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tkambler/ticktock/HEAD/misc/ticktock.jpg -------------------------------------------------------------------------------- /frontend/src/config/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import 'glob-loader!./modules.pattern'; -------------------------------------------------------------------------------- /frontend/src/services/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import 'glob-loader!./modules.pattern'; -------------------------------------------------------------------------------- /frontend/src/components/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import 'glob-loader!./modules.pattern'; -------------------------------------------------------------------------------- /frontend/src/directives/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import 'glob-loader!./modules.pattern'; -------------------------------------------------------------------------------- /frontend/assets/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tkambler/ticktock/HEAD/frontend/assets/favicon.ico -------------------------------------------------------------------------------- /lib/app/fs.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = require('bluebird').promisifyAll(require('fs-extra')); -------------------------------------------------------------------------------- /frontend/assets/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tkambler/ticktock/HEAD/frontend/assets/favicon-16x16.png -------------------------------------------------------------------------------- /frontend/assets/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tkambler/ticktock/HEAD/frontend/assets/favicon-32x32.png -------------------------------------------------------------------------------- /frontend/assets/mstile-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tkambler/ticktock/HEAD/frontend/assets/mstile-150x150.png -------------------------------------------------------------------------------- /frontend/.gitignore: -------------------------------------------------------------------------------- 1 | # Files 2 | .DS_Store 3 | webpack.stats.json 4 | 5 | # Folders 6 | .sass-cache 7 | node_modules 8 | public -------------------------------------------------------------------------------- /frontend/assets/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tkambler/ticktock/HEAD/frontend/assets/apple-touch-icon.png -------------------------------------------------------------------------------- /frontend/assets/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tkambler/ticktock/HEAD/frontend/assets/android-chrome-192x192.png -------------------------------------------------------------------------------- /frontend/assets/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tkambler/ticktock/HEAD/frontend/assets/android-chrome-512x512.png -------------------------------------------------------------------------------- /frontend/scss/fonts.scss: -------------------------------------------------------------------------------- 1 | $fa-font-path: "/css/fonts"; 2 | @import "opensans-npm-webfont/style.scss"; 3 | @import "font-awesome/scss/font-awesome.scss"; -------------------------------------------------------------------------------- /lib/app/tasks.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | 'ExecTask': require('./exec-task'), 5 | 'RunTask': require('./run-task') 6 | }; -------------------------------------------------------------------------------- /example/notifications/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = (task, res) => { 4 | 5 | // console.log('Custom notification', task, res); 6 | 7 | }; -------------------------------------------------------------------------------- /lib/app/random-number.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = (min, max) => { 4 | 5 | return Math.floor(Math.random() * (max - min + 1) + min); 6 | 7 | }; 8 | -------------------------------------------------------------------------------- /Gruntfile.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = (grunt) => { 4 | 5 | grunt.config.init({}); 6 | 7 | grunt.loadTasks('tasks'); 8 | 9 | grunt.task.run('init'); 10 | 11 | }; -------------------------------------------------------------------------------- /frontend/scss/style.scss: -------------------------------------------------------------------------------- 1 | $icon-font-path: '/css/fonts/'; 2 | 3 | @import 'bootstrap-sass/assets/stylesheets/bootstrap'; 4 | @import 'bootstrap-sass/assets/stylesheets/bootstrap/_theme'; 5 | @import './fonts'; 6 | @import './global'; -------------------------------------------------------------------------------- /di/services/cli/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | exports = module.exports = function(config, knex, docker) { 4 | 5 | return {}; 6 | 7 | }; 8 | 9 | exports['@singleton'] = true; 10 | exports['@require'] = ['config', 'knex', 'docker']; -------------------------------------------------------------------------------- /lib/app/delay.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Promise = require('bluebird'); 4 | 5 | module.exports = (seconds) => { 6 | 7 | return new Promise((resolve, reject) => { 8 | setTimeout(resolve, seconds * 1000); 9 | }); 10 | 11 | }; -------------------------------------------------------------------------------- /frontend/src/app/config.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const config = { 4 | 'base_api_url': `${window.location.protocol}//${window.location.host}/api`, 5 | 'base_api_protocol': window.location.protocol.split(':')[0], 6 | 'base_api_host': window.location.host 7 | }; 8 | 9 | export default config; -------------------------------------------------------------------------------- /frontend/assets/browserconfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | #da532c 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /di/services/docker/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | exports = module.exports = function() { 4 | 5 | const Docker = require('dockerode'); 6 | 7 | const docker = new Docker({ 8 | 'socketPath': '/var/run/docker.sock', 9 | 'Promise': require('bluebird') 10 | }); 11 | 12 | return docker; 13 | 14 | }; 15 | 16 | exports['@singleton'] = true; 17 | exports['@require'] = []; -------------------------------------------------------------------------------- /frontend/server.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const port = 9000; 4 | const express = require('express'); 5 | const app = express(); 6 | const path = require('path'); 7 | 8 | app.use('/', express.static(path.resolve(__dirname, 'public'))); 9 | 10 | app.listen(port, (err) => { 11 | 12 | if (err) { 13 | throw err; 14 | } 15 | 16 | console.log(`Server is listening on port: ${port}`); 17 | 18 | }); -------------------------------------------------------------------------------- /frontend/src/services/src/task/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import app from 'app'; 4 | import _ from 'lodash'; 5 | 6 | app.factory('Task', function(Restangular) { 7 | 8 | Restangular = Restangular.withConfig(function(RestangularConfigurer) { 9 | }); 10 | 11 | Restangular.extendModel('tasks', function(model) { 12 | return model; 13 | }); 14 | 15 | return Restangular.service('tasks'); 16 | 17 | }); -------------------------------------------------------------------------------- /frontend/src/app/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import angular from 'angular'; 4 | 5 | const app = angular.module('app', [ 6 | 'ngSanitize', 7 | 'restangular', 8 | 'ui.bootstrap', 9 | 'ui.router', 10 | 'validation.match', 11 | 'angular-loading-bar', 12 | 'angular-json-tree', 13 | 'ngJsTree', 14 | ]); 15 | 16 | export default app; 17 | 18 | import config from './config'; 19 | 20 | app.constant('config', config); -------------------------------------------------------------------------------- /frontend/src/services/src/output/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import app from 'app'; 4 | import _ from 'lodash'; 5 | 6 | app.factory('Output', function(Restangular) { 7 | 8 | Restangular = Restangular.withConfig(function(RestangularConfigurer) { 9 | }); 10 | 11 | Restangular.extendModel('outputs', function(model) { 12 | return model; 13 | }); 14 | 15 | return Restangular.service('outputs'); 16 | 17 | }); -------------------------------------------------------------------------------- /tasks/seed.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = (grunt) => { 4 | 5 | grunt.registerTask('seed', 'Import Knex seeds', function() { 6 | 7 | const done = this.async(); 8 | const config = require('services/config'); 9 | const knex = require('services/knex'); 10 | 11 | return knex.seed.run(config.get('db:seeds')) 12 | .then(done) 13 | .catch(grunt.fatal); 14 | 15 | }); 16 | 17 | }; -------------------------------------------------------------------------------- /tasks/rollback.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = (grunt) => { 4 | 5 | grunt.registerTask('rollback', 'Rollback the latest Knex migration', function() { 6 | 7 | const done = this.async(); 8 | const knex = require('services/knex'); 9 | 10 | return knex.migrate.rollback({ 11 | 'disableTransactions': true 12 | }) 13 | .then(done) 14 | .catch(grunt.fatal); 15 | 16 | }); 17 | 18 | }; -------------------------------------------------------------------------------- /frontend/src/services/src/parse-error/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import app from 'app'; 4 | import _ from 'lodash'; 5 | 6 | app.factory('parseError', function() { 7 | 8 | return (message, defaultMessage = 'Unknown Error') => { 9 | if (_.isString(message)) return message; 10 | if (_.get(message, 'data.error')) return message.data.error; 11 | if (_.get(message, 'message')) return message.message; 12 | return defaultMessage; 13 | }; 14 | 15 | }); -------------------------------------------------------------------------------- /frontend/src/config/src/states.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import app from 'app'; 4 | import _ from 'lodash'; 5 | import states from 'states'; 6 | 7 | app.config(function($stateProvider, $urlRouterProvider) { 8 | 9 | _.each(states, (state, name) => { 10 | _.defaults(state, { 11 | 'reloadOnSearch': false, 12 | 'resolve': {} 13 | }); 14 | return $stateProvider.state(state); 15 | }); 16 | 17 | $urlRouterProvider.otherwise('/home'); 18 | 19 | }); -------------------------------------------------------------------------------- /start.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | require('json5/lib/require'); 4 | 5 | const Ahoy = require('ahoy-di'); 6 | const path = require('path'); 7 | const fs = require('fs'); 8 | 9 | const container = new Ahoy({ 10 | 'id': 'services', 11 | 'extendRequire': true, 12 | 'services': path.resolve(__dirname, 'di/services') 13 | }); 14 | 15 | container.constant('appDir', __dirname); 16 | 17 | container.load('boot') 18 | .catch((err) => { 19 | console.log(err); 20 | process.exit(1); 21 | }); -------------------------------------------------------------------------------- /frontend/assets/site.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "", 3 | "short_name": "", 4 | "icons": [ 5 | { 6 | "src": "/android-chrome-192x192.png", 7 | "sizes": "192x192", 8 | "type": "image/png" 9 | }, 10 | { 11 | "src": "/android-chrome-512x512.png", 12 | "sizes": "512x512", 13 | "type": "image/png" 14 | } 15 | ], 16 | "theme_color": "#ffffff", 17 | "background_color": "#ffffff", 18 | "display": "standalone" 19 | } 20 | -------------------------------------------------------------------------------- /tasks/reset-db.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = (grunt) => { 4 | 5 | grunt.registerTask('reset-db', 'Reset the DB', function() { 6 | 7 | const config = require('services/config'); 8 | const fs = require('fs'); 9 | 10 | try { 11 | fs.unlinkSync(config.get('db:path')); 12 | } catch(err) { 13 | if (err.code !== 'ENOENT') { 14 | throw err; 15 | } 16 | } 17 | 18 | grunt.task.run(['migrate', 'seed']); 19 | 20 | }); 21 | 22 | }; -------------------------------------------------------------------------------- /frontend/src/services/src/filter-enabled/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import app from 'app'; 4 | import _ from 'lodash'; 5 | 6 | app.factory('filterEnabled', function() { 7 | 8 | return (items) => { 9 | return items.filter((item) => { 10 | if (_.isBoolean(item.enabled)) { 11 | return item.enabled; 12 | } else if (_.isFunction(item.enabled)) { 13 | return item.enabled(); 14 | } else { 15 | return true; 16 | } 17 | }); 18 | }; 19 | 20 | }); -------------------------------------------------------------------------------- /frontend/src/boot.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import app from 'app'; 4 | import Promise from 'bluebird'; 5 | 6 | app.run(function($rootScope, $state, $log, $trace, $transitions) { 7 | 8 | $log.debug('App is running.'); 9 | 10 | Promise.setScheduler((cb) => { 11 | $rootScope.$evalAsync(cb); 12 | }); 13 | 14 | $transitions.onStart({ 15 | }, (transition) => { 16 | transition.promise 17 | .then(() => { 18 | $rootScope.stateClass = $state.current.name.replace(/\./g, '-'); 19 | }); 20 | }); 21 | 22 | }); -------------------------------------------------------------------------------- /tasks/migrate.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = (grunt) => { 4 | 5 | /** 6 | * Migrates the database to a specified migration point. 7 | * 8 | * @example $ grunt migrate 9 | */ 10 | grunt.registerTask('migrate', 'Run Knex migrations', function() { 11 | 12 | const done = this.async(); 13 | const knex = require('services/knex'); 14 | 15 | return knex.migrate.latest({ 16 | 'disableTransactions': true 17 | }) 18 | .then(done) 19 | .catch(grunt.fatal); 20 | 21 | }); 22 | 23 | }; -------------------------------------------------------------------------------- /frontend/src/states/src/app/state.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | 'name': 'app', 5 | 'resolve': { 6 | }, 7 | 'views': { 8 | '': { 9 | 'controllerAs': '$ctrl', 10 | 'controller': function($log, $rootScope, $scope, $state) { 11 | 12 | return new class { 13 | 14 | }; 15 | 16 | }, 17 | 'template': ` 18 |
19 | 20 | ` 21 | } 22 | } 23 | }; -------------------------------------------------------------------------------- /frontend/src/states/src/app.home/style.scss: -------------------------------------------------------------------------------- 1 | .primary.app-home { 2 | 3 | .state-container { 4 | 5 | display: flex; 6 | flex-direction: row; 7 | justify-content: space-between; 8 | height: 100%; 9 | 10 | .tree-nav { 11 | display: flex; 12 | flex-direction: column; 13 | justify-content: flex-start; 14 | width: 25%; 15 | overflow-y: auto; 16 | } 17 | 18 | .databox-container { 19 | width: 75%; 20 | padding-left: 15px; 21 | padding-top: 15px; 22 | } 23 | 24 | } 25 | 26 | } -------------------------------------------------------------------------------- /frontend/src/states/src/app.task/style.scss: -------------------------------------------------------------------------------- 1 | .primary.app-task { 2 | 3 | .state-container { 4 | 5 | display: flex; 6 | flex-direction: row; 7 | justify-content: space-between; 8 | height: 100%; 9 | 10 | .tree-nav { 11 | display: flex; 12 | flex-direction: column; 13 | justify-content: flex-start; 14 | width: 25%; 15 | overflow-y: auto; 16 | } 17 | 18 | .databox-container { 19 | width: 75%; 20 | padding-left: 15px; 21 | padding-top: 15px; 22 | } 23 | 24 | } 25 | 26 | } -------------------------------------------------------------------------------- /di/services/boot/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | exports = module.exports = function(config, docker, taskManager, ipcServer, api) { 4 | 5 | class Boot { 6 | 7 | constructor() { 8 | 9 | process.on('SIGTERM', () => { 10 | return taskManager.shutdown() 11 | .then(() => { 12 | process.exit(0); 13 | }); 14 | }); 15 | 16 | } 17 | 18 | } 19 | 20 | return new Boot(); 21 | 22 | }; 23 | 24 | exports['@singleton'] = true; 25 | exports['@require'] = ['config', 'docker', 'task-manager', 'ipc-server', 'api']; -------------------------------------------------------------------------------- /frontend/src/config/src/http.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import app from 'app'; 4 | 5 | app.config(function($httpProvider, config) { 6 | 7 | $httpProvider.defaults.withCredentials = true; 8 | 9 | /* Prepend all outbound requests to URLs containing `api` with the appropriate hostname. */ 10 | $httpProvider.interceptors.push(function () { 11 | return { 12 | 'request': function(c) { 13 | if (c.url.indexOf('/api/') === 0) { 14 | c.url = `${config.base_api_protocol}://${config.base_api_host}${c.url}`; 15 | } 16 | return c; 17 | } 18 | }; 19 | }); 20 | 21 | }); -------------------------------------------------------------------------------- /frontend/src/services/src/save-file/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | /* global Blob */ 3 | 4 | import app from 'app'; 5 | import FileSaver from 'file-saver'; 6 | import _ from 'lodash'; 7 | 8 | app.factory('saveFile', function($log) { 9 | 10 | return (filename, data, options) => { 11 | 12 | data = typeof data === 'string' ? data : JSON.stringify(data, null, 4); 13 | options = options || {}; 14 | _.defaults(options, { 15 | 'type': 'text/plain;charset=utf-8' 16 | }); 17 | 18 | let blob = new Blob([data], { 19 | 'type': options.type 20 | }); 21 | 22 | FileSaver.saveAs(blob, filename); 23 | 24 | }; 25 | 26 | }); -------------------------------------------------------------------------------- /migrations/20180402165616_outputs.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | exports.up = function(knex, Promise) { 4 | 5 | return knex.schema.createTable('outputs', (table) => { 6 | 7 | table.increments(); 8 | table.string('task_id').notNull(); 9 | table.string('std_out'); 10 | table.string('std_err'); 11 | table.string('exit_code').notNull(); 12 | table.string('date').notNull(); 13 | table.string('start_ts').notNull(); 14 | table.string('end_ts').notNull(); 15 | 16 | }); 17 | 18 | }; 19 | 20 | exports.down = function(knex, Promise) { 21 | 22 | return knex.schema.dropTable('outputs'); 23 | 24 | }; 25 | -------------------------------------------------------------------------------- /di/services/ipc-server/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | exports = module.exports = function(taskManager) { 4 | 5 | const dnode = require('dnode'); 6 | 7 | const server = dnode({ 8 | 'execute': (id, cb) => { 9 | taskManager.execute(id); 10 | return cb(); 11 | }, 12 | 'getTasks': (cb) => { 13 | const tasks = taskManager.tasks.map((task) => { 14 | return task.getPrint(); 15 | }); 16 | cb(null, tasks); 17 | } 18 | }); 19 | 20 | server.listen(9090); 21 | 22 | return {}; 23 | 24 | }; 25 | 26 | exports['@singleton'] = true; 27 | exports['@require'] = ['task-manager']; -------------------------------------------------------------------------------- /frontend/src/states/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import _ from 'lodash'; 4 | import tmpStates from 'glob-loader!./modules.states.pattern'; 5 | import 'glob-loader!./modules.components.pattern'; 6 | 7 | const states = {}; 8 | _.each(tmpStates, (state, k) => { 9 | const name = k.split('/')[2]; 10 | states[name] = state; 11 | states[name].name = name; 12 | }); 13 | 14 | _.each(states, (state) => { 15 | const parent = state.name.split('.').reverse().slice(1).reverse().join('.'); 16 | if (!parent) return; 17 | if (!states[parent]) { 18 | states[parent] = { 19 | 'name': parent, 20 | 'abstract': true 21 | }; 22 | } 23 | }); 24 | 25 | export default states; -------------------------------------------------------------------------------- /lib/app/writable-stream.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const { Writable } = require('stream'); 4 | 5 | class WritableStream extends Writable { 6 | 7 | constructor() { 8 | 9 | super(); 10 | 11 | } 12 | 13 | get output() { 14 | 15 | return this._output ? this._output : this._output = new Buffer(0); 16 | 17 | } 18 | 19 | set output(value) { 20 | 21 | return this._output = value; 22 | 23 | } 24 | 25 | _write(chunk, encoding, done) { 26 | 27 | this.output = Buffer.concat([this.output, chunk]); 28 | done(); 29 | 30 | } 31 | 32 | } 33 | 34 | module.exports = WritableStream; -------------------------------------------------------------------------------- /tasks/init.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = function(grunt) { 4 | 5 | return grunt.registerTask('init', function() { 6 | 7 | const Ahoy = require('ahoy-di'); 8 | const path = require('path'); 9 | const done = this.async(); 10 | 11 | const container = new Ahoy({ 12 | 'id': 'services', 13 | 'extendRequire': true, 14 | 'services': [ 15 | path.resolve(__dirname, '../di/services') 16 | ] 17 | }); 18 | 19 | container.constant('appDir', path.resolve(__dirname, '../')); 20 | 21 | return container.load('cli') 22 | .then(done) 23 | .catch(grunt.fatal); 24 | 25 | }); 26 | 27 | }; -------------------------------------------------------------------------------- /tasks/create-migration.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = function(grunt) { 4 | 5 | /** 6 | * Migrates the database to a specified migration point. 7 | * 8 | * @example $ grunt create-migration -name my-migration-name 9 | */ 10 | grunt.registerTask('create-migration', 'Run Knex migrations', function() { 11 | 12 | const done = this.async(); 13 | const knex = require('services/knex'); 14 | 15 | if (!grunt.option('name')) { 16 | return grunt.fatal(`'name' is required`); 17 | } 18 | 19 | return knex.migrate.make(grunt.option('name')) 20 | .then(() => { 21 | return done(); 22 | }) 23 | .catch((err) => { 24 | return grunt.fatal(err); 25 | }); 26 | 27 | }); 28 | 29 | }; -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.4' 2 | 3 | services: 4 | 5 | ticktock-dev: 6 | image: ticktock-dev 7 | build: 8 | context: . 9 | target: development 10 | ports: 11 | - "8000:80" 12 | entrypoint: nodemon start.js 13 | volumes: 14 | - .:/opt/ticktock 15 | - ./example/notifications:/opt/ticktock/notifications 16 | - ./example/config.yml:/config.yml 17 | - /var/run/docker.sock:/var/run/docker.sock 18 | 19 | ticktock-prod: 20 | image: tkambler/ticktock 21 | build: 22 | context: . 23 | target: production 24 | ports: 25 | - "8000:80" 26 | volumes: 27 | - ./example/config.yml:/config.yml 28 | - /var/run/docker.sock:/var/run/docker.sock 29 | 30 | maildev: 31 | image: djfarrelly/maildev 32 | ports: 33 | - "1080:80" -------------------------------------------------------------------------------- /di/services/log/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | exports = module.exports = function(config) { 4 | 5 | const winston = require('winston'); 6 | const logger = new winston.Logger(); 7 | 8 | logger.setLevels({ 9 | 'error': 0, 10 | 'warn': 1, 11 | 'info': 2, 12 | 'debug': 3, 13 | 'trace': 4 14 | }); 15 | 16 | logger.add(winston.transports.Console, { 17 | 'json': true, 18 | 'timestamp': true, 19 | 'stringify': true, 20 | 'prettyPrint': true 21 | }); 22 | 23 | logger.stream = { 24 | 'write': (msg, enc) => {} 25 | }; 26 | 27 | logger.setLevel = (level) => { 28 | logger.transports.console.level = level; 29 | }; 30 | 31 | if (config.get('env:development')) { 32 | logger.setLevel('debug'); 33 | } 34 | 35 | return logger; 36 | 37 | }; 38 | 39 | exports['@singleton'] = true; 40 | exports['@require'] = ['config']; -------------------------------------------------------------------------------- /report: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const dnode = require('dnode'); 4 | const Promise = require('bluebird'); 5 | const inquirer = require('inquirer'); 6 | const prettyjson = require('prettyjson'); 7 | let client; 8 | 9 | function init() { 10 | 11 | return new Promise((resolve, reject) => { 12 | try { 13 | client = dnode.connect(9090); 14 | } catch(e) { 15 | return reject(e); 16 | } 17 | client.on('error', (err) => { 18 | throw err; 19 | }); 20 | client.on('remote', (remote) => { 21 | return resolve(remote); 22 | }); 23 | }); 24 | 25 | } 26 | 27 | init() 28 | .then((api) => { 29 | 30 | api.getTasks((err, tasks) => { 31 | 32 | if (err) { 33 | throw err; 34 | } 35 | 36 | console.log(prettyjson.render(tasks)); 37 | 38 | process.exit(); 39 | 40 | }); 41 | 42 | }); -------------------------------------------------------------------------------- /frontend/scss/global.scss: -------------------------------------------------------------------------------- 1 | html { 2 | height: 100%; 3 | } 4 | 5 | body { 6 | height: 100%; 7 | font-family: 'Open Sans', sans-serif; 8 | box-sizing: border-box; 9 | } 10 | 11 | a { 12 | cursor: pointer; 13 | } 14 | 15 | textarea { 16 | resize: none; 17 | } 18 | 19 | ui-view.primary { 20 | display: flex; 21 | flex-direction: column; 22 | height: 100%; 23 | } 24 | 25 | ui-view.content { 26 | display: flex; 27 | flex-direction: column; 28 | height: 100%; 29 | width: 100%; 30 | } 31 | 32 | .noselect { 33 | -webkit-touch-callout: none; /* iOS Safari */ 34 | -webkit-user-select: none; /* Safari */ 35 | -khtml-user-select: none; /* Konqueror HTML */ 36 | -moz-user-select: none; /* Firefox */ 37 | -ms-user-select: none; /* Internet Explorer/Edge */ 38 | user-select: none; /* Non-prefixed version, currently 39 | supported by Chrome and Opera */ 40 | } 41 | 42 | .noty_layout { 43 | z-index: 10000; 44 | } -------------------------------------------------------------------------------- /example/config.yml: -------------------------------------------------------------------------------- 1 | timezone: America/New_York 2 | 3 | admin: 4 | username: username 5 | password: password 6 | 7 | tasks: 8 | - title: Do Something 9 | description: It does something very important. 10 | id: do_something 11 | interval: every 10 seconds 12 | type: run 13 | image: mhart/alpine-node:8.6.0 14 | command: ["ls", "-al"] 15 | overlap: false 16 | enabled: true 17 | execute_on_start: false 18 | - title: List Running Processes 19 | description: It lists running processes. 20 | id: list_processes 21 | interval: "* */1 * * *" 22 | type: run 23 | image: mhart/alpine-node:8.6.0 24 | command: ["ps", "aux"] 25 | overlap: false 26 | enabled: true 27 | execute_on_start: false 28 | email: 29 | smtp: 30 | from_name: TickTock 31 | from_email: ticktock@localhost.site 32 | config: 33 | host: maildev 34 | port: 25 35 | secure: false 36 | tls: 37 | secure: false 38 | ignoreTLS: true 39 | rejectUnauthorized: false -------------------------------------------------------------------------------- /di/services/knex/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | exports = module.exports = function(config, appDir) { 4 | 5 | const fs = require('app/fs'); 6 | const path = require('path'); 7 | const dbFile = '/var/ticktock/db.sqlite3'; 8 | 9 | const knex = require('knex')({ 10 | 'client': 'sqlite3', 11 | 'connection': { 12 | 'filename': dbFile 13 | }, 14 | 'useNullAsDefault': true, 15 | 'migrations': { 16 | 'directory': path.resolve(appDir, 'migrations') 17 | }, 18 | 'seeds': { 19 | 'directory': path.resolve(appDir, 'seeds') 20 | } 21 | }); 22 | 23 | return fs.ensureDirAsync(path.dirname(dbFile)) 24 | .then(() => { 25 | 26 | return knex.migrate.latest({ 27 | 'disableTransactions': true 28 | }); 29 | 30 | }) 31 | .then(() => { 32 | 33 | return knex; 34 | 35 | }); 36 | 37 | }; 38 | 39 | exports['@singleton'] = true; 40 | exports['@require'] = ['config', 'appDir']; 41 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM mhart/alpine-node:8.6.0 AS development 2 | RUN apk update && apk add \ 3 | python \ 4 | make \ 5 | g++ \ 6 | ruby-dev \ 7 | ruby \ 8 | ruby-io-console \ 9 | ruby-bundler 10 | RUN rm -rf /var/cache/apk/* 11 | RUN gem install sass compass --no-ri --no-rdoc 12 | RUN npm i -g nodemon grunt-cli 13 | ENV NODE_PATH=./lib 14 | COPY package.json package-lock.json /opt/ticktock/ 15 | WORKDIR /opt/ticktock 16 | RUN npm i 17 | RUN npm cache clean --force 18 | COPY . /opt/ticktock/ 19 | RUN chmod +x ./execute 20 | RUN chmod +x ./report 21 | WORKDIR /opt/ticktock/frontend 22 | RUN npm i 23 | RUN grunt 24 | WORKDIR /opt/ticktock 25 | ENTRYPOINT ["node", "start.js"] 26 | 27 | FROM mhart/alpine-node:8.6.0 AS production 28 | RUN apk update && apk add \ 29 | python \ 30 | make \ 31 | g++ 32 | RUN rm -rf /var/cache/apk/* 33 | ENV NODE_PATH=./lib 34 | COPY package.json package-lock.json /opt/ticktock/ 35 | WORKDIR /opt/ticktock 36 | RUN npm i 37 | RUN npm cache clean --force 38 | COPY . /opt/ticktock/ 39 | RUN chmod +x ./execute 40 | RUN chmod +x ./report 41 | RUN rm -rf /opt/ticktock/frontend 42 | COPY --from=development /opt/ticktock/frontend/public /opt/ticktock/frontend/public 43 | ENTRYPOINT ["node", "start.js"] -------------------------------------------------------------------------------- /frontend/src/config/src/restangular.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import app from 'app'; 4 | import _ from 'lodash'; 5 | import angular from 'angular'; 6 | 7 | app.config(function(RestangularProvider, config) { 8 | 9 | RestangularProvider.setBaseUrl(config.base_api_url); 10 | RestangularProvider.setDefaultHttpFields({ 11 | 'withCredentials': true 12 | }); 13 | 14 | RestangularProvider.setResponseExtractor((response) => { 15 | if (!response || !_.isObject(response)) return response; 16 | let newResponse = response; 17 | if (angular.isArray(response)) { 18 | newResponse.originalElements = []; 19 | angular.forEach(newResponse, (value, key) => { 20 | if (_.isString(newResponse[key])) { 21 | return; 22 | } 23 | newResponse[key].originalElement = angular.copy(value); 24 | newResponse.originalElements.push(newResponse[key].originalElement); 25 | }); 26 | } else { 27 | newResponse.originalElement = angular.copy(response); 28 | } 29 | return newResponse; 30 | }); 31 | 32 | RestangularProvider.setRestangularFields({ 33 | 'selfLink': 'self.link' 34 | }); 35 | 36 | }); -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ticktock", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "author": "", 10 | "license": "ISC", 11 | "dependencies": { 12 | "ahoy-di": "^1.2.0", 13 | "basic-auth": "^2.0.0", 14 | "bluebird": "^3.5.1", 15 | "body-parser": "^1.18.2", 16 | "confit": "^2.3.0", 17 | "cron-parser": "^2.4.5", 18 | "dnode": "^1.2.2", 19 | "dockerode": "^2.5.4", 20 | "eventemitter2": "^5.0.1", 21 | "express": "^4.16.3", 22 | "fs-extra": "^5.0.0", 23 | "grunt": "^1.0.2", 24 | "inquirer": "^5.1.0", 25 | "js-yaml": "^3.10.0", 26 | "json5": "^0.5.1", 27 | "knex": "^0.14.4", 28 | "later": "^1.2.0", 29 | "lodash": "^4.17.5", 30 | "md5": "^2.2.1", 31 | "moment": "^2.20.1", 32 | "moment-timezone": "^0.5.14", 33 | "node-cron": "^1.2.1", 34 | "nodemailer": "^4.6.0", 35 | "prettyjson": "^1.2.1", 36 | "shortstop-concat": "^1.0.2", 37 | "shortstop-handlers": "^1.0.1", 38 | "sqlite3": "^4.0.0", 39 | "uuid": "^3.2.1", 40 | "winston": "^2.4.0" 41 | }, 42 | "nodemonConfig": { 43 | "ignore": [ 44 | "frontend/*" 45 | ] 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /frontend/src/components/src/databox/style.scss: -------------------------------------------------------------------------------- 1 | databox { 2 | 3 | .panel-body { 4 | padding-bottom: 0; 5 | } 6 | 7 | dropdown-button { 8 | display: inline-block; 9 | position: relative; 10 | align-self: center; 11 | } 12 | 13 | .databox-section { 14 | 15 | .databox-section-heading { 16 | text-transform: uppercase; 17 | font-weight: bold; 18 | border-bottom: 1px dashed #333; 19 | padding-bottom: 4px; 20 | } 21 | 22 | .databox-section-contents { 23 | 24 | pre { 25 | margin-top: 15px; 26 | } 27 | 28 | .databox-item { 29 | 30 | font-size: 0.9em; 31 | padding: 15px; 32 | 33 | .databox-item-inner { 34 | padding-bottom: 4px; 35 | border-bottom: 1px dashed #ccc; 36 | display: flex; 37 | flex-direction: row; 38 | justify-content: space-between; 39 | } 40 | 41 | .databox-item-label { 42 | width: 30%; 43 | font-weight: bold; 44 | } 45 | 46 | .databox-item-value { 47 | width: 70%; 48 | text-align: right; 49 | } 50 | 51 | } 52 | 53 | } 54 | 55 | } 56 | 57 | } -------------------------------------------------------------------------------- /lib/app/run-task.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const BaseTask = require('./base-task'); 4 | const Promise = require('bluebird'); 5 | const docker = require('services/docker'); 6 | const WritableStream = require('./writable-stream'); 7 | const moment = require('moment'); 8 | 9 | class RunTask extends BaseTask { 10 | 11 | run() { 12 | 13 | const outStream = new WritableStream(); 14 | const errStream = new WritableStream(); 15 | const start = moment(); 16 | 17 | return new Promise((resolve, reject) => { 18 | 19 | return docker.run(this.task.image, this.task.command, [outStream, errStream], { 20 | 21 | }, (err, data, container) => { 22 | 23 | if (err) { 24 | return reject(err); 25 | } 26 | 27 | return container.remove() 28 | .then(() => { 29 | return resolve({ 30 | 'outputBuffer': outStream.output, 31 | 'errorBuffer': errStream.output, 32 | 'exitCode': data.StatusCode, 33 | 'start': start, 34 | 'end': moment() 35 | }); 36 | }) 37 | .catch(reject); 38 | 39 | }); 40 | 41 | }); 42 | 43 | } 44 | 45 | } 46 | 47 | module.exports = RunTask; -------------------------------------------------------------------------------- /frontend/src/components/src/json-viewer/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import app from 'app'; 4 | 5 | app.component('jsonViewer', { 6 | 'bindings': { 7 | 'resolve': '<', 8 | 'close': '&', 9 | 'dismiss': '&', 10 | }, 11 | 'controller': function() { 12 | 13 | return new class { 14 | 15 | dismiss() { 16 | 17 | return this.close(); 18 | 19 | } 20 | 21 | }; 22 | 23 | }, 24 | 'template': ` 25 | 26 |
27 | 28 |
29 | 30 | 37 | 38 | 49 | 50 | 53 | 54 |
55 | 56 |
57 | 58 | ` 59 | }); -------------------------------------------------------------------------------- /frontend/assets/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | TickTock 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 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /execute: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const dnode = require('dnode'); 4 | const Promise = require('bluebird'); 5 | const inquirer = require('inquirer'); 6 | let client; 7 | 8 | function init() { 9 | 10 | return new Promise((resolve, reject) => { 11 | try { 12 | client = dnode.connect(9090); 13 | } catch(e) { 14 | return reject(e); 15 | } 16 | client.on('error', (err) => { 17 | throw err; 18 | }); 19 | client.on('remote', (remote) => { 20 | return resolve(remote); 21 | }); 22 | }); 23 | 24 | } 25 | 26 | init() 27 | .then((api) => { 28 | 29 | api.getTasks((err, tasks) => { 30 | if (err) { 31 | throw err; 32 | } 33 | return inquirer.prompt([ 34 | { 35 | 'message': 'Select the task to execute', 36 | 'name': 'id', 37 | 'type': 'list', 38 | 'choices': tasks.map((task) => { 39 | return { 40 | 'name': task.title, 41 | 'value': task.id 42 | }; 43 | }) 44 | } 45 | ]) 46 | .then(({ id }) => { 47 | 48 | api.execute(id, (err) => { 49 | if (err) { 50 | throw err; 51 | } 52 | console.log(`Task is executing.`); 53 | process.exit(0); 54 | }); 55 | 56 | }); 57 | }); 58 | 59 | }); -------------------------------------------------------------------------------- /frontend/src/components/src/tabs/style.scss: -------------------------------------------------------------------------------- 1 | tabs { 2 | 3 | .tabs-heading { 4 | margin-right: 25px; 5 | a { 6 | color: #333; 7 | cursor: default; 8 | border: 1px solid transparent; 9 | font-weight: 700; 10 | &:hover { 11 | background-color: transparent; 12 | border: 1px solid transparent; 13 | } 14 | } 15 | } 16 | 17 | .nav-tabs { 18 | margin-bottom: 15px; 19 | } 20 | 21 | .dropdown-submenu { 22 | position:relative; 23 | } 24 | .dropdown-submenu>.dropdown-menu { 25 | top:0; 26 | left:100%; 27 | margin-top:-6px; 28 | margin-left:-1px; 29 | -webkit-border-radius:0 6px 6px 6px; 30 | -moz-border-radius:0 6px 6px 6px; 31 | border-radius:0 6px 6px 6px; 32 | } 33 | .dropdown-submenu:hover>.dropdown-menu { 34 | display:block; 35 | } 36 | .dropdown-submenu>a:after { 37 | display:block; 38 | content:" "; 39 | float:right; 40 | width:0; 41 | height:0; 42 | border-color:transparent; 43 | border-style:solid; 44 | border-width:5px 0 5px 5px; 45 | border-left-color:#cccccc; 46 | margin-top:5px; 47 | margin-right:-10px; 48 | } 49 | .dropdown-submenu:hover>a:after { 50 | border-left-color:#ffffff; 51 | } 52 | .dropdown-submenu.pull-left { 53 | float:none; 54 | } 55 | .dropdown-submenu.pull-left>.dropdown-menu { 56 | left:-100%; 57 | margin-left:10px; 58 | -webkit-border-radius:6px 0 6px 6px; 59 | -moz-border-radius:6px 0 6px 6px; 60 | border-radius:6px 0 6px 6px; 61 | } 62 | 63 | } -------------------------------------------------------------------------------- /frontend/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "extends": "eslint:recommended", 3 | "env": { 4 | "browser": true, 5 | "es6": true, 6 | "commonjs": true, 7 | "node": true, 8 | "mocha": true, 9 | }, 10 | "parserOptions": { 11 | "ecmaVersion": 6, 12 | "sourceType": "module" 13 | }, 14 | "rules": { 15 | "eqeqeq": 2, 16 | "quotes": [1, "single"], 17 | "no-cond-assign": 2, 18 | "no-debugger": 2, 19 | "no-mixed-spaces-and-tabs": 2, 20 | "no-trailing-spaces": 2, 21 | "semi": 2, 22 | "spaced-comment": 2, 23 | "no-unused-vars": [2, { 24 | "vars": "all", 25 | "args": "none", 26 | "varsIgnorePattern": "^_", // Anything starting with _ will be ignored 27 | }], 28 | "no-multi-spaces": 2, 29 | "array-bracket-spacing": 2, 30 | "block-spacing": 2, 31 | "brace-style": 2, 32 | "camelcase": 0, 33 | "comma-spacing": [2, { 34 | "before": false, 35 | "after": true, 36 | }], 37 | "comma-style": [2, "last"], 38 | "eol-last": 2, 39 | "indent": 2, 40 | "key-spacing": [2, { 41 | "beforeColon": false, 42 | "afterColon": true, 43 | }], 44 | "no-spaced-func": 2, 45 | "object-curly-spacing": [2, "always"], 46 | "space-before-blocks": [2, "always"], 47 | "space-before-function-paren": [2, "always"], 48 | "space-in-parens": [0, "always"], 49 | "space-infix-ops": [2, {"int32Hint": false}], 50 | "arrow-spacing": [2, {"before": true, "after": true}], 51 | "quote-props": [2, "always"], 52 | "keyword-spacing": 2 53 | }, 54 | }; 55 | -------------------------------------------------------------------------------- /frontend/src/services/src/notify/notify.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import noty from 'noty'; 4 | import _ from 'lodash'; 5 | import $ from 'jquery'; 6 | 7 | module.exports = (message, type, options, $log) => { 8 | 9 | options = options || {}; 10 | _.defaultsDeep(options, { 11 | 'callbacks': {}, 12 | 'theme': 'relax', 13 | 'animation': { 14 | 'open': 'animated fadeIn', 15 | 'close': 'animated fadeOut' 16 | } 17 | }); 18 | const onShow = _.get(options.callbacks.onShow); 19 | const afterClose = _.get(options.callbacks.afterShow); 20 | options.callbacks.onShow = undefined; 21 | const id = _.uniqueId('notification_'); 22 | 23 | if (options.link) { 24 | message += options.link.url ? `
${options.link.label}` : `
${options.link.label}`; 25 | } 26 | 27 | message = `
${message}
`; 28 | 29 | let _options = { 30 | 'text': message, 31 | 'layout': 'top', 32 | 'type': type, 33 | 'timeout': 4000 34 | }; 35 | _.merge(_options, options); 36 | 37 | const n = new noty(_options); 38 | 39 | function clickFn() { 40 | if (_.get(options, 'link.fn')) { 41 | _.get(options, 'link.fn')(); 42 | } 43 | } 44 | 45 | n.on('onShow', () => { 46 | if (options.link) { 47 | $(`#${id}`).on('click', clickFn); 48 | } 49 | if (onShow) { 50 | onShow(); 51 | } 52 | }); 53 | 54 | n.on('onClose', () => { 55 | if (options.link) { 56 | $(`#${id}`).off('click', clickFn); 57 | } 58 | if (afterClose) { 59 | afterClose(); 60 | } 61 | }); 62 | 63 | n.show(); 64 | 65 | }; 66 | -------------------------------------------------------------------------------- /frontend/src/components/src/header/style.scss: -------------------------------------------------------------------------------- 1 | header { 2 | 3 | .navbar { 4 | margin-bottom: 0; 5 | } 6 | 7 | .navbar-inverse { 8 | border-radius: 0; 9 | .navbar-brand { 10 | color: #fff; 11 | } 12 | .navbar-nav > li > a { 13 | color: #fff; 14 | } 15 | } 16 | 17 | .home-icon { 18 | color: #fff; 19 | } 20 | 21 | #primary-header-dropdown { 22 | margin-left: 15px; 23 | } 24 | 25 | /* Bootstop dropdown submenus */ 26 | 27 | .dropdown-submenu { 28 | position:relative; 29 | } 30 | .dropdown-submenu>.dropdown-menu { 31 | top:0; 32 | left:100%; 33 | margin-top:-6px; 34 | margin-left:-1px; 35 | -webkit-border-radius:0 6px 6px 6px; 36 | -moz-border-radius:0 6px 6px 6px; 37 | border-radius:0 6px 6px 6px; 38 | } 39 | .dropdown-submenu:hover>.dropdown-menu { 40 | display:block; 41 | } 42 | .dropdown-submenu>a:after { 43 | display:block; 44 | content:" "; 45 | float:right; 46 | width:0; 47 | height:0; 48 | border-color:transparent; 49 | border-style:solid; 50 | border-width:5px 0 5px 5px; 51 | border-left-color:#cccccc; 52 | margin-top:5px; 53 | margin-right:-10px; 54 | } 55 | .dropdown-submenu:hover>a:after { 56 | border-left-color:#ffffff; 57 | } 58 | .dropdown-submenu.pull-left { 59 | float:none; 60 | } 61 | .dropdown-submenu.pull-left>.dropdown-menu { 62 | left:-100%; 63 | margin-left:10px; 64 | -webkit-border-radius:6px 0 6px 6px; 65 | -moz-border-radius:6px 0 6px 6px; 66 | border-radius:6px 0 6px 6px; 67 | } 68 | 69 | /* End: Bootstrap dropdown submenus */ 70 | 71 | } -------------------------------------------------------------------------------- /frontend/src/components/src/databox/template.html: -------------------------------------------------------------------------------- 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 |
33 |
34 |
-------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "frontend", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "author": "", 10 | "license": "ISC", 11 | "dependencies": { 12 | "angular": "^1.5.6", 13 | "angular-json-tree": "^1.0.1", 14 | "angular-loading-bar": "^0.9.0", 15 | "angular-sanitize": "^1.5.6", 16 | "angular-ui-bootstrap": "^2.5.6", 17 | "angular-ui-router": "^1.0.0-rc.1", 18 | "angular-validation-match": "^1.9.0", 19 | "animate.css": "^3.6.1", 20 | "babel-core": "^6.26.0", 21 | "babel-loader": "^7.1.3", 22 | "babel-plugin-transform-builtin-extend": "^1.1.2", 23 | "babel-plugin-transform-class-properties": "^6.24.1", 24 | "babel-preset-env": "^1.6.1", 25 | "babel-preset-es2015": "^6.24.1", 26 | "bluebird": "^3.5.1", 27 | "bootstrap-sass": "^3.3.7", 28 | "css-loader": "^0.28.10", 29 | "exports-loader": "^0.7.0", 30 | "express": "^4.16.2", 31 | "file-saver": "^1.3.3", 32 | "font-awesome": "^4.7.0", 33 | "glob-loader": "^0.3.0", 34 | "grunt": "^1.0.2", 35 | "grunt-concurrent": "^2.3.1", 36 | "grunt-contrib-clean": "^1.1.0", 37 | "grunt-contrib-compass": "^1.1.1", 38 | "grunt-contrib-concat": "^1.0.1", 39 | "grunt-contrib-connect": "^1.0.2", 40 | "grunt-contrib-copy": "^1.0.0", 41 | "grunt-contrib-cssmin": "^2.2.1", 42 | "grunt-contrib-uglify": "^3.3.0", 43 | "grunt-contrib-watch": "^1.0.0", 44 | "grunt-replace": "^1.0.1", 45 | "grunt-webpack": "^3.0.2", 46 | "gruntify-eslint": "^4.0.0", 47 | "html-loader": "^0.5.5", 48 | "imports-loader": "^0.8.0", 49 | "jquery": "^2.2.3", 50 | "json5-loader": "^1.0.1", 51 | "load-grunt-tasks": "^3.5.2", 52 | "lodash": "^4.17.5", 53 | "moment": "^2.21.0", 54 | "ng-js-tree": "0.0.10", 55 | "node-sass": "^4.7.2", 56 | "noty": "^3.1.2", 57 | "opensans-npm-webfont": "^1.0.0", 58 | "raw-loader": "^0.5.1", 59 | "restangular": "^1.6.1", 60 | "sass-loader": "^6.0.7", 61 | "style-loader": "^0.20.2", 62 | "time-grunt": "^1.4.0", 63 | "uglifyjs-webpack-plugin": "^1.2.2", 64 | "underscore.string": "^3.3.4", 65 | "webpack": "^3.6.0" 66 | }, 67 | "private": true 68 | } 69 | -------------------------------------------------------------------------------- /frontend/src/services/src/notify/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import app from 'app'; 4 | import _ from 'lodash'; 5 | 6 | app.factory('notify', function($rootScope, parseError, $log) { 7 | 8 | let notify = require('./notify'); 9 | 10 | return { 11 | 'success': function(message, options) { 12 | let defaultMessage = _.isString(options) ? options : undefined; 13 | options = _.isObject(options) ? options : undefined; 14 | defaultMessage = _.get(options, 'default_message') ? _.get(options, 'default_message') : defaultMessage; 15 | notify(parseError(message, defaultMessage), 'success', options, $log); 16 | }, 17 | 'error': function(message, options) { 18 | let defaultMessage = _.isString(options) ? options : undefined; 19 | options = _.isObject(options) ? options : undefined; 20 | defaultMessage = _.get(options, 'default_message') ? _.get(options, 'default_message') : defaultMessage; 21 | notify(parseError(message, defaultMessage), 'error', options, $log); 22 | }, 23 | 'warn': function(message, options) { 24 | let defaultMessage = _.isString(options) ? options : undefined; 25 | options = _.isObject(options) ? options : undefined; 26 | defaultMessage = _.get(options, 'default_message') ? _.get(options, 'default_message') : defaultMessage; 27 | notify(parseError(message, defaultMessage), 'warn', options, $log); 28 | }, 29 | 'info': function(message, options) { 30 | let defaultMessage = _.isString(options) ? options : undefined; 31 | options = _.isObject(options) ? options : undefined; 32 | defaultMessage = _.get(options, 'default_message') ? _.get(options, 'default_message') : defaultMessage; 33 | notify(parseError(message, defaultMessage), 'info', options, $log); 34 | }, 35 | 'log': function(message, options) { 36 | let defaultMessage = _.isString(options) ? options : undefined; 37 | options = _.isObject(options) ? options : undefined; 38 | defaultMessage = _.get(options, 'default_message') ? _.get(options, 'default_message') : defaultMessage; 39 | notify(parseError(message, defaultMessage), 'log'); 40 | } 41 | }; 42 | 43 | }); 44 | -------------------------------------------------------------------------------- /frontend/src/components/src/dropdown-button/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import app from 'app'; 4 | 5 | app.component('dropdownButton', { 6 | 'bindings': { 7 | 'options': '<' 8 | }, 9 | 'controller': function() { 10 | 11 | return new class { 12 | 13 | $onInit() { 14 | 15 | } 16 | 17 | get klass() { 18 | switch (_.get(this, 'options.size')) { 19 | case 'small': 20 | return 'btn-sm'; 21 | default: 22 | return ''; 23 | } 24 | } 25 | 26 | get label() { 27 | return _.get(this, 'options.label'); 28 | } 29 | 30 | get icon() { 31 | return _.get(this, 'options.icon'); 32 | } 33 | 34 | }; 35 | 36 | }, 37 | 'template': ` 38 | 39 | 40 | 53 | ` 54 | }); -------------------------------------------------------------------------------- /lib/app/exec-task.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const BaseTask = require('./base-task'); 4 | const docker = require('services/docker'); 5 | const WritableStream = require('./writable-stream'); 6 | const moment = require('moment'); 7 | 8 | class ExecTask extends BaseTask { 9 | 10 | run() { 11 | 12 | let start; 13 | 14 | return docker.listContainers() 15 | .then((containers) => { 16 | return containers; 17 | }) 18 | .filter((container) => { 19 | return container.Names.indexOf(this.task.container) >= 0 || container.Names.indexOf('/' + this.task.container) >= 0; 20 | }) 21 | .then((containers) => { 22 | if (containers.length !== 1) { 23 | console.log(`Error: Unable to locate container with name: ${this.task.container}`); 24 | } 25 | return docker.getContainer(containers[0].Id); 26 | }) 27 | .then((container) => { 28 | return container.exec({ 29 | 'Cmd': this.task.command, 30 | 'AttachStdout': true 31 | }) 32 | .then((exec) => { 33 | const outStream = new WritableStream(); 34 | const errStream = new WritableStream(); 35 | return new Promise((resolve, reject) => { 36 | start = moment(); 37 | return exec.start((err, stream) => { 38 | if (err) { 39 | return reject(err); 40 | } 41 | container.modem.demuxStream(stream, outStream, errStream); 42 | stream.on('end', resolve); 43 | }); 44 | }) 45 | .then(() => { 46 | return exec.inspect(); 47 | }) 48 | .then((res) => { 49 | return { 50 | 'outputBuffer': outStream.output, 51 | 'errorBuffer': errStream.output, 52 | 'exitCode': res.ExitCode, 53 | 'start': start, 54 | 'end': moment() 55 | }; 56 | }); 57 | }); 58 | }); 59 | 60 | } 61 | 62 | } 63 | 64 | module.exports = ExecTask; -------------------------------------------------------------------------------- /frontend/webpack.config.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const path = require('path'); 4 | const webpack = require('webpack'); 5 | const UglifyJsPlugin = require('uglifyjs-webpack-plugin'); 6 | 7 | const config = { 8 | 'entry': { 9 | 'app': 'app', 10 | 'boot': 'boot', 11 | 'config': 'config', 12 | 'components': 'components', 13 | 'directives': 'directives', 14 | 'services': 'services', 15 | 'states': 'states', 16 | }, 17 | 'devtool': 'inline-source-map', 18 | 'watchOptions': { 19 | 'ignored': /node_modules/ 20 | }, 21 | 'output': { 22 | 'path': path.resolve(__dirname, 'public/js'), 23 | 'filename': '[name].bundle.js' 24 | }, 25 | 'resolve': { 26 | 'modules': [ 27 | path.resolve(__dirname, 'src'), 28 | path.resolve(__dirname, 'scss'), 29 | path.resolve(__dirname, 'vendor'), 30 | path.resolve(__dirname, 'node_modules') 31 | ] 32 | }, 33 | 'externals': [ 34 | { 35 | 'angular': 'angular', 36 | 'bluebird': 'Promise' 37 | } 38 | ], 39 | 'plugins': [ 40 | new webpack.optimize.CommonsChunkPlugin({ 41 | 'name': 'common' 42 | }) 43 | ], 44 | 'module': { 45 | 'rules': [ 46 | 47 | { 48 | 'test': /\.js$/, 49 | 'exclude': /node_modules/, 50 | 'use': [ 51 | { 52 | 'loader': 'babel-loader', 53 | 'options': { 54 | 'presets': ['env'], 55 | 'plugins': ['transform-class-properties'] 56 | } 57 | } 58 | ] 59 | }, 60 | { 61 | 'test': /\.json5$/, 62 | 'use': 'json5-loader' 63 | }, 64 | { 65 | 'test': /\.html$/, 66 | 'use': { 67 | 'loader': 'html-loader', 68 | 'options': { 69 | 'attrs': false 70 | } 71 | } 72 | }, 73 | { 74 | 'test': /\.txt$/, 75 | 'use': 'raw-loader' 76 | }, 77 | { 78 | 'test': /\.css/, 79 | 'use': [ 80 | { 81 | 'loader': 'css-loader' 82 | } 83 | ] 84 | }, 85 | { 86 | 'test': /\.scss$/, 87 | 'use': [ 88 | { 89 | 'loader': 'style-loader' 90 | }, 91 | { 92 | 'loader': 'css-loader' 93 | }, 94 | { 95 | 'loader': 'sass-loader' 96 | } 97 | ] 98 | } 99 | ] 100 | } 101 | }; 102 | 103 | module.exports = config; 104 | 105 | // if (process.env.NODE_ENV === 'production') { 106 | // config.plugins.push( 107 | // new UglifyJsPlugin({ 108 | // 'uglifyOptions': { 109 | // 'mangle': false 110 | // } 111 | // }) 112 | // ); 113 | // } -------------------------------------------------------------------------------- /frontend/src/components/src/ui-grid/template.html: -------------------------------------------------------------------------------- 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 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 |
30 | 31 |
No Results Found
48 | 62 |
63 | 64 |
65 | 66 |
-------------------------------------------------------------------------------- /di/services/config/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | exports = module.exports = function(appDir) { 4 | 5 | const Promise = require('bluebird'); 6 | const handlers = require('shortstop-handlers'); 7 | const concatHandler = require('shortstop-concat'); 8 | const { EventEmitter2 } = require('eventemitter2'); 9 | const fs = require('app/fs'); 10 | const _ = require('lodash'); 11 | const yaml = require('js-yaml'); 12 | const uuid = require('uuid'); 13 | let config; 14 | 15 | const confit = Promise.promisifyAll(require('confit')({ 16 | 'basedir': appDir, 17 | 'protocols': { 18 | 'require': handlers.require(appDir), 19 | 'path': handlers.path(appDir), 20 | 'glob': handlers.glob(appDir), 21 | 'env': handlers.env(), 22 | 'concat': concatHandler(appDir, { 23 | 'require': handlers.require(appDir), 24 | 'path': handlers.path(appDir) 25 | }) 26 | } 27 | })); 28 | 29 | const initConfig = (config) => { 30 | 31 | const tasks = config.get('tasks'); 32 | 33 | tasks.forEach((task, k) => { 34 | 35 | _.defaults(task, { 36 | 'overlap': false, 37 | 'enabled': true, 38 | 'execute_on_start': false, 39 | 'email': [], 40 | 'random_delay': 0 41 | }); 42 | 43 | if (!task.id) { 44 | throw new Error(`task.id is required.`); 45 | } 46 | 47 | const idTasks = _.filter(tasks, { 48 | 'id': task.id 49 | }); 50 | 51 | if (idTasks.length > 1) { 52 | throw new Error(`More than one task has been assigned the following ID: ${task.id}`); 53 | } 54 | 55 | task.batch_email_interval = parseInt(task.batch_email_interval, 10); 56 | task.batch_email_interval = task.batch_email_interval || 0; 57 | task.random_delay = parseInt(task.random_delay, 10); 58 | task.random_delay = task.random_delay || 0; 59 | 60 | if (task.email && !_.isArray(task.email)) { 61 | task.email = task.email.split(',') 62 | .map((email) => { 63 | return email.trim(); 64 | }); 65 | } 66 | 67 | config.set('tasks', config.get('tasks').filter((task) => { 68 | return task.enabled; 69 | })); 70 | 71 | }); 72 | 73 | }; 74 | 75 | fs.watchFile('/config.yml', () => { 76 | return fs.readFileAsync('/config.yml', 'utf8') 77 | .then(yaml.safeLoad.bind(yaml)) 78 | .then((src) => { 79 | config.use(src); 80 | initConfig(config); 81 | config.emit('change'); 82 | }); 83 | }); 84 | 85 | const emitter = new EventEmitter2(); 86 | 87 | return Promise.props({ 88 | '_config': (() => { 89 | return confit.createAsync(); 90 | })(), 91 | 'src': (() => { 92 | return fs.readFileAsync('/config.yml', 'utf8') 93 | .then(yaml.safeLoad.bind(yaml)); 94 | })() 95 | }) 96 | .then(({ _config, src }) => { 97 | 98 | config = _config; 99 | 100 | config.on = emitter.on.bind(emitter); 101 | config.emit = emitter.emit.bind(emitter); 102 | 103 | config.use(src); 104 | 105 | initConfig(config); 106 | 107 | return config; 108 | 109 | }); 110 | 111 | }; 112 | 113 | exports['@singleton'] = true; 114 | exports['@require'] = ['appDir']; -------------------------------------------------------------------------------- /frontend/src/components/src/header/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import app from 'app'; 4 | import './style.scss'; 5 | 6 | app.component('header', { 7 | 'controller': function($log, $state, filterEnabled, $uibModal, Task) { 8 | 9 | const taskLinks = []; 10 | 11 | const links = [ 12 | { 13 | 'label': 'TickTock', 14 | 'links': [ 15 | { 16 | 'label': 'Home', 17 | 'ui-sref': 'app.home' 18 | }, 19 | { 20 | 'label': 'Tasks', 21 | 'links': taskLinks 22 | } 23 | ] 24 | } 25 | ]; 26 | 27 | return new class { 28 | 29 | $onInit() { 30 | 31 | this.links = links; 32 | 33 | return Task.getList() 34 | .then((tasks) => { 35 | 36 | tasks = tasks.originalElements; 37 | 38 | tasks.forEach((task) => { 39 | taskLinks.push({ 40 | 'label': task.title, 41 | 'ui-sref': `app.task({ taskId: '${task.id}'})` 42 | }); 43 | }); 44 | 45 | }); 46 | 47 | } 48 | 49 | showProfileModal() { 50 | 51 | return $uibModal.open({ 52 | 'component': 'profileModal', 53 | 'scrollable': false 54 | }); 55 | 56 | } 57 | 58 | }; 59 | 60 | }, 61 | 'template': ` 62 | 97 | ` 98 | }); -------------------------------------------------------------------------------- /frontend/assets/safari-pinned-tab.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 7 | 8 | Created by potrace 1.11, written by Peter Selinger 2001-2013 9 | 10 | 12 | 56 | 62 | 63 | 64 | -------------------------------------------------------------------------------- /frontend/src/components/src/databox/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import app from 'app'; 4 | import _ from 'lodash'; 5 | require('./style.scss'); 6 | 7 | app.component('databox', { 8 | 'bindings': { 9 | 'options': '<' 10 | }, 11 | 'controller': function($log, $element, filterEnabled) { 12 | 13 | const validColumns = [6, 12]; 14 | 15 | return new class { 16 | 17 | $onInit() { 18 | // $log.debug('databox', { 'this': this, 'sections': this.options.sections }); 19 | } 20 | 21 | $onChanges(changes) { 22 | 23 | if (!changes.options || !changes.options.isFirstChange()) { 24 | return; 25 | } 26 | 27 | this._heading = this.options.heading; 28 | 29 | _.defaults(this.options, { 30 | 'actions': [], 31 | 'sections': [] 32 | }); 33 | 34 | if (_.get(this, 'options.items', []).length > 0) { 35 | this.options.sections.splice(0, 0, { 36 | 'items': this.options.items 37 | }); 38 | delete this.options.items; 39 | } 40 | 41 | this._sections = _.chain(this.options.sections) 42 | .cloneDeep() 43 | .map((section) => { 44 | _.defaults(section, { 45 | 'items': [] 46 | }); 47 | const items = filterEnabled(_.chain(section.items) 48 | .filter((item) => { 49 | return item.label; 50 | }) 51 | .map((item) => { 52 | item.value = !_.isUndefined(item.value) ? item.value : '-'; 53 | item.columns = validColumns.indexOf(item.columns) >= 0 ? item.columns : 6; 54 | item.klass = `col-xs-${item.columns}`; 55 | return item; 56 | }) 57 | .value()); 58 | section.items = []; 59 | items.forEach((item) => { 60 | if (item.break) { 61 | delete item.break; 62 | section.items.push({ 63 | 'break': true, 64 | 'klass': 'clearfix' 65 | }); 66 | section.items.push(item); 67 | } else { 68 | section.items.push(item); 69 | } 70 | }); 71 | return section; 72 | }) 73 | .value(); 74 | 75 | } 76 | 77 | $postLink() { 78 | 79 | _.defer(() => { 80 | $element.find('[data-toggle="tooltip"]').tooltip(); 81 | }); 82 | 83 | } 84 | 85 | get actions() { 86 | 87 | return this.options.actions; 88 | 89 | } 90 | 91 | get actionOptions() { 92 | 93 | if (this._actionOptions) { 94 | return this._actionOptions; 95 | } 96 | 97 | this._actionOptions = { 98 | 'label': 'Actions', 99 | 'size': 'small', 100 | 'links': this.actions 101 | }; 102 | 103 | return this._actionOptions; 104 | 105 | } 106 | 107 | get sections() { 108 | 109 | return this._sections || []; 110 | 111 | } 112 | 113 | get heading() { 114 | 115 | return this._heading; 116 | 117 | } 118 | 119 | }; 120 | 121 | }, 122 | 'template': require('./template.html') 123 | }); -------------------------------------------------------------------------------- /frontend/src/components/src/ui-grid/style.scss: -------------------------------------------------------------------------------- 1 | ui-grid { 2 | 3 | .dropdown-toggle-icon { 4 | cursor: pointer; 5 | } 6 | 7 | .actions-cell { 8 | text-align: center; 9 | width: 14px; 10 | } 11 | 12 | .checkbox-cell { 13 | text-align: center; 14 | width: 14px; 15 | } 16 | 17 | .dropdown-menu { 18 | a.disabled { 19 | text-decoration: line-through; 20 | } 21 | &.scrollable-menu { 22 | height: auto; 23 | max-height: 500px; 24 | overflow-x: hidden; 25 | } 26 | } 27 | 28 | .panel-heading { 29 | 30 | display: flex; 31 | flex-direction: row; 32 | 33 | .heading-container { 34 | width: 30%; 35 | align-self: center; 36 | } 37 | 38 | .buttons-container { 39 | width: 70%; 40 | text-align: right; 41 | align-self: center; 42 | ui-grid-dropdown-button { 43 | margin-right: 10px; 44 | &:last-child { 45 | margin-right: 0; 46 | } 47 | } 48 | } 49 | 50 | } 51 | 52 | .panel-footer { 53 | 54 | display: flex; 55 | flex-direction: row; 56 | 57 | .status-container { 58 | width: 50%; 59 | align-self: center; 60 | } 61 | 62 | .pagination-container { 63 | width: 50%; 64 | text-align: right; 65 | align-self: center; 66 | display: flex; 67 | justify-content: flex-end; 68 | } 69 | 70 | } 71 | 72 | table { 73 | 74 | border-collapse: collapse; 75 | 76 | td, th { 77 | border-left: 1px solid #ddd; 78 | border-right: 1px solid #ddd; 79 | border-bottom: 1px solid #ddd; 80 | } 81 | 82 | thead { 83 | 84 | th { 85 | background-color: #fff; 86 | color: #333; 87 | text-align: left; 88 | font-size: 12px; 89 | border-bottom: none; 90 | &:first-child { 91 | border-left: none; 92 | } 93 | &:last-child { 94 | border-right: none; 95 | } 96 | } 97 | 98 | } 99 | 100 | tbody { 101 | 102 | td { 103 | &:first-child { 104 | border-left: none; 105 | } 106 | &:last-child { 107 | border-right: none; 108 | } 109 | } 110 | 111 | } 112 | 113 | } 114 | 115 | ui-grid-search-input { 116 | display: block; 117 | .input-group-sm { 118 | .form-control { 119 | border-radius: 0; 120 | border-left: none; 121 | border-right: none; 122 | } 123 | .input-group-btn { 124 | .btn { 125 | border-radius: 0; 126 | border-right: none; 127 | } 128 | } 129 | } 130 | } 131 | 132 | ui-grid-dropdown-button { 133 | display: inline-block; 134 | position: relative; 135 | align-self: center; 136 | } 137 | 138 | .pagination { 139 | margin: 0; 140 | } 141 | 142 | .dropdown-submenu { 143 | position:relative; 144 | } 145 | .dropdown-submenu>.dropdown-menu { 146 | top:0; 147 | left:100%; 148 | margin-top:-6px; 149 | margin-left:-1px; 150 | -webkit-border-radius:0 6px 6px 6px; 151 | -moz-border-radius:0 6px 6px 6px; 152 | border-radius:0 6px 6px 6px; 153 | } 154 | .dropdown-submenu:hover>.dropdown-menu { 155 | display:block; 156 | } 157 | .dropdown-submenu>a:after { 158 | display:block; 159 | content:" "; 160 | float:right; 161 | width:0; 162 | height:0; 163 | border-color:transparent; 164 | border-style:solid; 165 | border-width:5px 0 5px 5px; 166 | border-left-color:#cccccc; 167 | margin-top:5px; 168 | margin-right:-10px; 169 | } 170 | .dropdown-submenu:hover>a:after { 171 | border-left-color:#ffffff; 172 | } 173 | .dropdown-submenu.pull-left { 174 | float:none; 175 | } 176 | .dropdown-submenu.pull-left>.dropdown-menu { 177 | left:-100%; 178 | margin-left:10px; 179 | -webkit-border-radius:6px 0 6px 6px; 180 | -moz-border-radius:6px 0 6px 6px; 181 | border-radius:6px 0 6px 6px; 182 | } 183 | 184 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # TickTock 2 | 3 | --- 4 | 5 | TickTock runs scheduled tasks within Docker containers. Each task can be independently configured to run within an existing container or within a container that is automatically created and subsequently removed. TickTock includes built-in support for sending task notification emails via SMTP. It can also be extended to send notifications using a custom service that you provide in the form of a [Node.js](https://nodejs.org/) script. 6 | 7 | The interval at which a task is run is defined using natural language with the help of the [Later](https://bunkat.github.io/later/getting-started.html) module. For example, to execute a task every ten minutes you would simply set an `interval` of `every 10 minutes`. Standard [crontab](https://crontab.guru/) intervals are also supported. 8 | 9 | TickTock provides a visual front-end (accessible via the browser) through which you can view execution results. 10 | 11 | 12 | 13 | ## Sample docker-compose.yml 14 | 15 | A configuration file (more on that below) must be mounted into the TickTock container at `/config.yml`. 16 | 17 | ``` 18 | version: '3.4' 19 | 20 | services: 21 | 22 | ticktock: 23 | image: tkambler/ticktock 24 | volumes: 25 | - ./example/config.yml:/config.yml 26 | - ./data:/var/ticktock 27 | - /var/run/docker.sock:/var/run/docker.sock 28 | ``` 29 | 30 | ## Sample Configuration File (/config.yml) 31 | 32 | ``` 33 | # Username / password for web admin panel 34 | admin: 35 | username: username 36 | password: password 37 | 38 | # Mandatory. An array of task descriptions. 39 | 40 | tasks: 41 | 42 | - title: Do Something 43 | description: It does something very important. 44 | interval: every 10 seconds 45 | # Valid values: run, exec 46 | # A `run` task runs within a container that is created and removed for each execution. 47 | type: run 48 | image: mhart/alpine-node:8.6.0 49 | command: ["ls", "-al"] 50 | # If overlap is enabled, tasks will continue to be executed, even if previous executions 51 | # have not yet completed. Default: false 52 | overlap: false 53 | # If true, automatically execute the task when TickTock is first launched. Default: false 54 | execute_on_start: true 55 | # Default: true 56 | enabled: false 57 | 58 | - title: List running processes 59 | description: It lists running processes. 60 | interval: every 10 seconds 61 | # You can also define the interval using the crontab format (see below). 62 | # interval: "23 16 * * *" 63 | # Valid values: run, exec 64 | # An `exec` task runs within a pre-existing container that has already been started. 65 | type: exec 66 | # The name of the container within which the task will be executed. 67 | container: container1 68 | command: ["ps", "aux"] 69 | overlap: false 70 | enabled: true 71 | # If specified, each time the task is run a random number between 0 and the provided value 72 | # will be generated. Execution of the task will then delayed by that number of seconds. 73 | random_delay: 20 74 | # If SMTP notifications have been configured, you can pass an array of recipients here. 75 | email: 76 | - foo@localhost.site 77 | - herp@derp.com 78 | # Optional. If set, notifications will be combined into a single email that is sent out 79 | # after x number of executions have occurred. 80 | batch_email_interval: 10 81 | 82 | # Optional. 83 | email: 84 | smtp: 85 | from_name: TickTock 86 | from_email: ticktock@localhost.site 87 | # Values stored under the `config` property are passed directly to NodeMailer. 88 | # Configuration details can be found here: http://nodemailer.com/smtp/ 89 | config: 90 | host: maildev 91 | port: 25 92 | secure: false 93 | tls: 94 | secure: false 95 | ignoreTLS: true 96 | rejectUnauthorized: false 97 | ``` 98 | 99 | ## Executing Tasks on Demand 100 | 101 | Create a terminal session within the running TickTock container and run the script as shown below. You will be presented with a list of available tasks. Make a selection, and it will be immediately executed. 102 | 103 | ``` 104 | $ docker-compose exec ticktock sh 105 | $ ./execute 106 | ```` 107 | ## Generating a Task Report 108 | 109 | Create a terminal session within the running TickTock container and run the script as shown below. You will be presented with a list of defined tasks, including the previous and next execution times for each task. 110 | 111 | ``` 112 | $ docker-compose exec ticktock sh 113 | $ ./report 114 | ```` 115 | 116 | ## Extending TickTock with Custom Notification Recipients 117 | 118 | TickTock can be extended to send task notifications to a custom provider of your choosing. To do so, mount a Node application into the container as shown below. 119 | 120 | ``` 121 | version: '3.4' 122 | 123 | services: 124 | 125 | ticktock: 126 | image: tkambler/ticktock 127 | volumes: 128 | - ./example/notifications:/opt/ticktock/notifications 129 | - ./example/config.yml:/config.yml 130 | - /var/run/docker.sock:/var/run/docker.sock 131 | ``` 132 | 133 | In this example, we've mounted a `notifications` folder into the container at `/opt/ticktock/notifications`. This folder must contain a Node script named `index.js` that exports a single function, as shown below. 134 | 135 | ``` 136 | /** 137 | * @param task - An object describing the task that was executed. 138 | * @param res - An object (or an array of objects, if batching was enabled) that describes the execution's result(s). 139 | */ 140 | module.exports = (task, res) => { 141 | 142 | // Forward to Slack, REST endpoint, IRC, etc... 143 | 144 | }; 145 | ``` -------------------------------------------------------------------------------- /di/services/notifications/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | exports = module.exports = function(config) { 4 | 5 | const Promise = require('bluebird'); 6 | const nodemailer = require('nodemailer'); 7 | const _ = require('lodash'); 8 | const fs = require('app/fs'); 9 | 10 | class Notifications { 11 | 12 | constructor() { 13 | 14 | config.on('change', () => { 15 | 16 | if (!config.get('email:smtp')) { 17 | return; 18 | } 19 | 20 | this._emailTransport = Promise.promisifyAll(nodemailer.createTransport(config.get('email:smtp:config'))); 21 | 22 | }); 23 | 24 | } 25 | 26 | get emailTransport() { 27 | 28 | if (this._emailTransport) { 29 | return this._emailTransport; 30 | } 31 | 32 | if (!config.get('email:smtp')) { 33 | return; 34 | } 35 | 36 | this._emailTransport = Promise.promisifyAll(nodemailer.createTransport(config.get('email:smtp:config'))); 37 | 38 | return this._emailTransport; 39 | 40 | } 41 | 42 | get customProviderPath() { 43 | 44 | return '/opt/ticktock/notifications/index.js'; 45 | 46 | } 47 | 48 | getCustomProvider() { 49 | 50 | return Promise.resolve() 51 | .then(() => { 52 | 53 | if (!_.isUndefined(this.customProvider)) { 54 | return this.customProvider; 55 | } 56 | 57 | return fs.statAsync(this.customProviderPath) 58 | .then(() => { 59 | return this.customProvider = require(this.customProviderPath); 60 | }) 61 | .catch(() => { 62 | return this.customProvider = null; 63 | }); 64 | 65 | }); 66 | 67 | } 68 | 69 | custom(task, res) { 70 | 71 | return this.getCustomProvider() 72 | .then((provider) => { 73 | if (!provider) { 74 | return; 75 | } 76 | provider(task, res); 77 | return null; 78 | }); 79 | 80 | } 81 | 82 | email(task, res) { 83 | 84 | if (!this.emailTransport) { 85 | return; 86 | } 87 | 88 | task.email.forEach((email) => { 89 | 90 | const options = { 91 | 'from': `${config.get('email:smtp:from_name')} <${config.get('email:smtp:from_email')}>`, 92 | 'to': email 93 | }; 94 | 95 | _.extend(options, this.generateEmail(task, res)); 96 | 97 | this.emailTransport.sendMail(options); 98 | 99 | }); 100 | 101 | } 102 | 103 | generateEmail(task, res) { 104 | 105 | const message = {}; 106 | 107 | if (_.isArray(res)) { // res is an array of batched notification objects. 108 | 109 | const successCount = res.reduce((acc, curr) => { 110 | if (curr.exitCode === 0) { 111 | acc = acc + 1; 112 | } 113 | return acc; 114 | }, 0); 115 | 116 | const failureCount = res.reduce((acc, curr) => { 117 | if (curr.exitCode !== 0) { 118 | acc = acc + 1; 119 | } 120 | return acc; 121 | }, 0); 122 | 123 | message.subject = `Task ${task.title}: ${successCount} successful run(s), ${failureCount} failure(s)`; 124 | message.html = `Title: ${task.title}
Description: ${task.description}

`; 125 | message.text = `Title: ${task.title}\nDescription: ${task.description}\n\n`; 126 | 127 | res.forEach((res) => { 128 | 129 | message.html += `


Start: ${res.start.format('dddd, MMMM Do YYYY, h:mm:ss a Z')}
End: ${res.end.format('dddd, MMMM Do YYYY, h:mm:ss a Z')}

${res.outputBuffer.toString('utf8')}\n\n-----\n\n${res.errorBuffer.toString('utf8')}


`; 130 | message.text += `--------------------\n\nStart: ${res.start.format('dddd, MMMM Do YYYY, h:mm:ss a Z')}\nEnd: ${res.end.format('dddd, MMMM Do YYYY, h:mm:ss a Z')}\n\n${res.outputBuffer.toString('utf8')}\n\n-----\n\n${res.errorBuffer.toString('utf8')}\n\n`; 131 | 132 | }); 133 | 134 | } else { // res is a single notification object. 135 | 136 | if (res.exitCode === 0) { 137 | 138 | message.subject = `Task Succeeded: ${task.title}`; 139 | message.html = `Title: ${task.title}
Description: ${task.description}
Start: ${res.start.format('dddd, MMMM Do YYYY, h:mm:ss a Z')}
End: ${res.end.format('dddd, MMMM Do YYYY, h:mm:ss a Z')}

${res.outputBuffer.toString('utf8')}\n\n-----\n\n${res.errorBuffer.toString('utf8')}
`; 140 | message.text = `Title: ${task.title}\nDescription: ${task.description}\nStart: ${res.start.format('dddd, MMMM Do YYYY, h:mm:ss a Z')}\nEnd: ${res.end.format('dddd, MMMM Do YYYY, h:mm:ss a Z')}\n\n${res.outputBuffer.toString('utf8')}\n\n-----\n\n${res.errorBuffer.toString('utf8')}`; 141 | 142 | } else { 143 | 144 | message.subject = `Task Failed: ${task.title}`; 145 | message.html = `Title: ${task.title}
Description: ${task.description}
Start: ${res.start.format('dddd, MMMM Do YYYY, h:mm:ss a Z')}
End: ${res.end.format('dddd, MMMM Do YYYY, h:mm:ss a Z')}

${res.outputBuffer.toString('utf8')}\n\n-----\n\n${res.errorBuffer.toString('utf8')}
`; 146 | message.text = `Title: ${task.title}\nDescription: ${task.description}\nStart: ${res.start.format('dddd, MMMM Do YYYY, h:mm:ss a Z')}\nEnd: ${res.end.format('dddd, MMMM Do YYYY, h:mm:ss a Z')}\n\n${res.outputBuffer.toString('utf8')}\n\n-----\n\n${res.errorBuffer.toString('utf8')}`; 147 | 148 | } 149 | 150 | } 151 | 152 | return message; 153 | 154 | } 155 | 156 | } 157 | 158 | return new Notifications(); 159 | 160 | }; 161 | 162 | exports['@singleton'] = true; 163 | exports['@require'] = ['config']; -------------------------------------------------------------------------------- /di/services/task-manager/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | exports = module.exports = function(config, docker, notifications, log, knex) { 4 | 5 | const { ExecTask, RunTask } = require('app/tasks'); 6 | const _ = require('lodash'); 7 | const prettyjson = require('prettyjson'); 8 | 9 | class TaskManager { 10 | 11 | constructor() { 12 | 13 | config.get('tasks').forEach(this.loadTask.bind(this)); 14 | 15 | if (this.tasks.length === 0) { 16 | log.info('No tasks were registered.'); 17 | } 18 | 19 | } 20 | 21 | get tasks() { 22 | 23 | return this._tasks ? this._tasks : this._tasks = []; 24 | 25 | } 26 | 27 | loadTask(taskObj) { 28 | 29 | const task = this.forgeTask(taskObj); 30 | 31 | task.on('processing', () => { 32 | 33 | log.info('Processing task.', { 34 | 'task': task.toJSON() 35 | }); 36 | 37 | }); 38 | 39 | task.on('processed', (res) => { 40 | 41 | log.info('Task completed.', { 42 | 'task': task.toJSON(), 43 | 'output': res.outputBuffer.toString('utf8') 44 | }); 45 | 46 | knex('outputs').insert({ 47 | 'task_id': task.id, 48 | 'std_out': res.outputBuffer.toString('utf8'), 49 | 'std_err': res.errorBuffer.toString('utf8'), 50 | 'start_ts': res.start.format(), 51 | 'end_ts': res.end.format(), 52 | 'date': res.start.format('MM-DD-YYYY'), 53 | 'exit_code': res.exitCode 54 | }) 55 | .catch((err) => { 56 | log.error(err); 57 | }); 58 | 59 | }); 60 | 61 | task.on('notify', (res) => { 62 | 63 | if (task.email) { 64 | notifications.email(task, res); 65 | } 66 | 67 | notifications.custom(task, res); 68 | 69 | }); 70 | 71 | this.tasks.push(task); 72 | 73 | log.info('Registered task.', { 74 | 'task': task.toJSON() 75 | }); 76 | 77 | } 78 | 79 | forgeTask(task) { 80 | 81 | switch (task.type) { 82 | case 'exec': 83 | return new ExecTask(task, config, log, knex); 84 | case 'run': 85 | return new RunTask(task, config, log, knex); 86 | default: 87 | const err = new Error(); 88 | err.code = 'INVALID_TASK_TYPE'; 89 | err.task = task; 90 | throw err; 91 | } 92 | 93 | } 94 | 95 | onConfigChange() { 96 | 97 | log.info('Configuration file has been updated.'); 98 | 99 | const orphans = []; 100 | 101 | this.tasks.forEach((task) => { 102 | 103 | const taskObj = _.find(config.get('tasks'), (taskObj) => { 104 | return taskObj.id === task.id; 105 | }); 106 | 107 | if (!taskObj) { 108 | orphans.push(task); 109 | } 110 | 111 | }); 112 | 113 | this.processOrphans(orphans); 114 | 115 | config.get('tasks').forEach((taskObj) => { 116 | 117 | let task = this.getTaskByID(taskObj.id); 118 | 119 | if (task) { 120 | return; 121 | } 122 | 123 | this.loadTask(taskObj); 124 | 125 | }); 126 | 127 | } 128 | 129 | getTaskByID(id) { 130 | 131 | return _.find(this.tasks, (task) => { 132 | return task.id === id; 133 | }); 134 | 135 | } 136 | 137 | processOrphans(tasks) { 138 | 139 | if (tasks.length === 0) { 140 | return; 141 | } 142 | 143 | log.info(`${tasks.length} discarded task(s) found.`); 144 | 145 | tasks.forEach((task) => { 146 | 147 | log.info('Discarding task.', { 148 | 'task': task.toJSON() 149 | }); 150 | 151 | this.removeTask(task); 152 | 153 | }); 154 | 155 | } 156 | 157 | removeTask(task) { 158 | 159 | const idx = this.tasks.indexOf(task); 160 | this.tasks.splice(idx, 1); 161 | task.stop(); 162 | 163 | } 164 | 165 | shutdown() { 166 | 167 | return new Promise((resolve, reject) => { 168 | 169 | const running = []; 170 | 171 | this.tasks.forEach((task) => { 172 | task.stop(); 173 | if (task.isRunning()) { 174 | running.push(task); 175 | task.once('drain', () => { 176 | running.splice(running.indexOf(task), 1); 177 | if (running.length === 0) { 178 | return resolve(); 179 | } 180 | }); 181 | } 182 | }); 183 | 184 | }); 185 | 186 | } 187 | 188 | execute(id) { 189 | 190 | const task = this.getTaskByID(id); 191 | 192 | if (!task) { 193 | throw new Error(`Unable to locate task with ID: ${id}`); 194 | } 195 | 196 | task.execute(); 197 | 198 | } 199 | 200 | printStatus() { 201 | 202 | const data = this.tasks.map((task) => { 203 | 204 | return { 205 | 'Title': task.title, 206 | 'Description': task.description, 207 | 'Total Executions': task.executionCount, 208 | 'Last Execution': task.getLastExecution(), 209 | 'Next Execution': task.getNextExecution() 210 | } 211 | 212 | }); 213 | 214 | console.log(prettyjson.render(data)); 215 | 216 | } 217 | 218 | } 219 | 220 | return new TaskManager(); 221 | 222 | }; 223 | 224 | exports['@singleton'] = true; 225 | exports['@require'] = ['config', 'docker', 'notifications', 'log', 'knex']; -------------------------------------------------------------------------------- /di/services/api/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | exports = module.exports = function(log, config, appDir, knex, taskManager) { 4 | 5 | const Promise = require('bluebird'); 6 | const express = require('express'); 7 | const app = Promise.promisifyAll(express()); 8 | const moment = require('moment'); 9 | const path = require('path'); 10 | const auth = require('basic-auth'); 11 | const _ = require('lodash'); 12 | const port = 80; 13 | 14 | app.use(require('body-parser').json({ 15 | 'limit': '50mb' 16 | })); 17 | 18 | function adminAuth(req, res, next) { 19 | 20 | if (!config.get('admin:username') || !config.get('admin:password')) { 21 | 22 | res.statusCode = 401; 23 | res.setHeader('WWW-Authenticate', 'Basic realm="example"'); 24 | res.end('Access denied'); 25 | 26 | } else { 27 | 28 | const { name, pass } = auth(req) || {}; 29 | 30 | if (name !== config.get('admin:username') || pass !== config.get('admin:password')) { 31 | res.statusCode = 401; 32 | res.setHeader('WWW-Authenticate', 'Basic realm="example"'); 33 | res.end('Access denied'); 34 | } else { 35 | return next(); 36 | } 37 | 38 | } 39 | 40 | } 41 | 42 | app.param('task_id', (req, res, next, id) => { 43 | 44 | const task = _.find(config.get('tasks'), { 45 | 'id': id 46 | }); 47 | 48 | if (task) { 49 | req.task = task; 50 | return next(); 51 | } else { 52 | const err = new Error(`Task not found: ${id}`); 53 | err.http_code = 404; 54 | return next(err); 55 | } 56 | 57 | }); 58 | 59 | app.param('output_id', (req, res, next, id) => { 60 | 61 | return knex('outputs') 62 | .first('*') 63 | .where('id', id) 64 | .then((output) => { 65 | if (!output) { 66 | const err = new Error(`Output not found: ${id}`); 67 | err.http_code = 404; 68 | return next(err); 69 | } 70 | req.output = output; 71 | next(); 72 | return null; 73 | }) 74 | .catch((e) => { 75 | const err = new Error(`Output not found: ${id}`); 76 | err.http_code = 404; 77 | return next(err); 78 | }); 79 | 80 | }); 81 | 82 | app.use('/', adminAuth); 83 | app.use('/', express.static(path.resolve(appDir, 'frontend/public'))); 84 | 85 | app.route('/api/tasks') 86 | .get((req, res, next) => { 87 | 88 | const tasks = config.get('tasks'); 89 | 90 | return knex('outputs') 91 | .distinct('task_id', 'date') 92 | .then((rows) => { 93 | return rows; 94 | }) 95 | .map((row) => { 96 | row.date = moment(row.date, 'MM-DD-YYYY'); 97 | return row; 98 | }) 99 | .then((rows) => { 100 | 101 | rows = _.groupBy(rows, 'task_id'); 102 | 103 | const result = []; 104 | 105 | _.each(rows, (outputs, taskID) => { 106 | 107 | const task = _.cloneDeep(_.find(tasks, { 108 | 'id': taskID 109 | })); 110 | 111 | if (!task) { 112 | return; 113 | } 114 | 115 | outputs.sort((a, b) => { 116 | if (a.date.isBefore(b.date)) { 117 | return -1; 118 | } else if (a.date.isAfter(b.date)) { 119 | return 1; 120 | } else { 121 | return 0; 122 | } 123 | }); 124 | 125 | task.dates = outputs.map((output) => { 126 | return output.date.format('MM-DD-YYYY'); 127 | }); 128 | 129 | result.push(task); 130 | 131 | }); 132 | 133 | return res.send(result); 134 | 135 | }) 136 | .catch(next); 137 | 138 | }); 139 | 140 | app.route('/api/tasks/:task_id') 141 | .get((req, res, next) => { 142 | 143 | return res.send(req.task); 144 | 145 | }); 146 | 147 | app.route('/api/tasks/:task_id/trigger') 148 | .put((req, res, next) => { 149 | 150 | taskManager.execute(req.task.id); 151 | 152 | return res.status(200).end(); 153 | 154 | }); 155 | 156 | app.route('/api/tasks/:task_id/outputs/:date') 157 | .get((req, res, next) => { 158 | 159 | return knex('outputs') 160 | .select('*') 161 | .where({ 162 | 'task_id': req.task.id, 163 | 'date': req.params.date 164 | }) 165 | .then((outputs) => { 166 | 167 | outputs = outputs.map((output) => { 168 | output.startMoment = moment(output.start_ts); 169 | return output; 170 | }); 171 | 172 | outputs.sort((a, b) => { 173 | if (a.startMoment.isBefore(b.startMoment)) { 174 | return -1; 175 | } else if (a.startMoment.isAfter(b.startMoment)) { 176 | return 1; 177 | } else { 178 | return 0; 179 | } 180 | }); 181 | 182 | return outputs.map((output) => { 183 | delete output.startMoment; 184 | return output; 185 | }); 186 | 187 | }) 188 | .then(res.send.bind(res)) 189 | .catch(next); 190 | 191 | }); 192 | 193 | app.route('/api/outputs/:output_id') 194 | .get((req, res, next) => { 195 | 196 | return res.send(req.output); 197 | 198 | }); 199 | 200 | return app.listenAsync(port) 201 | .tap(() => { 202 | log.info(`API is listening on port: ${port}`); 203 | }); 204 | 205 | }; 206 | 207 | exports['@singleton'] = true; 208 | exports['@require'] = ['log', 'config', 'appDir', 'knex', 'task-manager']; -------------------------------------------------------------------------------- /frontend/Gruntfile.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = function(grunt) { 4 | 5 | require('time-grunt')(grunt); 6 | 7 | require('load-grunt-tasks')(grunt, { 8 | 'pattern': [ 9 | 'grunt-*', 10 | 'gruntify-eslint' 11 | ] 12 | }); 13 | 14 | grunt.initConfig({ 15 | 'pkg': require('./package.json'), 16 | 'uglify': { 17 | 'options': { 18 | 'mangle': false, 19 | 'beautify': true, 20 | 'sourceMap': { 21 | 'includeSources': true 22 | } 23 | }, 24 | 'vendor': { 25 | 'files': { 26 | 'public/js/vendor.js': [ 27 | 'node_modules/jquery/dist/jquery.js', 28 | 'node_modules/moment/moment.js', 29 | 'node_modules/lodash/lodash.js', 30 | 'node_modules/underscore.string/dist/underscore.string.js', 31 | 'node_modules/bluebird/js/browser/bluebird.js', 32 | 'node_modules/bootstrap-sass/assets/javascripts/bootstrap.js', 33 | 'node_modules/angular/angular.js', 34 | 'node_modules/angular-ui-bootstrap/dist/ui-bootstrap-tpls.js', 35 | 'node_modules/angular-ui-router/release/angular-ui-router.js', 36 | 'node_modules/angular-sanitize/angular-sanitize.js', 37 | 'node_modules/restangular/dist/restangular.min.js', 38 | 'node_modules/angular-validation-match/dist/angular-validation-match.js', 39 | 'node_modules/angular-loading-bar/src/loading-bar.js', 40 | 'node_modules/angular-json-tree/dist/angular-json-tree.js', 41 | 'node_modules/jstree/dist/jstree.js', 42 | 'node_modules/ng-js-tree/dist/ngJsTree.js', 43 | ] 44 | } 45 | } 46 | }, 47 | 'connect': { 48 | 'server': { 49 | 'options': { 50 | 'port': 9070, 51 | 'base': 'public', 52 | 'keepalive': true 53 | } 54 | } 55 | }, 56 | 'eslint': { 57 | 'options': { 58 | 'configFile': '.eslintrc.js', 59 | 'fix': true 60 | }, 61 | 'target': ['src/**/*.js'] 62 | }, 63 | 'compass': { 64 | 'all': { 65 | 'options': { 66 | 'httpPath': '/', 67 | 'cssDir': 'public/css', 68 | 'sassDir': 'scss', 69 | 'specify': [ 70 | 'scss/style.scss' 71 | ], 72 | 'imagesDir': 'public/images', 73 | 'relativeAssets': true, 74 | 'outputStyle': 'compressed', 75 | 'importPath': [ 76 | 'node_modules' 77 | ] 78 | } 79 | } 80 | }, 81 | 'concurrent': { 82 | 'build-serve': { 83 | 'options': { 84 | 'logConcurrentOutput': true, 85 | 'limit': 5 86 | }, 87 | 'tasks': ['connect', 'webpack:watch', 'watch:compass', 'watch:uglify', 'watch:assets'] 88 | }, 89 | 'webpack-serve': { 90 | 'options': { 91 | 'logConcurrentOutput': true, 92 | 'limit': 5 93 | }, 94 | 'tasks': ['connect', 'webpack:watch'] 95 | } 96 | }, 97 | 'watch': { 98 | 'compass': { 99 | 'files': ['Gruntfile.js', 'scss/**/*'], 100 | 'tasks': ['compass'] 101 | }, 102 | 'uglify': { 103 | 'files': ['Gruntfile.js'], 104 | 'tasks': ['uglify'] 105 | }, 106 | 'assets': { 107 | 'files': ['assets/**/*'], 108 | 'tasks': ['copy:assets'] 109 | } 110 | }, 111 | 'cssmin': { 112 | 'vendor': { 113 | 'files': { 114 | 'public/css/vendor.css': [ 115 | 'node_modules/noty/lib/noty.css', 116 | 'node_modules/animate.css/animate.css', 117 | 'node_modules/angular-loading-bar/src/loading-bar.css', 118 | 'node_modules/angular-json-tree/dist/angular-json-tree.css', 119 | ] 120 | } 121 | } 122 | }, 123 | 'clean': { 124 | 'everything': ['public/**/*'] 125 | }, 126 | 'copy': { 127 | 'fonts': { 128 | 'files': [ 129 | { 130 | 'expand': true, 131 | 'cwd': 'node_modules/opensans-npm-webfont/fonts', 132 | 'src': '**/*', 133 | 'dest': 'public/css/fonts' 134 | }, 135 | { 136 | 'expand': true, 137 | 'cwd': 'node_modules/bootstrap-sass/assets/fonts/bootstrap', 138 | 'src': '**/*', 139 | 'dest': 'public/css/fonts' 140 | }, 141 | { 142 | 'expand': true, 143 | 'cwd': 'node_modules/font-awesome/fonts', 144 | 'src': '**/*', 145 | 'dest': 'public/css/fonts' 146 | } 147 | ] 148 | }, 149 | 'assets': { 150 | 'files': [ 151 | { 152 | 'expand': true, 153 | 'cwd': 'assets', 154 | 'src': '**/*', 155 | 'dest': 'public' 156 | } 157 | ] 158 | }, 159 | 'misc': { 160 | 'files': [ 161 | { 162 | 'expand': true, 163 | 'cwd': 'node_modules/jstree/dist/themes', 164 | 'src': '**/*', 165 | 'dest': 'public/css/themes' 166 | } 167 | ] 168 | } 169 | }, 170 | 'webpack': { 171 | 'options': { 172 | 'progress': true, 173 | 'stats': false, 174 | 'storeStatsTo': 'webpackStats' 175 | }, 176 | 'once': require('./webpack.config.js'), 177 | 'watch': Object.assign({ 'watch': true, 'keepalive': true }, require('./webpack.config.js')) 178 | }, 179 | 'replace': { 180 | 'options': { 181 | 'patterns': [ 182 | { 183 | 'match': 'timestamp', 184 | 'replacement': '<%= new Date().getTime() %>' 185 | } 186 | ] 187 | }, 188 | 'index': { 189 | 'files': [ 190 | { 191 | 'expand': false, 192 | 'src': 'public/index.html', 193 | 'dest': 'public/index.html' 194 | } 195 | ] 196 | } 197 | } 198 | }); 199 | 200 | grunt.registerTask('save-webpack-stats', function() { 201 | grunt.file.write('./webpack.stats.json', JSON.stringify(grunt.config.get('webpackStats'), null, 4)); 202 | }); 203 | 204 | grunt.registerTask('build', ['clean', 'copy', 'cssmin', 'compass', 'uglify', 'replace', 'webpack:once', 'save-webpack-stats']); 205 | grunt.registerTask('build-serve', ['clean', 'copy', 'cssmin', 'compass', 'uglify', 'replace', 'concurrent:build-serve']); 206 | grunt.registerTask('webpack-serve', ['concurrent:webpack-serve']); 207 | grunt.registerTask('lint', 'eslint'); 208 | grunt.registerTask('default', 'build'); 209 | 210 | }; -------------------------------------------------------------------------------- /lib/app/base-task.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const parseCron = require('cron-parser').parseExpression; 4 | const later = require('later'); 5 | const prettyjson = require('prettyjson'); 6 | const _ = require('lodash'); 7 | const WritableStream = require('app/writable-stream'); 8 | const { EventEmitter2 } = require('eventemitter2'); 9 | const delay = require('app/delay'); 10 | const rand = require('app/random-number'); 11 | const moment = require('moment-timezone'); 12 | 13 | function precisionRound(number, precision) { 14 | const factor = Math.pow(10, precision); 15 | return Math.round(number * factor) / factor; 16 | } 17 | 18 | class BaseTask extends EventEmitter2 { 19 | 20 | constructor(task, config, log) { 21 | 22 | super(); 23 | 24 | this.task = _.cloneDeep(task); 25 | this.config = config; 26 | this.log = log; 27 | 28 | if (this.isCron) { 29 | this.parsed = later.parse.cron(task.interval); 30 | } else { 31 | this.parsed = later.parse.text(task.interval); 32 | } 33 | 34 | if (this.parsed.error >= 0) { 35 | const err = new Error(); 36 | err.code = 'INTERVAL_PARSE_ERROR'; 37 | err.character_position = this.parsed.error; 38 | err.task = task; 39 | throw err; 40 | } 41 | 42 | this.schedule(); 43 | 44 | if (this.executeOnStart) { 45 | this.execute(); 46 | } 47 | 48 | } 49 | 50 | get id() { 51 | 52 | return this.task.id; 53 | 54 | } 55 | 56 | get zone() { 57 | 58 | if (!_.isUndefined(this._zone)) { 59 | return this._zone; 60 | } 61 | 62 | if (!this.config.get('timezone')) { 63 | return this._zone = null; 64 | } 65 | 66 | return this._zone = moment.tz.zone(this.config.get('timezone')); 67 | 68 | } 69 | 70 | get isCron() { 71 | 72 | if (!_.isUndefined(this._isCron)) { 73 | return this._isCron; 74 | } 75 | 76 | try { 77 | const cron = parseCron(this.task.interval); 78 | this._isCron = cron ? true : false; 79 | } catch(e) { 80 | this._isCron = false; 81 | } 82 | 83 | return this._isCron; 84 | 85 | } 86 | 87 | get randomDelay() { 88 | 89 | return this.task.random_delay; 90 | 91 | } 92 | 93 | get executionCount() { 94 | 95 | if (!_.isUndefined(this._executionCount)) { 96 | return this._executionCount; 97 | } 98 | 99 | this._executionCount = 0; 100 | 101 | return this._executionCount; 102 | 103 | } 104 | 105 | set executionCount(value) { 106 | 107 | return this._executionCount = value; 108 | 109 | } 110 | 111 | get executeOnStart() { 112 | 113 | return this.task.execute_on_start; 114 | 115 | } 116 | 117 | get batchEmailInterval() { 118 | 119 | return this.task.batch_email_interval; 120 | 121 | } 122 | 123 | get pendingNotifications() { 124 | 125 | return this._pendingNotifications ? this._pendingNotifications : this._pendingNotifications = []; 126 | 127 | } 128 | 129 | get title() { 130 | 131 | return this.task.title; 132 | 133 | } 134 | 135 | get description() { 136 | 137 | return this.task.description; 138 | 139 | } 140 | 141 | get command() { 142 | 143 | return this.task.command; 144 | 145 | } 146 | 147 | get email() { 148 | 149 | return this.task.email; 150 | 151 | } 152 | 153 | get executions() { 154 | 155 | return this._executions ? this._executions : this._executions = []; 156 | 157 | } 158 | 159 | getRandomDelay() { 160 | 161 | return rand(0, this.randomDelay); 162 | 163 | } 164 | 165 | processNotifications(res) { 166 | 167 | if (this.batchEmailInterval === 0) { 168 | return this.emit('notify', res); 169 | } 170 | 171 | this.pendingNotifications.push(res); 172 | 173 | if (this.pendingNotifications.length === this.batchEmailInterval) { 174 | this.emit('notify', this.pendingNotifications); 175 | this.pendingNotifications.splice(0, this.pendingNotifications.length); 176 | } 177 | 178 | } 179 | 180 | toJSON() { 181 | 182 | const data = _.cloneDeep(this.task); 183 | data.cron = this.isCron; 184 | data.last_execution = this.getLastExecution(true); 185 | data.next_execution = this.getNextExecution(true); 186 | 187 | return data; 188 | 189 | } 190 | 191 | print() { 192 | 193 | console.log(prettyjson.render(this)); 194 | 195 | } 196 | 197 | execute() { 198 | 199 | if (this.executions.length > 1 && !this.task.overlap) { 200 | return; 201 | } 202 | 203 | this.emit('processing'); 204 | 205 | const timeout = this.getRandomDelay(); 206 | 207 | return delay(timeout) 208 | .then(() => { 209 | 210 | const execution = this.run(); 211 | this.executions.push(execution); 212 | 213 | return execution 214 | .then((res) => { 215 | this.emit('processed', res); 216 | this.processNotifications(res); 217 | }) 218 | .finally(() => { 219 | this.executionCount = this.executionCount + 1; 220 | const idx = this.executions.indexOf(execution); 221 | this.executions.splice(idx, 1); 222 | if (this.executions.length === 0) { 223 | this.emit('drain'); 224 | } 225 | }); 226 | 227 | }); 228 | 229 | } 230 | 231 | schedule() { 232 | 233 | this.interval = later.setInterval(this.execute.bind(this), this.parsed); 234 | 235 | } 236 | 237 | isRunning() { 238 | 239 | return this.executions.length > 0; 240 | 241 | } 242 | 243 | stop() { 244 | 245 | this.stopped = true; 246 | 247 | if (this.interval) { 248 | this.interval.clear(); 249 | } 250 | 251 | } 252 | 253 | start() { 254 | 255 | if (!this.stopped) { 256 | return; 257 | } 258 | 259 | this.stopped = false; 260 | this.schedule(); 261 | 262 | } 263 | 264 | getLastExecution(includeDiff) { 265 | 266 | if (!this.interval) { 267 | return '-'; 268 | } 269 | 270 | const schedule = later.schedule(this.parsed); 271 | 272 | let prev = schedule.prev(); 273 | 274 | if (includeDiff) { 275 | const now = moment(); 276 | let diff = Math.abs(now.diff(moment(prev), 'seconds')); 277 | if (diff < 3600) { 278 | diff = Math.abs(precisionRound(moment(prev).diff(now, 'minutes', true), 1)); 279 | prev += ` (${diff} minutes(s) ago)`; 280 | } else { 281 | diff = Math.abs(precisionRound(moment(prev).diff(now, 'hours', true), 1)); 282 | prev += ` (${diff} hour(s) ago)`; 283 | } 284 | } 285 | 286 | return prev; 287 | 288 | } 289 | 290 | getNextExecution(includeDiff) { 291 | 292 | if (!this.interval) { 293 | return '-'; 294 | } 295 | 296 | const schedule = later.schedule(this.parsed); 297 | 298 | let next = schedule.next(); 299 | 300 | if (includeDiff) { 301 | const now = moment(); 302 | let diff = moment(next).diff(now, 'seconds'); 303 | if (diff < 3600) { 304 | diff = precisionRound(moment(next).diff(now, 'minutes', true), 1); 305 | next += ` (${diff} minutes(s) from now)`; 306 | } else { 307 | diff = precisionRound(moment(next).diff(now, 'hours', true), 1); 308 | next += ` (${diff} hour(s) from now)`; 309 | } 310 | } 311 | 312 | return next; 313 | 314 | } 315 | 316 | } 317 | 318 | module.exports = BaseTask; -------------------------------------------------------------------------------- /frontend/src/components/src/tabs/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import app from 'app'; 4 | import _ from 'lodash'; 5 | 6 | require('./style.scss'); 7 | 8 | app.factory('tabStateWatcher', function($log, $state, $stateParams, $transitions) { 9 | 10 | return new class { 11 | 12 | constructor() { 13 | 14 | $transitions.onStart({}, (transition) => { 15 | transition.promise 16 | .then(() => { 17 | this.tabs.forEach((tabs) => { 18 | tabs.onTransitionSuccess(); 19 | }); 20 | }); 21 | }); 22 | 23 | } 24 | 25 | get tabs() { 26 | return this._tabs ? this._tabs : this._tabs = []; 27 | } 28 | 29 | registerTabs(tabs) { 30 | this.tabs.push(tabs); 31 | return this; 32 | } 33 | 34 | unregisterTabs(tabs) { 35 | const idx = this.tabs.indexOf(tabs); 36 | if (idx >= 0) { 37 | this.tabs.splice(idx, 1); 38 | } 39 | return this; 40 | } 41 | 42 | }; 43 | 44 | }); 45 | 46 | app.component('tabs', { 47 | 'bindings': { 48 | 'heading': '<', 49 | 'alwaysShowTabs': '<', 50 | 'parent': '<', 51 | 'actions': '<', 52 | 'onReady': '<' 53 | }, 54 | 'transclude': true, 55 | 'controller': function($log, $state, $stateParams, $timeout, $element, $transitions, tabStateWatcher) { 56 | 57 | return new class { 58 | 59 | $onInit() { 60 | this.tabs = []; 61 | this._generatedActions = this.actions || []; 62 | tabStateWatcher.registerTabs(this); 63 | } 64 | 65 | $onDestroy() { 66 | tabStateWatcher.unregisterTabs(this); 67 | this._destroyed = true; 68 | } 69 | 70 | $postLink() { 71 | // $log.debug('tabs.$postLink', { '$stateParams': $stateParams, '$stateParams.tab': $stateParams.tab }); 72 | _.defer(() => { 73 | $element.find('[data-toggle="tooltip"]').tooltip(); 74 | }); 75 | if (this.onReady) { 76 | this.onReady(this); 77 | } 78 | } 79 | 80 | watchTabs() { 81 | 82 | } 83 | 84 | onTransitionSuccess() { 85 | // $log.debug('onTransitionSuccess', { 86 | // 'this': this, 87 | // '$stateParams.tab': $stateParams.tab, 88 | // 'this.activeTab.id': _.get(this, 'activeTab.id') 89 | // }); 90 | if ($stateParams.tab && this.activeTab && this.activeTab.id !== $stateParams.tab) { 91 | this.selectTabById($stateParams.tab); 92 | } 93 | } 94 | 95 | registerTab(tab) { 96 | if (!tab.enabled) { 97 | return; 98 | } 99 | // $log.debug('registerTab', tab); 100 | this.tabs.push(tab); 101 | _.defer(this.initDefaultTab.bind(this)); 102 | } 103 | 104 | initDefaultTab() { 105 | if (this._defaultTabInitialized) { 106 | return; 107 | } 108 | this._defaultTabInitialized = true; 109 | $timeout(() => { 110 | if ($stateParams.tab) { 111 | this.selectTab(_.find(this.tabs, { 'id': $stateParams.tab }) || this.tabs[0]); 112 | } else { 113 | this.selectTab(this.tabs[0]); 114 | } 115 | this.generateActions(); 116 | this.watchTabs(); 117 | }); 118 | } 119 | 120 | unregisterTab(tab) { 121 | this.tabs.splice(this.tabs.indexOf(tab), 1); 122 | } 123 | 124 | selectTabById(id) { 125 | const tab = _.find(this.tabs, { 'id': id }); 126 | // $log.debug('selectTabById', { 127 | // 'id': id, 128 | // 'tab': tab, 129 | // 'this': this, 130 | // 'this.tabs': _.cloneDeep(this.tabs) 131 | // }); 132 | this.selectTab(tab); 133 | } 134 | 135 | selectTab(tab) { 136 | if (this.activeTab === tab) { 137 | return; 138 | } 139 | const stateParams = _.cloneDeep($stateParams); 140 | stateParams.tab = tab ? tab.id : undefined; 141 | tab.loadTriggered = true; 142 | this.activeTab = tab; 143 | $state.transitionTo($state.current.name, stateParams, { 144 | 'notify': false, 145 | 'location': 'replace' 146 | }); 147 | if (_.isFunction(tab.onShow)) { 148 | tab.onShow(); 149 | } 150 | } 151 | 152 | generateActions() { 153 | const actions = _.cloneDeep(this.actions) || []; 154 | const newActions = []; 155 | this.tabs.forEach((tab) => { 156 | if (tab.actions.length) { 157 | const tabLinks = { 158 | 'label': tab.heading, 159 | 'links': [] 160 | }; 161 | tab.actions.forEach((tabAction) => { 162 | tabLinks.links.push(tabAction); 163 | }); 164 | newActions.push(tabLinks); 165 | } 166 | }); 167 | if (newActions.length) { 168 | if (actions.length) { 169 | actions.push({ 'divider': true }); 170 | } 171 | actions.splice(actions.length, 0, ...newActions); 172 | } 173 | this._generatedActions = actions; 174 | } 175 | 176 | setTabActions(tab) { 177 | _.defer(() => { 178 | $timeout(() => { 179 | this.generateActions(); 180 | }); 181 | }); 182 | } 183 | 184 | get showTabs() { 185 | return this.alwaysShowTabs ? true : this.tabs.length > 1; 186 | } 187 | 188 | get generatedActions() { 189 | return this._generatedActions || []; 190 | } 191 | 192 | }; 193 | 194 | }, 195 | 'template': ` 196 | 197 | 219 | 220 | 221 | 222 | ` 223 | }); 224 | 225 | app.component('tab', { 226 | 'bindings': { 227 | 'heading': '<', 228 | 'id': '<', 229 | 'lazyLoad': '<', 230 | 'enabled': '<' 231 | }, 232 | 'require': { 233 | 'tabs': '^^' 234 | }, 235 | 'transclude': true, 236 | 'controller': function($log, filterEnabled) { 237 | 238 | return new class { 239 | 240 | constructor() { 241 | this.loadTriggered = false; 242 | } 243 | 244 | $onInit() { 245 | if (!this.heading) throw new Error(`'heading' is required`); 246 | this.enabled = _.isBoolean(this.enabled) ? this.enabled : true; 247 | this.heading = this.heading.trim(); 248 | this.id = this.id || _.chain(this.heading).toLower().snakeCase().value(); 249 | this.tabs.registerTab(this); 250 | } 251 | 252 | get parent() { 253 | return this.tabs.parent; 254 | } 255 | 256 | get _show() { 257 | return (this.tabs.activeTab === this); 258 | } 259 | 260 | get actions() { 261 | return this._actions || []; 262 | } 263 | 264 | set actions(actions) { 265 | this._actions = filterEnabled(actions); 266 | this.tabs.setTabActions(this); 267 | } 268 | 269 | get onShow() { 270 | return this._onShow; 271 | } 272 | 273 | set onShow(cb) { 274 | this._onShow = cb; 275 | if (this.loadTriggered) { 276 | cb(); 277 | } 278 | return this._onShow; 279 | } 280 | 281 | }; 282 | 283 | }, 284 | 'template': `
` 285 | }); -------------------------------------------------------------------------------- /frontend/src/components/src/ui-grid/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import app from 'app'; 4 | import _ from 'lodash'; 5 | 6 | require('./ui-grid.service'); 7 | require('./style.scss'); 8 | 9 | app.component('uiGrid', { 10 | 'bindings': { 11 | 'options': '<' 12 | }, 13 | 'controller': function($log, UIGrid) { 14 | 15 | return new class { 16 | 17 | $onInit() { 18 | 19 | // $log.debug('uiGrid', { 'this': this }); 20 | this.options = _.cloneDeep(this.options); 21 | this.grid = new UIGrid(this.options); 22 | if (this.options.onReady) { 23 | this.options.onReady({ 24 | 'reload': () => { 25 | this.grid.init(); 26 | } 27 | }); 28 | } 29 | this.grid.init() 30 | .then(() => { 31 | this.generatePaginationDropdown(); 32 | this.generateColumnsDropdown(); 33 | this.generateActionsDropdown(); 34 | this.generateFiltersDropdown(); 35 | this.generateSearchInput(); 36 | }); 37 | 38 | } 39 | 40 | generatePaginationDropdown() { 41 | 42 | let options = { 43 | 'label': `Pagination: ${this.grid.activePagination}`, 44 | 'size': 'small', 45 | 'links': this.grid.pagination.map((value) => { 46 | return { 47 | 'label': value, 48 | 'fn': () => { 49 | return this.grid.setPagination(value) 50 | .then(this.generatePaginationDropdown.bind(this)); 51 | } 52 | }; 53 | }) 54 | }; 55 | 56 | this.paginationDropdown = options; 57 | 58 | } 59 | 60 | generateColumnsDropdown() { 61 | 62 | const preferences = this.grid.preferences; 63 | 64 | let options = { 65 | 'label': 'Columns', 66 | 'size': 'small', 67 | 'links': this.grid.toggleableColumns.map((column) => { 68 | return { 69 | 'label': column.label, 70 | 'klass': preferences.columns[column.object_key].toggled ? '' : 'disabled', 71 | 'fn': () => { 72 | return this.grid.toggleColumn(column.object_key) 73 | .then(this.generateColumnsDropdown.bind(this)); 74 | } 75 | }; 76 | }) 77 | }; 78 | 79 | this.columnsDropdown = options; 80 | 81 | } 82 | 83 | generateActionsDropdown() { 84 | 85 | const links = [ 86 | { 87 | 'label': 'Refresh Table', 88 | 'fn': () => { 89 | this.grid.init(); 90 | } 91 | }, 92 | { 93 | 'label': 'Export to CSV', 94 | 'fn': () => { 95 | this.grid.exportCSV(); 96 | } 97 | } 98 | ]; 99 | 100 | if (this.options.toolbar_actions.length > 0) { 101 | links.push({ 'divider': true }); 102 | } 103 | 104 | links.splice(links.length, 0, ...this.options.toolbar_actions.map((action) => { 105 | return { 106 | 'label': action.label, 107 | 'fn': () => { 108 | action.fn(this.grid.getToggledRows()); 109 | } 110 | }; 111 | })); 112 | 113 | let options = { 114 | 'label': 'Actions', 115 | 'size': 'small', 116 | 'links': links 117 | }; 118 | 119 | this.actionsDropdown = options; 120 | 121 | } 122 | 123 | generateFiltersDropdown() { 124 | 125 | const links = [ 126 | { 127 | 'label': 'No Filter', 128 | 'fn': () => { 129 | this.grid.setFilter() 130 | .then(this.generateFiltersDropdown.bind(this)); 131 | } 132 | } 133 | ]; 134 | 135 | _.each(this.grid.filters, (filters, groupName) => { 136 | 137 | const group = { 138 | 'label': groupName 139 | }; 140 | 141 | group.links = filters.map((filter) => { 142 | return { 143 | 'label': filter.label, 144 | 'fn': () => { 145 | this.grid.setFilter(filter) 146 | .then(this.generateFiltersDropdown.bind(this)); 147 | } 148 | }; 149 | }); 150 | 151 | links.push(group); 152 | 153 | }); 154 | 155 | let options = { 156 | 'label': this.grid.chosenFilter ? `Filter: ${this.grid.chosenFilter.label}` : 'Filter: None', 157 | 'size': 'small', 158 | 'links': _.orderBy(links, 'label') 159 | }; 160 | 161 | this.filtersDropdown = options; 162 | 163 | } 164 | 165 | generateSearchInput() { 166 | 167 | this.searchOptions = { 168 | 'columns': _.filter(this.grid.toggledColumns, { 'searchable': true }) 169 | }; 170 | 171 | } 172 | 173 | get showCharts() { 174 | 175 | return this.grid.showCharts; 176 | 177 | } 178 | 179 | get charts() { 180 | 181 | return this.grid.charts; 182 | 183 | } 184 | 185 | get sortColumn() { 186 | 187 | return this.grid.sortColumn; 188 | 189 | } 190 | 191 | get sortDirection() { 192 | 193 | return this.grid.sortDirection; 194 | 195 | } 196 | 197 | sort(objectKey) { 198 | 199 | return this.grid.sort(objectKey); 200 | 201 | } 202 | 203 | }; 204 | 205 | }, 206 | 'template': require('./template.html') 207 | }); 208 | 209 | app.component('uiGridDropdownButton', { 210 | 'bindings': { 211 | 'options': '<' 212 | }, 213 | 'controller': function() { 214 | 215 | return new class { 216 | 217 | $onInit() { 218 | 219 | } 220 | 221 | get klass() { 222 | switch (_.get(this, 'options.size')) { 223 | case 'small': 224 | return 'btn-sm'; 225 | default: 226 | return ''; 227 | } 228 | } 229 | 230 | get label() { 231 | return _.get(this, 'options.label'); 232 | } 233 | 234 | get icon() { 235 | return _.get(this, 'options.icon'); 236 | } 237 | 238 | }; 239 | 240 | }, 241 | 'template': ` 242 | 243 | 244 | 257 | ` 258 | }); 259 | 260 | app.component('uiGridSearchInput', { 261 | 'bindings': { 262 | 'options': '<', 263 | 'onSearch': '&' 264 | }, 265 | 'require': { 266 | 'uiGrid': '^^' 267 | }, 268 | 'controller': function($log) { 269 | 270 | return new class { 271 | 272 | $onInit() { 273 | this.model = {}; 274 | } 275 | 276 | get columns() { 277 | return _.get(this, ['options', 'columns']) || []; 278 | } 279 | 280 | get activeColumn() { 281 | return this.uiGrid.grid.searchColumn; 282 | } 283 | 284 | setColumn(col) { 285 | this.uiGrid.grid.searchColumn = col; 286 | } 287 | 288 | submit() { 289 | return this.uiGrid.grid.search(); 290 | } 291 | 292 | }; 293 | 294 | }, 295 | 'template': ` 296 |
297 |
298 | 299 |
300 | 301 | 304 |
305 |
306 | 307 |
308 |
309 |
310 | ` 311 | }); -------------------------------------------------------------------------------- /frontend/src/states/src/app.task/state.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import './style.scss'; 4 | import moment from 'moment'; 5 | import Promise from 'bluebird'; 6 | import _ from 'lodash'; 7 | import s from 'underscore.string'; 8 | 9 | module.exports = { 10 | 'name': 'app.task', 11 | 'url': '/task/:taskId', 12 | 'resolve': { 13 | 'task': function(Task, $stateParams, $log) { 14 | 15 | return Task.getList() 16 | .then((tasks) => { 17 | return _.find(tasks, { 18 | 'id': $stateParams.taskId 19 | }); 20 | }); 21 | 22 | }, 23 | 'treeData': function(task, $log) { 24 | 25 | const res = [ 26 | { 27 | 'text': task.title, 28 | 'id': task.id, 29 | 'parent': '#', 30 | 'state': { 31 | 'opened': true 32 | } 33 | } 34 | ]; 35 | 36 | task.dates.forEach((date) => { 37 | res.push({ 38 | 'id': `${task.id}--${date}`, 39 | 'parent': task.id, 40 | 'text': date 41 | }); 42 | }); 43 | 44 | return res; 45 | 46 | } 47 | }, 48 | 'views': { 49 | 'content@app': { 50 | 'controllerAs': '$ctrl', 51 | 'controller': function($log, $state, filterEnabled, $uibModal, $http, notify, treeData, task, Output, $timeout, $stateParams, saveFile) { 52 | 53 | return new class { 54 | 55 | treeData = treeData; 56 | ignoreChanges = false; 57 | 58 | constructor() { 59 | 60 | this.onReady = this.onReady.bind(this); 61 | this.onSelectNode = this.onSelectNode.bind(this); 62 | 63 | } 64 | 65 | $onInit() { 66 | 67 | if ($stateParams.outputID && $stateParams.taskID) { 68 | this.loadTaskOutput($stateParams.outputID, $stateParams.taskID); 69 | } 70 | 71 | } 72 | 73 | get shouldApply() { 74 | 75 | return !this.ignoreChanges; 76 | 77 | } 78 | 79 | get treeConfig() { 80 | 81 | return this._treeConfig ? this._treeConfig : this._treeConfig = { 82 | 'core': { 83 | 'multiple': false, 84 | 'animation': false, 85 | 'error': function(error) { 86 | $log.error('treeCtrl: error from js tree - ' + angular.toJson(error)); 87 | }, 88 | 'check_callback': true, 89 | 'worker': true 90 | }, 91 | 'types': { 92 | 'default': { 93 | 'icon': 'glyphicon glyphicon-flash' 94 | }, 95 | 'star': { 96 | 'icon': 'glyphicon glyphicon-star' 97 | }, 98 | 'cloud': { 99 | 'icon': 'glyphicon glyphicon-cloud' 100 | } 101 | }, 102 | 'plugins': ['types'], 103 | 'version': 1, 104 | }; 105 | 106 | } 107 | 108 | set treeInstance(value) { 109 | 110 | this._treeInstance = value; 111 | 112 | } 113 | 114 | get treeData() { 115 | 116 | return this._treeData; 117 | 118 | } 119 | 120 | set treeData(value) { 121 | 122 | return this._treeData = value; 123 | 124 | } 125 | 126 | onReady() { 127 | 128 | this.ignoreChanges = false; 129 | 130 | } 131 | 132 | onSelectNode(e, data) { 133 | 134 | if (_.isFinite(parseInt(data.node.id, 10))) { 135 | const [ taskID, date ] = data.node.parent.split('--'); 136 | return this.loadTaskOutput(data.node.id, taskID); 137 | } 138 | 139 | const [ taskID, date ] = data.node.id.split('--'); 140 | 141 | if (!taskID || !date) { 142 | return; 143 | } 144 | 145 | return Promise.resolve(this.getTaskOutputs(taskID, date)) 146 | .map((output) => { 147 | return output.originalElement; 148 | }) 149 | .then((outputs) => { 150 | this.setTaskOutputs(taskID, date, outputs); 151 | }); 152 | 153 | } 154 | 155 | getTaskOutputs(taskID, date) { 156 | 157 | return task.one('outputs', date).getList(); 158 | 159 | } 160 | 161 | setTaskOutputs(taskID, date, outputs) { 162 | 163 | this.ignoreChanges = true; 164 | 165 | this._treeData = this._treeData.filter((row) => { 166 | return row.parent !== `${taskID}--${date}`; 167 | }); 168 | 169 | this._treeData = this._treeData.map((row) => { 170 | row.state = { 171 | 'opened': (row.id === taskID || row.id === `${taskID}--${date}`) ? true : false 172 | }; 173 | return row; 174 | }); 175 | 176 | outputs.forEach((output) => { 177 | this._treeData.push({ 178 | 'id': output.id, 179 | 'parent': `${taskID}--${date}`, 180 | 'text': moment(output.start_ts).format('hh:mm:ss A') 181 | }); 182 | }); 183 | 184 | this.treeConfig.version++; 185 | 186 | } 187 | 188 | loadTaskOutput(id, taskID) { 189 | 190 | return Output.one(id).get() 191 | .then((output) => { 192 | 193 | const stateParams = _.cloneDeep($stateParams); 194 | stateParams.outputID = id; 195 | stateParams.taskID = taskID; 196 | 197 | $state.transitionTo($state.current.name, stateParams, { 198 | 'notify': false 199 | }); 200 | 201 | this.output = output; 202 | 203 | $timeout(() => { 204 | this.databoxOptions = null; 205 | }, 0); 206 | 207 | $timeout(() => { 208 | 209 | this.databoxOptions = { 210 | 'heading': task.title, 211 | 'actions': [ 212 | { 213 | 'label': 'Trigger New Execution of Task', 214 | 'fn': () => { 215 | return task.customPUT({}, 'trigger') 216 | .then(() => { 217 | notify.success(`Execution of task triggered.`); 218 | }); 219 | } 220 | }, 221 | { 222 | 'label': 'Save Standard Output to File', 223 | 'fn': () => { 224 | return saveFile(`${s.slugify(task.title)}-id-stdout.txt`, output.std_out); 225 | } 226 | }, 227 | { 228 | 'label': 'Save Standard Error to File', 229 | 'fn': () => { 230 | return saveFile(`${s.slugify(task.title)}-id-stderr.txt`, output.std_err); 231 | } 232 | } 233 | ], 234 | 'sections': [ 235 | { 236 | 'label': `Overview`, 237 | 'items': [ 238 | { 'label': 'Description', 'value': task.description, 'columns': 12 }, 239 | { 'label': 'Exit Code', 'value': output.exit_code }, 240 | { 'label': 'Start TS', 'value': moment(output.start_ts).format('dddd, MMMM Do YYYY, h:mm:ss A'), 'columns': 6, 'break': true }, 241 | { 'label': 'End TS', 'value': moment(output.end_ts).format('dddd, MMMM Do YYYY, h:mm:ss A'), 'columns': 6 } 242 | ] 243 | }, 244 | { 245 | 'label': 'Standard Output', 246 | 'content': output.std_out || '-', 247 | 'content_type': 'code' 248 | }, 249 | { 250 | 'label': 'Standard Error', 251 | 'content': output.std_err || '-', 252 | 'content_type': 'code' 253 | } 254 | ] 255 | }; 256 | 257 | }, 0); 258 | 259 | }); 260 | 261 | } 262 | 263 | } 264 | 265 | }, 266 | 'template': ` 267 | 268 |
269 | 270 |
271 | 272 |
273 | 274 |
275 | 276 |
277 | 278 | 279 | 280 |
281 | 282 |
283 | 284 | ` 285 | } 286 | } 287 | }; -------------------------------------------------------------------------------- /frontend/src/states/src/app.home/state.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import './style.scss'; 4 | import moment from 'moment'; 5 | import Promise from 'bluebird'; 6 | import _ from 'lodash'; 7 | import s from 'underscore.string'; 8 | 9 | module.exports = { 10 | 'name': 'app.home', 11 | 'url': '/home?outputID&taskID', 12 | 'resolve': { 13 | 'tasks': function(Task) { 14 | 15 | return Task.getList(); 16 | 17 | }, 18 | 'treeData': function(tasks, $log) { 19 | 20 | const res = []; 21 | 22 | tasks.forEach((task) => { 23 | res.push({ 24 | 'id': task.id, 25 | 'parent': '#', 26 | 'text': task.title, 27 | 'state': { 28 | 'opened': true 29 | } 30 | }); 31 | task.dates.forEach((date) => { 32 | res.push({ 33 | 'id': `${task.id}--${date}`, 34 | 'parent': task.id, 35 | 'text': date 36 | }); 37 | }); 38 | }); 39 | 40 | return res; 41 | 42 | } 43 | }, 44 | 'views': { 45 | 'content@app': { 46 | 'controllerAs': '$ctrl', 47 | 'controller': function($log, $state, filterEnabled, $uibModal, $http, notify, treeData, tasks, Output, $timeout, $stateParams, saveFile) { 48 | 49 | return new class { 50 | 51 | treeData = treeData; 52 | ignoreChanges = false; 53 | 54 | constructor() { 55 | 56 | this.onReady = this.onReady.bind(this); 57 | this.onSelectNode = this.onSelectNode.bind(this); 58 | 59 | } 60 | 61 | $onInit() { 62 | 63 | if ($stateParams.outputID && $stateParams.taskID) { 64 | this.loadTaskOutput($stateParams.outputID, $stateParams.taskID); 65 | } 66 | 67 | } 68 | 69 | get shouldApply() { 70 | 71 | return !this.ignoreChanges; 72 | 73 | } 74 | 75 | get treeConfig() { 76 | 77 | return this._treeConfig ? this._treeConfig : this._treeConfig = { 78 | 'core': { 79 | 'multiple': false, 80 | 'animation': false, 81 | 'error': function(error) { 82 | $log.error('treeCtrl: error from js tree - ' + angular.toJson(error)); 83 | }, 84 | 'check_callback': true, 85 | 'worker': true 86 | }, 87 | 'types': { 88 | 'default': { 89 | 'icon': 'glyphicon glyphicon-flash' 90 | }, 91 | 'star': { 92 | 'icon': 'glyphicon glyphicon-star' 93 | }, 94 | 'cloud': { 95 | 'icon': 'glyphicon glyphicon-cloud' 96 | } 97 | }, 98 | 'plugins': ['types'], 99 | 'version': 1, 100 | }; 101 | 102 | } 103 | 104 | set treeInstance(value) { 105 | 106 | this._treeInstance = value; 107 | 108 | } 109 | 110 | get treeData() { 111 | 112 | return this._treeData; 113 | 114 | } 115 | 116 | set treeData(value) { 117 | 118 | return this._treeData = value; 119 | 120 | } 121 | 122 | onReady() { 123 | 124 | this.ignoreChanges = false; 125 | 126 | } 127 | 128 | onSelectNode(e, data) { 129 | 130 | if (_.isFinite(parseInt(data.node.id, 10))) { 131 | const [ taskID, date ] = data.node.parent.split('--'); 132 | return this.loadTaskOutput(data.node.id, taskID); 133 | } 134 | 135 | const [ taskID, date ] = data.node.id.split('--'); 136 | 137 | if (!taskID || !date) { 138 | return; 139 | } 140 | 141 | return Promise.resolve(this.getTaskOutputs(taskID, date)) 142 | .map((output) => { 143 | return output.originalElement; 144 | }) 145 | .then((outputs) => { 146 | this.setTaskOutputs(taskID, date, outputs); 147 | }); 148 | 149 | } 150 | 151 | getTaskOutputs(taskID, date) { 152 | 153 | const task = _.find(tasks, { 154 | 'id': taskID 155 | }); 156 | 157 | return task.one('outputs', date).getList(); 158 | 159 | } 160 | 161 | setTaskOutputs(taskID, date, outputs) { 162 | 163 | this.ignoreChanges = true; 164 | 165 | this._treeData = this._treeData.filter((row) => { 166 | return row.parent !== `${taskID}--${date}`; 167 | }); 168 | 169 | this._treeData = this._treeData.map((row) => { 170 | row.state = { 171 | 'opened': (row.id === taskID || row.id === `${taskID}--${date}`) ? true : false 172 | }; 173 | return row; 174 | }); 175 | 176 | outputs.forEach((output) => { 177 | this._treeData.push({ 178 | 'id': output.id, 179 | 'parent': `${taskID}--${date}`, 180 | 'text': moment(output.start_ts).format('hh:mm:ss A') 181 | }); 182 | }); 183 | 184 | this.treeConfig.version++; 185 | 186 | } 187 | 188 | loadTaskOutput(id, taskID) { 189 | 190 | const task = _.find(tasks, { 191 | 'id': taskID 192 | }); 193 | 194 | return Output.one(id).get() 195 | .then((output) => { 196 | 197 | const stateParams = _.cloneDeep($stateParams); 198 | stateParams.outputID = id; 199 | stateParams.taskID = taskID; 200 | 201 | $state.transitionTo($state.current.name, stateParams, { 202 | 'notify': false 203 | }); 204 | 205 | this.output = output; 206 | 207 | $timeout(() => { 208 | this.databoxOptions = null; 209 | }, 0); 210 | 211 | $timeout(() => { 212 | 213 | this.databoxOptions = { 214 | 'heading': task.title, 215 | 'actions': [ 216 | { 217 | 'label': 'Trigger New Execution of Task', 218 | 'fn': () => { 219 | return task.customPUT({}, 'trigger') 220 | .then(() => { 221 | notify.success(`Execution of task triggered.`); 222 | }); 223 | } 224 | }, 225 | { 226 | 'label': 'Save Standard Output to File', 227 | 'fn': () => { 228 | return saveFile(`${s.slugify(task.title)}-id-stdout.txt`, output.std_out); 229 | } 230 | }, 231 | { 232 | 'label': 'Save Standard Error to File', 233 | 'fn': () => { 234 | return saveFile(`${s.slugify(task.title)}-id-stderr.txt`, output.std_err); 235 | } 236 | } 237 | ], 238 | 'sections': [ 239 | { 240 | 'label': `Overview`, 241 | 'items': [ 242 | { 'label': 'Description', 'value': task.description, 'columns': 12 }, 243 | { 'label': 'Exit Code', 'value': output.exit_code }, 244 | { 'label': 'Start TS', 'value': moment(output.start_ts).format('dddd, MMMM Do YYYY, h:mm:ss A'), 'columns': 6, 'break': true }, 245 | { 'label': 'End TS', 'value': moment(output.end_ts).format('dddd, MMMM Do YYYY, h:mm:ss A'), 'columns': 6 } 246 | ] 247 | }, 248 | { 249 | 'label': 'Standard Output', 250 | 'content': output.std_out || '-', 251 | 'content_type': 'code' 252 | }, 253 | { 254 | 'label': 'Standard Error', 255 | 'content': output.std_err || '-', 256 | 'content_type': 'code' 257 | } 258 | ] 259 | }; 260 | 261 | }, 0); 262 | 263 | }); 264 | 265 | } 266 | 267 | } 268 | 269 | }, 270 | 'template': ` 271 | 272 |
273 | 274 |
275 | 276 |
277 | 278 |
279 | 280 |
281 | 282 | 283 | 284 |
285 | 286 |
287 | 288 | ` 289 | } 290 | } 291 | }; -------------------------------------------------------------------------------- /frontend/src/components/src/ui-grid/ui-grid.service.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import app from 'app'; 4 | import _ from 'lodash'; 5 | import Promise from 'bluebird'; 6 | 7 | app.factory('UIGrid', function($http, $log, $state, $stateParams, filterEnabled, saveFile) { 8 | 9 | class UIGrid { 10 | 11 | constructor(options) { 12 | 13 | _.defaults(options, { 14 | 'columns': {} 15 | }); 16 | 17 | let columns; 18 | 19 | (() => { 20 | if (_.isArray(options.columns)) { 21 | let cols = {}; 22 | options.columns.forEach((column) => { 23 | cols[column.key] = column; 24 | }); 25 | options.columns = cols; 26 | } 27 | })(); 28 | 29 | (() => { 30 | if (!options.url && options.data) { 31 | _.each(options.columns, (col, k) => { 32 | col.toggleable = false; 33 | col.show_by_default = true; 34 | col.object_key = k; 35 | }); 36 | columns = options.columns; 37 | } 38 | })(); 39 | 40 | let toggleAll = false; 41 | let searchText = ''; 42 | let searchColumn; 43 | let toggledRows = {}; 44 | let activePagination = 10; 45 | let charts = []; 46 | let csvExport; 47 | let currentPage = 1; 48 | let data = options.data; 49 | let filters; 50 | let defaultValue = options.defaultValue; 51 | let chosenFilter = $stateParams.gridFilter; 52 | let metadata; 53 | let pagination = [10, 25, 50, 100]; 54 | let preferences; 55 | let processedRows; 56 | let rawColumns; 57 | let rawRows; 58 | let reqCount; 59 | let toggledColumns; 60 | let totalCount = 0; 61 | let totalPages = 0; 62 | let rowActions = []; 63 | let totalRowActions = 0; 64 | let chosenChartItem; 65 | let sortColumn; 66 | let sortDirection; 67 | 68 | let updatePreferences = function() { 69 | return Promise.resolve($http({ 70 | 'url': options.url, 71 | 'method': 'POST', 72 | 'params': { 73 | 'format': 'update_prefs' 74 | }, 75 | 'data': { 76 | 'prefs': preferences 77 | } 78 | })) 79 | .then(this.init.bind(this)); 80 | }.bind(this); 81 | 82 | Object.defineProperties(this, { 83 | 84 | 'init': { 85 | 'value': () => { 86 | 87 | reqCount = 0; 88 | searchText = ''; 89 | searchColumn = null; 90 | toggleAll = false; 91 | 92 | return this.request({}); 93 | 94 | } 95 | }, 96 | 97 | 'request': { 98 | 'value': function({ params, data }) { 99 | 100 | if (options.url) { 101 | return this.requestURL({ 'params': params, 'data': data }); 102 | } else { 103 | return this.requestData({ 'params': params, 'data': data }); 104 | } 105 | 106 | } 107 | }, 108 | 109 | 'requestURL': { 110 | 'value': function({ params, data }) { 111 | 112 | params = params || {}; 113 | params.count = _.get(preferences, 'pagination'); 114 | params.req = reqCount; 115 | params.filter = chosenFilter; 116 | params.search = searchText; 117 | params['search-column'] = _.get(searchColumn, 'object_key'); 118 | params['sort-column'] = sortColumn; 119 | params['sort-dir'] = sortDirection; 120 | if (_.get(searchColumn, 'column')) { 121 | params['search-column'] = _.get(searchColumn, 'column'); 122 | } 123 | 124 | return Promise.resolve($http({ 125 | 'url': options.url, 126 | 'method': 'POST', 127 | 'params': params, 128 | 'data': data, 129 | 'transformResponse': [ 130 | (data) => { 131 | if (params.format === 'csv') { 132 | return { 133 | 'csv': data 134 | }; 135 | } else { 136 | return JSON.parse(data); 137 | } 138 | } 139 | ] 140 | })) 141 | .tap((res) => { 142 | 143 | if (res.data.csv) { 144 | return saveFile('data.csv', res.data.csv); 145 | } 146 | 147 | reqCount++; 148 | toggledRows = {}; 149 | 150 | if (_.isArray(res.data)) { 151 | 152 | this.enablePagination = false; 153 | this.enableColumnToggling = false; 154 | this.enableFilters = false; 155 | this.enableSearching = false; 156 | 157 | if (!columns) { 158 | _.each(options.columns, (col, k) => { 159 | col.toggleable = false; 160 | col.show_by_default = true; 161 | col.object_key = k; 162 | }); 163 | columns = options.columns; 164 | } 165 | 166 | rawRows = res.data; 167 | this.processColumns(); 168 | this.processRows(rawRows); 169 | 170 | } else { 171 | 172 | this.enablePagination = true; 173 | this.enableColumnToggling = true; 174 | this.enableFilters = true; 175 | this.enableSearching = true; 176 | 177 | activePagination = res.data.metadata.activePagination; 178 | 179 | if (!_.isUndefined(res.data.metadata.preferences)) { 180 | preferences = res.data.metadata.preferences; 181 | } 182 | if (!_.isUndefined(res.data.metadata.columns)) { 183 | columns = _(res.data.metadata.columns) 184 | .map((column, k) => { 185 | column.object_key = k; 186 | return column; 187 | }) 188 | .value(); 189 | } 190 | 191 | if (!_.isUndefined(res.data.metadata.charts)) { 192 | 193 | charts = res.data.metadata.charts.map((chart) => { 194 | let newChart = { 195 | 'id': chart.id, 196 | 'heading': chart.label, 197 | 'data': chart, 198 | 'filter': chart.filter, 199 | 'onClick': (e) => { 200 | return this.onChartClick(chart, e); 201 | } 202 | }; 203 | return newChart; 204 | }); 205 | } 206 | 207 | if (!_.isUndefined(res.data.metadata.csvExport)) { 208 | csvExport = res.data.metadata.csv_export; 209 | } 210 | if (!_.isUndefined(res.data.metadata.filters)) { 211 | filters = _.groupBy(res.data.metadata.filters, 'group'); 212 | } 213 | if (!_.isUndefined(res.data.metadata.pagination)) { 214 | pagination = res.data.metadata.pagination; 215 | } 216 | if (!_.isUndefined(res.data.metadata.totalCount)) { 217 | totalCount = res.data.metadata.totalCount; 218 | } 219 | if (!_.isUndefined(res.data.metadata.totalPages)) { 220 | totalPages = res.data.metadata.totalPages; 221 | } 222 | 223 | rawRows = res.data.rows; 224 | 225 | this.processColumns(); 226 | this.processRows(rawRows); 227 | 228 | searchColumn = searchColumn || columns[0]; 229 | 230 | let sc; 231 | if (!sortColumn) { 232 | sc = _.find(columns, { 233 | 'default_sort': true 234 | }); 235 | if (!sc) { 236 | sc = _.find(columns, { 237 | 'primary': true 238 | }); 239 | } 240 | sortColumn = _.get(sc, 'object_key'); 241 | sortDirection = 'ASC'; 242 | } 243 | 244 | } 245 | 246 | }); 247 | 248 | } 249 | }, 250 | 251 | 'requestData': { 252 | 'value': function({ params, data }) { 253 | 254 | return Promise.resolve() 255 | .then(() => { 256 | 257 | params = params || {}; 258 | _.defaults(params, { 259 | 'count': activePagination 260 | }); 261 | 262 | if (_.isUndefined(params.page)) { 263 | params.page = currentPage - 1; 264 | } 265 | 266 | let sliceStart = params.page * params.count; 267 | let sliceEnd = sliceStart + params.count; 268 | const sliced = options.data.slice(sliceStart, sliceEnd); 269 | rawRows = _.cloneDeep(sliced); 270 | totalCount = options.data.length; 271 | totalPages = Math.ceil(totalCount / params.count); 272 | this.processColumns(); 273 | this.processRows(rawRows); 274 | 275 | }); 276 | 277 | } 278 | }, 279 | 280 | 'rows': { 281 | 'get': () => { 282 | return processedRows; 283 | } 284 | }, 285 | 286 | 'totalPages': { 287 | 'get': () => { 288 | return totalPages; 289 | } 290 | }, 291 | 292 | 'pagination': { 293 | 'get': () => { 294 | return pagination; 295 | } 296 | }, 297 | 298 | 'currentPage': { 299 | 'get': () => { 300 | return currentPage; 301 | }, 302 | 'set': (page) => { 303 | return this.request({ 304 | 'params': { 305 | 'count': activePagination, 306 | 'page': page - 1 307 | } 308 | }) 309 | .tap(() => { 310 | toggleAll = false; 311 | currentPage = page; 312 | }); 313 | } 314 | }, 315 | 316 | 'activePagination': { 317 | 'get': () => { 318 | return activePagination; 319 | } 320 | }, 321 | 322 | 'heading': { 323 | 'get': () => { 324 | return options.heading; 325 | } 326 | }, 327 | 328 | 'charts': { 329 | 'get': () => { 330 | return charts; 331 | } 332 | }, 333 | 334 | 'onChartClick': { 335 | 'value': (chart, e) => { 336 | 337 | switch (chart.type) { 338 | case 'pie': 339 | chosenChartItem = { 340 | 'label': e.dataItem.title, 341 | 'id': chart.id, 342 | 'type': chart.type 343 | }; 344 | this.init(); 345 | break; 346 | case 'stacked_column': 347 | chosenChartItem = { 348 | 'label': e.target.title, 349 | 'category': e.item.category, 350 | 'id': chart.id, 351 | 'type': chart.type 352 | }; 353 | this.init(); 354 | break; 355 | case 'geo': 356 | // Set the search value to e.mapObject.search_value 357 | // instead of setting a chosenChartItem 358 | 359 | // switch (e.type) { 360 | 361 | // case 'homeButtonClicked': 362 | 363 | // $timeout(() => { 364 | // $scope.setSearchValue(''); 365 | // $scope.chosenChartItem = null; 366 | // }); 367 | 368 | // break; 369 | 370 | // case 'clickMapObject': 371 | 372 | // if (!e.mapObject.search_column || !e.mapObject.search_value) return; 373 | 374 | // $timeout(() => { 375 | // $scope.setSearchColumn(e.mapObject.search_column); 376 | // $scope.setSearchValue(e.mapObject.search_value); 377 | // $scope.chosenChartItem = null; 378 | // }); 379 | 380 | // break; 381 | 382 | // } 383 | 384 | break; 385 | default: 386 | chosenChartItem = null; 387 | break; 388 | } 389 | 390 | } 391 | }, 392 | 393 | 'generateRowActions': { 394 | 'value': () => { 395 | totalRowActions = 0; 396 | rowActions = rawRows.map((row) => { 397 | if (!_.isFunction(options.row_actions)) { 398 | return undefined; 399 | } 400 | let entry = { 401 | 'icon': `glyphicon glyphicon-cog`, 402 | 'size': 'small', 403 | 'links': filterEnabled(options.row_actions(row)) 404 | }; 405 | totalRowActions += entry.links.length; 406 | return entry; 407 | }); 408 | } 409 | }, 410 | 411 | 'preferences': { 412 | 'get': () => { 413 | return _.cloneDeep(preferences); 414 | } 415 | }, 416 | 417 | 'totalCount': { 418 | 'get': () => { 419 | return totalCount; 420 | } 421 | }, 422 | 423 | 'filters': { 424 | 'get': () => { 425 | return filters; 426 | } 427 | }, 428 | 429 | 'index': { 430 | 'get': () => { 431 | return (currentPage - 1) * activePagination; 432 | } 433 | }, 434 | 435 | 'enableMultiselect': { 436 | 'get': () => { 437 | return options.enable_multiselect; 438 | } 439 | }, 440 | 441 | 'toggledRows': { 442 | 'get': () => { 443 | return toggledRows; 444 | } 445 | }, 446 | 447 | 'toggledColumns': { 448 | 'get': () => { 449 | return toggledColumns; 450 | } 451 | }, 452 | 453 | 'setFilter': { 454 | 'value': (filter) => { 455 | const stateParams = _.cloneDeep($stateParams); 456 | if (filter) { 457 | chosenFilter = filter.id; 458 | stateParams.gridFilter = chosenFilter; 459 | } else { 460 | if (stateParams.gridFilter) delete stateParams.gridFilter; 461 | chosenFilter = null; 462 | } 463 | $state.transitionTo($state.current.name, stateParams, { 464 | 'notify': false 465 | }); 466 | return this.init(); 467 | } 468 | }, 469 | 470 | 'setPagination': { 471 | 'value': (value) => { 472 | preferences.pagination = value; 473 | return updatePreferences(); 474 | } 475 | }, 476 | 477 | 'toggleColumn': { 478 | 'value': (key) => { 479 | let column = preferences.columns[key]; 480 | if (!column) throw new Error(`Unknown column: ${key}`); 481 | column.toggled = !column.toggled; 482 | return updatePreferences(); 483 | } 484 | }, 485 | 486 | 'chosenFilter': { 487 | 'get': () => { 488 | return this.getFilterById(chosenFilter); 489 | } 490 | }, 491 | 492 | 'toggleableColumns': { 493 | 'get': () => { 494 | return _(columns) 495 | .filter((column) => { 496 | return column.toggleable; 497 | }) 498 | .value(); 499 | } 500 | }, 501 | 502 | 'getFilterById': { 503 | 'value': (id) => { 504 | let res; 505 | for (let group in filters) { 506 | res = _.find(filters[group], { 'id': id }); 507 | if (res) break; 508 | } 509 | return res; 510 | } 511 | }, 512 | 513 | 'showCharts': { 514 | 'get': () => { 515 | return charts.length > 0; 516 | } 517 | }, 518 | 519 | 'processColumns': { 520 | 'value': () => { 521 | toggledColumns = _(columns) 522 | .filter((column) => { 523 | if (column.toggleable) { 524 | return _.get(preferences, ['columns', column.object_key, 'toggled']); 525 | } else { 526 | return column.show_by_default; 527 | } 528 | }) 529 | .compact() 530 | .value(); 531 | } 532 | }, 533 | 534 | 'columns': { 535 | 'get': () => { 536 | return columns; 537 | } 538 | }, 539 | 540 | 'processRows': { 541 | 'value': (rows) => { 542 | processedRows = rows.map((row) => { 543 | row.gridValues = {}; 544 | toggledColumns.forEach((column) => { 545 | let contentFn = _.get(options.columns, [column.object_key, 'contentFn']); 546 | if (contentFn) { 547 | row.gridValues[column.object_key] = contentFn(row); 548 | } else if (defaultValue) { 549 | row.gridValues[column.object_key] = row[column.object_key] || defaultValue; 550 | } else { 551 | row.gridValues[column.object_key] = row[column.object_key]; 552 | } 553 | let linkFn = _.get(options.columns, [column.object_key, 'linkFn']); 554 | if (linkFn) { 555 | let link = linkFn(row); 556 | if (link) { 557 | row.gridValues[column.object_key] = `${row.gridValues[column.object_key]}`; 558 | } 559 | } 560 | }); 561 | return row; 562 | }); 563 | this.generateRowActions(); 564 | } 565 | }, 566 | 567 | 'onToggleRow': { 568 | 'value': (row) => { 569 | } 570 | }, 571 | 572 | 'toggleAllRows': { 573 | 'value': () => { 574 | toggledRows = {}; 575 | rawRows.forEach((row, k) => { 576 | toggledRows[k] = true; 577 | }); 578 | } 579 | }, 580 | 581 | 'untoggleAllRows': { 582 | 'value': () => { 583 | toggledRows = {}; 584 | } 585 | }, 586 | 587 | 'getToggledRows': { 588 | 'value': () => { 589 | return _.cloneDeep(rawRows.filter((row, k) => { 590 | return toggledRows[k]; 591 | })); 592 | } 593 | }, 594 | 595 | 'toggleAll': { 596 | 'get': () => { 597 | return toggleAll; 598 | }, 599 | 'set': (value) => { 600 | toggleAll = value; 601 | return value ? this.toggleAllRows() : this.untoggleAllRows(); 602 | } 603 | }, 604 | 605 | 'rowActions': { 606 | 'get': () => { 607 | return options.row_actions; 608 | } 609 | }, 610 | 611 | 'rowActionOptions': { 612 | 'value': (row) => { 613 | return rowActions[rawRows.indexOf(row)]; 614 | } 615 | }, 616 | 617 | 'searchText': { 618 | 'get': () => { 619 | return searchText; 620 | }, 621 | 'set': (value) => { 622 | searchText = value; 623 | } 624 | }, 625 | 626 | 'searchColumn': { 627 | 'get': () => { 628 | return searchColumn; 629 | }, 630 | 'set': (value) => { 631 | searchColumn = value; 632 | } 633 | }, 634 | 635 | 'search': { 636 | 'value': () => { 637 | return this.request({}); 638 | } 639 | }, 640 | 641 | 'exportCSV': { 642 | 'value': () => { 643 | return this.request({ 644 | 'params': { 645 | 'format': 'csv' 646 | } 647 | }); 648 | } 649 | }, 650 | 651 | 'colSpan': { 652 | 'get': () => { 653 | let span = toggledColumns ? toggledColumns.length : 0; 654 | if (this.enableMultiselect) span++; 655 | if (totalRowActions > 0) span++; 656 | return span; 657 | } 658 | }, 659 | 660 | 'totalRowActions': { 661 | 'get': () => { 662 | return totalRowActions; 663 | } 664 | }, 665 | 666 | 'enableFilters': { 667 | 'get': () => { 668 | return this._enableFilters; 669 | }, 670 | 'set': (enable) => { 671 | return this._enableFilters = enable; 672 | } 673 | }, 674 | 675 | 'enablePagination': { 676 | 'get': () => { 677 | return this._enablePagination; 678 | // return options.url ? true : false; 679 | }, 680 | 'set': (enablePagination) => { 681 | return this._enablePagination = enablePagination; 682 | } 683 | }, 684 | 685 | 'enableColumnToggling': { 686 | 'get': () => { 687 | return this._enableColumnToggling; 688 | }, 689 | 'set': (enable) => { 690 | return this._enableColumnToggling = enable; 691 | } 692 | }, 693 | 694 | 'enableSearching': { 695 | 'get': () => { 696 | return this._enableSearching; 697 | }, 698 | 'set': (enable) => { 699 | return this._enableSearching = enable; 700 | } 701 | }, 702 | 703 | 'sortColumn': { 704 | 'get': () => { 705 | return sortColumn; 706 | } 707 | }, 708 | 709 | 'sortDirection': { 710 | 'get': () => { 711 | return sortDirection; 712 | } 713 | }, 714 | 715 | 'sort': { 716 | 'value': (objectKey) => { 717 | 718 | let col = _.find(columns, { 719 | 'object_key': objectKey 720 | }); 721 | 722 | if (!col.sortable) { 723 | return; 724 | } 725 | 726 | if (sortColumn === objectKey) { 727 | sortDirection = sortDirection === 'ASC' ? 'DESC' : 'ASC'; 728 | } else { 729 | sortColumn = objectKey; 730 | sortDirection = 'ASC'; 731 | } 732 | 733 | this.init(); 734 | 735 | 736 | } 737 | } 738 | 739 | }); 740 | 741 | } 742 | 743 | } 744 | 745 | return UIGrid; 746 | 747 | }); --------------------------------------------------------------------------------