Last active
June 3, 2025 17:39
-
-
Save DeflateAwning/d8d42a082cb27b7d01df751d0dc26f31 to your computer and use it in GitHub Desktop.
Tampermonkey script to improve AWS Batch: 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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ==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 | |
| // ==/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(); | |
| })(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment