AWS Batch Upgrades

Calculate AWS Batch job time since started and total execution time (on the "Job attempts" tab). Set the tab title to include the job name and execution status. Fix the bug where you logout and lose the job you had open. Add links to log streams, with commands to easily download them locally.

Tendrás que instalar una extensión para tu navegador como Tampermonkey, Greasemonkey o Violentmonkey si quieres utilizar este script.

You will need to install an extension such as Tampermonkey to install this script.

Tendrás que instalar una extensión como Tampermonkey o Violentmonkey para instalar este script.

Necesitarás instalar una extensión como Tampermonkey o Userscripts para instalar este script.

Tendrás que instalar una extensión como Tampermonkey antes de poder instalar este script.

Necesitarás instalar una extensión para administrar scripts de usuario si quieres instalar este script.

(Ya tengo un administrador de scripts de usuario, déjame instalarlo)

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

(Ya tengo un administrador de estilos de usuario, déjame instalarlo)

// ==UserScript==
// @name         AWS Batch Upgrades
// @namespace    https://gist.github.com/DeflateAwning/d8d42a082cb27b7d01df751d0dc26f31
// @version      0.4.1
// @description  Calculate AWS Batch job time since started and total execution time (on the "Job attempts" tab). Set the tab title to include the job name and execution status. Fix the bug where you logout and lose the job you had open. Add links to log streams, with commands to easily download them locally.
// @author       DeflateAwning
// @match        https://*.console.aws.amazon.com/batch/home?*
// @grant        GM_addStyle
// @license      MIT
// ==/UserScript==

// Instructions to Install:
//    1. On the GitHub Gist (link above), click the "Raw" button.
//    2. Tamper Monkey will prompt you to install the "user script". Click Install.

(function() {
    'use strict';

    // Add global CSS styles for the code block
    GM_addStyle(`
        .cloudwatch-code-block {
            background-color: #f5f5f5;
            color: #333;
            font-family: monospace;
            font-size: 14px;
            padding: 10px;
            border: 1px solid #ddd;
            border-radius: 4px;
            overflow-x: auto;
            box-shadow: 0px 1px 3px rgba(0, 0, 0, 0.2);
            display: inline-flex;
            white-space: pre-wrap;
            margin-top: 5px;
        }

        .no-margin {
            margin: 0;
        }
        .no-padding {
            padding: 0;
        }
    `);

    const PRERUN_STATUS_VALUES = ['Runnable', 'Submitted', 'Starting', 'Ready'];

    function calculateTimeDifference(startedAt, stoppedAt) {
        if (!startedAt || !stoppedAt) {
            return "N/A";
        }

        const timeDifference = stoppedAt.getTime() - startedAt.getTime();

        const hours = Math.floor(timeDifference / (1000 * 60 * 60));
        const minutes = Math.floor((timeDifference % (1000 * 60 * 60)) / (1000 * 60));
        const seconds = Math.floor((timeDifference % (1000 * 60)) / 1000);

        return `${hours}h ${minutes}m ${seconds}s`;
    }


    // Function to create and append the result field.
    function appendExecutionTimeField() {

        // Get the "Started at" and "Stopped at" elements.
        const startedAtElement = document.querySelector('[data-test-id="startedAt"]').querySelector('div:last-child');
        const stoppedAtElement = document.querySelector('[data-test-id="stoppedAt"]').querySelector('div:last-child');

        // Extract the text content from the elements.
        const startedAtText = startedAtElement.textContent.trim();
        const stoppedAtText = stoppedAtElement.textContent.trim();

        // Create date objects.
        const startedAtDate = new Date(startedAtText);
        const stoppedAtDate = new Date(stoppedAtText);

        const timeSinceStarted = calculateTimeDifference(startedAtDate, new Date());
        const totalExecutionTime = calculateTimeDifference(startedAtDate, stoppedAtDate);

        // Remove existing "tamper-time-summary" objects.
        document.querySelectorAll('.tamper-time-summary').forEach(e => e.remove());

        const newElementHTML = `
        <div style="color: rgb(139,0,0);" class="tamper-time-summary">
                <div>
                        <span style="color: #545b64;">Time Since Start:</span>
                        ${timeSinceStarted}
                </div>
                <div>
                        <span style="color: #545b64;">Total Execution Time:</span>
                        ${totalExecutionTime}
                </div>
                <div style="color: #545b64;">Note: Times are in local time.</div>
        </div>
        `;

        // Find the target element by its data-test-id.
        const targetElement = document.querySelector('[data-test-id="stoppedAt"]');

        // Append newElementHTML right after targetElement.
        targetElement.insertAdjacentHTML('afterend', newElementHTML);

    }

    function getJobName() {
        const jobNameElement = document.querySelector('span[data-analytics-funnel-key="funnel-name"]');

        if (jobNameElement) {
            let jobName = jobNameElement.textContent.trim();
            return jobName;
        }
    }

    function getJobStatus() {
        const statusElement = document.querySelector('div[data-analytics="baseJobDetail"]'); // Locate the main container
        if (statusElement) {
            const label = Array.from(statusElement.querySelectorAll('div'))
            .find(div => div.textContent.trim() === "Status"); // Find the label "Status"
            if (label) {
                const statusText = label.nextElementSibling?.textContent.trim(); // Get the text of the next sibling
                console.log(statusText); // Should output "Failed"
                return statusText;
            } else {
                console.log("Status label not found");
            }
        } else {
            console.log("Base job detail element not found");
        }
    }

    function checkAndUpdatePageTitle() {
        var currentUrl = window.location.href;

        // Check if the URL ends with "#jobs" and contains "/batch/home"
        if (currentUrl.endsWith("#jobs") && currentUrl.includes("/batch/home")) {
            // Set the page title
            document.title = "JOB LIST | AWS Batch";
            // console.log("Set job title.");
        }
        else if (currentUrl.includes("/batch/home") && currentUrl.includes("#jobs/fargate/detail/")) {
            let job_name = getJobName();
            let job_status = getJobStatus();
            let new_title = '';

            if (job_status == 'Running') {
                new_title += '🕙';
            }
            else if (job_status == 'Success' || job_status == 'Succeeded') {
                new_title += '✅';
            }
            else if (job_status == 'Failed') {
                new_title += '❌';
            }
            else if (PRERUN_STATUS_VALUES.includes(job_status)) { // like 'Starting'
                new_title += '🏁';
            }
            else if (job_status) {
                new_title += '🤷'; // non-null but unknown
            }
            else {
                new_title += '🫙'; // null jar
            }

            new_title += ' | ';

            if (job_name) {
                new_title += job_name;
            }
            else {
                new_title += 'JOB';
                //console.log("getJobName() returned empty job name.");
            }

            new_title += ' | BATCH';

            document.title = new_title;
        }
    }

    function getAwsRegionFromCurrentUrl() {
        // Returns the region (like "us-east-1") as a string.

        // If there are issues, consider using this method instead: https://gist.github.com/rams3sh/4858d5150acba5383dd697fda54dda2c

        const regionMatch = window.location.href.match(/([a-z]{1,5}-[a-z]{2,20}-\d+)\.console\.aws\.amazon\.com/);
        const region = regionMatch ? regionMatch[1] : null;
        return region;
    }

	function fixBadLoginOnJobPage() {
		// Fix the bug where you logout and lose the job you had open.

		// Skip this part if we're not on that error page.
		// Return if the page title isn't "Unauthorized".
		if (document.title != "Unauthorized") {
			return;
		}

		// Return if the URL isn't >1000 chars.
		if (window.location.href.length <= 1000) {
			return;
		}

		// Get the job ID from the URL. Regex extract the only UUID4.
        // Example partial URL: https://us-east-1.console.aws.amazon.com/batch/home?hashArgs=%23jobs%2Ffargate%2Fdetail%2F90d176de-1550-4b94-8013-cb9c077a61cc&isauthcode=true
        // Extract the first matching group.
		const job_id_match = window.location.href.match(/batch.+hashArgs.+detail%2[Ff]([a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12})/);
        const job_id = job_id_match ? job_id_match[1] : null;

        let awsRegion = getAwsRegionFromCurrentUrl();

        // Step 1: Find the <h1> tag
        const h1Tag = document.querySelector('h1');

        // Step 2: Create a new <div> element
        const newDiv = document.createElement('div');

        const jobLinkUrl = `https://${awsRegion}.console.aws.amazon.com/batch/home?region=${awsRegion}#jobs/fargate/detail/${job_id}`;

        newDiv.innerHTML = `
        <p>
            <strong>Message from TamperMonkey script:</strong>
            AWS did the bug thing where you can't easily see the job you had open.
            First, <a href="https://${awsRegion}.console.aws.amazon.com/batch/home?region=${awsRegion}#jobs" target="_blank">sign in in a new tab</a>,
            then use this link to <a href="${jobLinkUrl}" target="_blank">go back to the job you had open (${job_id}).</a>

            <hr />
           <strong>Message from TamperMonkey script:</strong> If I were you, I wouldn't ever click the "sign in again" link below.
        </p>
        `;

        // Step 5: Insert the <div> element after the <h1> tag
        h1Tag.insertAdjacentElement('afterend', newDiv);
	}

    function extractAllCloudWatchLogLinks() {
        // Returns a list-of-dicts, with keys: ["logUrl", "logGroupName", "logStreamName"]
        const logEntries = [];
        const anchors = document.querySelectorAll("a[href]");

        anchors.forEach(anchor => {
            const url = anchor.href;

            // Match the AWS CloudWatch log URL pattern
            const regex = /https:\/\/(.+)\.console\.aws\.amazon\.com\/cloudwatch\/home\?region=(.+)#logEventViewer:group=([^;]+);stream=([^;]+)/;
            const match = url.match(regex);

            if (match) {
                const logGroupName = decodeURIComponent(match[3]);
                const logStreamName = decodeURIComponent(match[4]);

                if (logEntries.some(entry => entry.logUrl === url)) {
                    // This url is duplicate. Continue.
                    return;
                }

                logEntries.push({
                    logUrl: url,
                    logGroupName: logGroupName,
                    logStreamName: logStreamName
                });
            }
        });

        return logEntries;
    }


    function addCloudWatchLogQueryCommands() {
        // Remove any existing elements with class "cloudwatch-log-summary" to prevent duplicates.
        // Must be done before the extractAllCloudWatchLogLinks() function call, or it positive feedback loops.
        document.querySelectorAll('.cloudwatch-log-summary').forEach(e => e.remove());

        const logLinks = extractAllCloudWatchLogLinks();

        // Return early if no log links were found
        if (logLinks.length === 0) {
            console.warn("No CloudWatch log links found.");
            return;
        }

        // Find the target element by its data-test-id
        const targetElement = document.querySelector('[data-test-id="jobId"]');

        // Return early if target element is not found
        if (!targetElement) {
            console.warn("Target element with data-test-id='jobId' not found.");
            return;
        }

        // Start creating an ordered list for log entries
        const logListHTML = `
            <div class="cloudwatch-log-summary" style="margin-top: 10px;">
                <p style="color: #545b64;" class="no-margin no-padding">Log Streams</p>
                <p class="no-margin no-padding"><a href="https://github.com/mikhail-m1/axe">Install cw-axe</a> to view logs locally.</p>
                ${logLinks.map(log => `
                    <div class="no-margin no-padding">
                        <!-- Link icon to log URL -->
                        <a href="${log.logUrl}" target="_blank" style="color: #1f77b4; margin-right: 8px;">
                            Log Stream Link 🔗
                        </a>

                        <!-- Code block for the CLI command -->
                        <div>
                            <pre class="cloudwatch-code-block">cw-axe log '${log.logGroupName}' '${log.logStreamName}' -s 1y</pre>
                        </div>
                    </div>
                `).join('')}
            </ol>
            </div>
        `;

        // Insert the generated HTML after the target element
        targetElement.insertAdjacentHTML('afterend', logListHTML);
    }



    function run_ignore_error() {
        try {
            appendExecutionTimeField();
        } catch (error) {
            console.error('appendExecutionTimeField: An error occurred:', error);
        }

        try {
            checkAndUpdatePageTitle();
        } catch (error) {
            console.error('checkAndUpdatePageTitle: An error occurred:', error);
        }
    }


    // Run every few seconds to keep time updated.
    setInterval(run_ignore_error, 2000);

    // Auto-reload the page if it says the job is Running.
    setInterval(
		function() {
			let job_status = getJobStatus();
			if (job_status == 'Running' || PRERUN_STATUS_VALUES.includes(job_status)) {
				location.reload();
			}
		},
		60 * 4 * 1000 // 4 minutes
	);

    // Run once at start, otherwise too many messages appear.
    try {
        fixBadLoginOnJobPage();
    } catch (error) {
        console.error('fixBadLoginOnJobPage: An error occurred:', error);
    }

    setInterval(
        function() {
            // Run once at start.
            try {
                addCloudWatchLogQueryCommands();
            } catch (error) {
                console.error('addCloudWatchLogQueryCommands: An error occurred:', error);
            }
        },
        3000 // 3 seconds after pageload.
    );

    // Debug run.
    // appendExecutionTimeField();

})();