├── 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 |
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 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
21 |
22 |
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 |
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 |
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 |
198 |
199 |
200 |
201 | -
202 | Actions
203 |
216 |
217 |
218 |
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 |
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 |
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 |
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 | });
--------------------------------------------------------------------------------