ui/controllers/running_jobs_controller.js

const $ = require('jquery');
const { BaseController } = require('./base_controller');
const { Constants } = require('../../core/constants');
const { Context } = require('../../core/context');
const { Job } = require('../../core/job');
const Templates = require('../common/templates');
const { UIConstants } = require('../common/ui_constants');
const { Util } = require('../../core/util');

/**
 * This controller is never instantiated or called directly.
 * Both the {@link JobRunController} and the {@link DashboardController}
 * inherit from this class, which contains common methods for displaying
 * info about running jobs.
 *
 */
class RunningJobsController extends BaseController {

    constructor(params, navSection) {
        super(params, navSection)
        this.completedUploads = [];
    }

    initRunningJobDisplay(dartProcess) {
        // Clear this on each init
        this.completedUploads = [];
        let job = Job.find(dartProcess.jobId);

        this.showDivs(job, dartProcess);
        if ($('#output-path-link').length) {
            $('#output-path-link').removeClass('local-file')
        }
        let controller = this;

        // If user moves from JobRunController to DashboardController
        // (both of which derive from this class), we don't want
        // listeners to be attached twice. Re-adding the listeners
        // causes them to render content in elements on the current
        // page.
        dartProcess.process.removeAllListeners('message');
        dartProcess.process.removeAllListeners('exit');

        dartProcess.process.on('message', (data) => {
            controller.renderChildProcOutput(data, dartProcess);
        });

        dartProcess.process.on('exit', (code, signal) => {
            Context.logger.info(`Process ${dartProcess.process.pid} exited with code ${code}, signal ${signal}`);
            delete Context.childProcesses[dartProcess.id];
            controller.renderOutcome(dartProcess, code);
        });
    }

    showDivs(job, dartProcess) {
        let processDiv = $('#dartProcessContainer');

        // If we're in the Jobs section, this is the job_run_controller
        // and we need to add some HTML to the page to display job status
        // because the user has just clicked "Run Job".
        //
        // Otherwise, we're in the dashboard, and the HTML we need was
        // already rendered when the page loaded.
        if (this.navSection == 'Jobs') {
            let html = Templates.partials['dartProcess']({ item: dartProcess });
            processDiv.html(html);
        }
        if (job.packageOp && job.packageOp.outputPath) {
            this.initProgressBar(dartProcess, 'packageInfo');
            $(`#${dartProcess.id} div.packageInfo`).show();
            if (job.packageOp.packageFormat == 'BagIt') {
                this.initProgressBar(dartProcess, 'validationInfo');
                $(`#${dartProcess.id} div.validationInfo`).show();
            }
        }
        if (job.uploadOps.length > 0) {
            this.initProgressBar(dartProcess, 'uploadInfo');
            $(`#${dartProcess.id} div.uploadInfo`).show();
        }
        processDiv.show();
    }

    renderChildProcOutput(data, dartProcess) {
        switch (data.op) {
        case 'package':
            this.renderPackageInfo(data, dartProcess);
            break;
        case 'validate':
            this.renderValidationInfo(data, dartProcess);
            break;
        case 'upload':
            this.renderUploadInfo(data, dartProcess);
            break;
        default:
            return;
        }
    }

    renderPackageInfo(data, dartProcess) {
        let [detailDiv, progressBar] = this.getDivs(dartProcess, 'packageInfo');
        if (data.action == 'fileAdded') {
            let shortName = Util.trimToLength(data.msg, 80, 'middle');
            detailDiv.text(Context.y18n.__('Added file %s', shortName));
            this.setProgressBar(progressBar, data);
        } else if (data.action == 'completed') {
            if (data.status == Constants.OP_SUCCEEDED) {
                this.markSuccess(detailDiv, progressBar, data.msg);
                if ($('#output-path-link').length) {
                    $('#output-path-link').addClass('local-file')
                }
            } else {
                this.markFailed(detailDiv, progressBar, data.msg);
            }
        } else {
            detailDiv.text(data.msg);
        }
    }

    renderValidationInfo(data, dartProcess) {
        let [detailDiv, progressBar] = this.getDivs(dartProcess, 'validationInfo');
        if (data.action == 'checksum') {
            let shortName = Util.trimToLength(data.msg, 80, 'middle');
            detailDiv.text(Context.y18n.__('Validating %s', shortName));
            this.setProgressBar(progressBar, data);
        } else if (data.action == 'completed') {
            if (data.status == Constants.OP_SUCCEEDED) {
                this.markSuccess(detailDiv, progressBar, data.msg);
            } else {
                this.markFailed(detailDiv, progressBar, data.msg);
            }
        }
    }

    renderUploadInfo(data, dartProcess) {
        let [detailDiv, progressBar] = this.getDivs(dartProcess, 'uploadInfo');
        if (data.action == 'start') {
            detailDiv.text(data.msg);
            // We can have multiple uploads, so we re-initialize the
            // progress bar each time we get a start event.
            this.initProgressBar(dartProcess, 'uploadInfo');
        } else if (data.action == 'completed') {
            if (data.status == Constants.OP_SUCCEEDED) {
                this.completedUploads.push(data.msg);
                this.markSuccess(detailDiv, progressBar, this.completedUploads.join("<br/>\n"));
            } else {
                this.markFailed(detailDiv, progressBar, data.msg);
            }
        } else if (data.action == 'status'){
            detailDiv.text(data.msg);
            this.setProgressBar(progressBar, data);
        }
    }

    // TODO: This has a problem. If we're doing a series of uploads and one
    // fails and it's not the LAST one, this reports a successful outcome.
    // It should report an error.
    renderOutcome(dartProcess, code) {
        // We have to reload this, because the child process updated
        // the job's record in the database.
        let job = Job.find(dartProcess.jobId);
        let [detailDiv, progressBar] = this.getDivs(dartProcess, 'outcome');
        if (code == 0) {
            if (job.packageOp) {
                this.ensureValidationMarkedComplete(dartProcess)
            }
            this.markSuccess(detailDiv, progressBar, Context.y18n.__('Job completed successfully.'));
        } else {
            let msg = Context.y18n.__('Job did not complete due to errors.')
            Context.logger.error(msg);
            this.logFailedOps(job);
            msg += `<br/>${job.getRunErrors().join("<br/>")}`
            this.markFailed(detailDiv, progressBar, msg.replace(/\n/g, '<br/>'));
        }
        // Button exists on job "Review and Run" page, not dashboard.
        if($('#btnRunJob').length) {
            $('#btnRunJob').prop('disabled', false);
        }
    }

    ensureValidationMarkedComplete(dartProcess) {
        // For some bags with thousands of files, validation isn't
        // marked complete after validation succeeds. This happens
        // occasionally, not consistently. Race condition between
        // process exit and event firing?
        this.renderValidationInfo({action: 'completed', status: Constants.OP_SUCCEEDED, msg: Context.y18n.__('Bag is valid')}, dartProcess);
    }

    getDivs(dartProcess, section) {
        let selectorPrefix = `#${dartProcess.id} div.${section} div.detail`;
        let detailDiv = $(`${selectorPrefix} div.message`);
        let progressBar = $(`${selectorPrefix} div.progress-bar`);
        return [detailDiv, progressBar];
    }

    logFailedOps(job) {
        if (job.packageOp) {
            Context.logger.error(JSON.stringify(job.packageOp));
        }
        if (job.validationOp) {
            Context.logger.error(JSON.stringify(job.validationOp));
        }
        if (job.uploadOps && job.uploadOps.length > 0) {
            Context.logger.error(JSON.stringify(job.uploadOps));
        }
    }

    initProgressBar(dartProcess, section) {
        let [_, progressBar] = this.getDivs(dartProcess, section);
        if (progressBar) {
            progressBar.removeClass("bg-success");
            progressBar.removeClass("bg-danger");
            let initialClasses = ["progress-bar-striped", "progress-bar-animated"];
            for (let cssClass of initialClasses) {
                if (progressBar.hasClass(cssClass)) {
                    progressBar.addClass(cssClass);
                }
            }
        }
    }

    setProgressBar(progressBar, data) {
        // Sometimes the data object does not include percentComplete,
        // and when percentComplete is unknown, it's set to -1.
        if (isNaN(data.percentComplete) || data.percentComplete < 0) {
            return;
        }
        // In bag creation, progress bar hits 100% when all payload
        // files are added. We have to add tag files and manifests
        // afterward, and we don't want the bar to bounce, so no
        // changes after it reaches 100%.
        //
        // Similarly with validation, the bar hits 100% when all checksums
        // have been verified, but the validator still has a few remaining
        // tasks, including tag file validation.
        //
        // In both cases, the bar will stick at 100% for a second or two.
        // When all items are complete, the bar animation will stop and
        // the bar will turn green.
        if (parseInt(progressBar.attr("aria-valuenow"), 10) != 100) {
            progressBar.attr("aria-valuenow", data.percentComplete);
            progressBar.css("width", data.percentComplete + '%');
        }
    }

    markSuccess(detailDiv, progressBar, message) {
        detailDiv.html(message);
        if (progressBar) {
            this.setProgressBar(progressBar, { percentComplete: 100 });
            progressBar.removeClass("progress-bar-striped progress-bar-animated");
            progressBar.addClass("bg-success");
        }
    }

    markFailed(detailDiv, progressBar, message) {
        detailDiv.html(message);
        if (progressBar) {
            progressBar.removeClass("progress-bar-striped progress-bar-animated");
            progressBar.attr("aria-valuenow", 100);
            progressBar.css("width", '100%');
            progressBar.addClass("bg-danger");
        }
    }

}

module.exports.RunningJobsController = RunningJobsController;