// Copyright Epic Games, Inc. All Rights Reserved. // boilerplate.js -- This script should be loaded for every Robomerge page to provide a consistent UI and dataset for each page, // as well as provide common, shared functions to our client-side Javascript. /******************** * COMMON FUNCTIONS * ********************/ // Global user information map let robomergeUser window.showNodeLastChanges = false window.allowNodeReconsiders = false // Common mutex variable around displaying error messages let clearer = null function clearErrorText() { const $err = $('#error') $err.addClass('_hidden'); if (!clearer) { clearer = setTimeout(() => { if (clearer) { $err.empty(); } }, 1000); } } function setErrorText(text) { if (clearer) { clearTimeout(clearer); clearer = null; } $('#error').text(text).removeClass('_hidden'); } function setError(xhr, error) { setErrorText(createErrorMessage(xhr, error)) } function createErrorMessage(xhr, error) { if (xhr.responseText) return `${xhr.status ? xhr.status + ": " : ""}${xhr.responseText}` else if (xhr.status == 0) { document.title = "ROBOMERGE (error)"; return "Connection error"; } else return `HTTP ${xhr.status}: ${error}`; } function promptFor(map) { var result = {}; var success = true; $.each(Object.keys(map), function(_, key) { if (success) { var p = map[key]; var dflt = ""; if (typeof(p) === "object") { dflt = p.default || ""; p = p.prompt || key; } var data = prompt(p, dflt); if (data === null) { success = false; } else { result[key] = data; } } }); return success ? result : null; } // Add way to prompt the user on leave/reload before the perform an action function addOnBeforeUnload(message) { $( window ).on("beforeunload", function() { return message }) } function removeOnBeforeUnload() { $( window ).off("beforeunload") } function makeClLink(cl, swarmURL, alias) { if (typeof(cl) !== "number") { return `${cl}` } if (swarmURL) { return `${alias || cl}` } else { return `${alias || cl}` } } function makeSwarmFileLink(depotPath, swarmURL, alias) { if (swarmURL) { return `${alias ? alias : depotPath}` } else { return `${alias ? alias : depotPath}` } } function debug(message) { log('DEBUG: ' + message) } function error(message) { log('ERROR!: ' + message + '\n' + (new Error()).stack) } function log(message) { const now = new Date(Date.now()) console.log( now.toLocaleString(undefined, {dateStyle: 'short', timeStyle: 'medium', hour12: false}) + "." + String(now.getMilliseconds()).padStart(3, '0') + ": " + message ) } // Reload the branchlist div let updateBranchesPending = false; function updateBranchList(graphBotName=null) { if (updateBranchesPending) return; updateBranchesPending = true; let expandedDivIds = [] $('#branchList .elist-row>.collapse.show').each( (_index, div) => { expandedDivIds.push(div.id) }) // Keep the height value pre-refresh const beginningBLHeight = $('#branchList').height() // Capture the current tab (if any) so we can swap back to it post-refresh if it still exists const currentBotNavLinkId = $('.nav-item.nav-item-active>a.nav-link.botlink.active.show').attr("id") // Prevent window resizing and scrolling $('#branchList').css('min-height', beginningBLHeight + 'px') getBranchList(function(data) { if (data && data.started) { preprocessData(data) // clear 'error' or 'stopped' from title if present document.title = 'ROBOMERGE'; $('div#currentlyRunning').empty() let newBranchList = $('
').append(renderBranchList(data)) expandedDivIds.forEach( (divId) => { const escapedDivId = divId.replace(/\./g, '\\.') // Show the div again const div = newBranchList.find(`#${escapedDivId}`) if (div.hasClass('collapse') && !div.hasClass('show')) { div.addClass('show') } // Ensure the caret icon in the link is correct const collapseControlIcon = newBranchList.find(`.nrow .namecell a[data-target="#${escapedDivId}"]>i`) if (collapseControlIcon.length === 1 && collapseControlIcon.hasClass("fa-caret-right")) { collapseControlIcon.removeClass("fa-caret-right") collapseControlIcon.addClass("fa-caret-down") } }) // Prevent window resizing and scrolling newBranchList.css('min-height', beginningBLHeight + 'px') $('#branchList').replaceWith(newBranchList) $('body').trigger('branchListUpdated') } else { const bl = $('#branchList').empty() $('
') .text('Cannot communicate with Robomerge.') .appendTo(bl) $('') logOutButton.click(function() { document.cookie = 'auth=; path=; redirect_to=;'; document.cookie = 'signedOut=true;'; window.location.href = '/login'; }) loggedInUser.append(logOutButton) topright.append(loggedInUser) topright.append('
') topright.append('
') } const bigHelpButton = $('') .appendTo(rootPath) .click(function() { alert(JSON.stringify(data, null, ' ')); }); //} } return entireNameDiv } function createPauseDivs(data, conflict) { const divs = [] // Display pause data if applicable if (data.is_paused) { divs.push($('
') .append( $('

').html(`

Paused by ${data.manual_pause.owner}
On: ${new Date(data.manual_pause.startedAt).toString()}
Reason: ${data.manual_pause.message}`) ) ) } // Display blockage data if applicable if (data.is_blocked) { const changelist = conflict && conflict.cl ? conflict.cl : data.blockage && data.blockage.change ? data.blockage.change : 'unknown' let info = conflict && conflict.kind ? `Cause: ${conflict.kind.toLowerCase()}` : data.blockage ? `Blocked. Type: ${data.blockage.type}
Message: ${data.blockage.message}` : `No info can be provided. Please contact Robomerge help.` if (conflict && conflict.slackLinks) { info += `
${conflict.slackLinks.map(link => `
Slack Thread`).join("
")}` } divs.push($('
') .append( $('

').html('

Blocked on change ' + makeClLink(changelist, data.swarmURL) + `
${info}`) ) ) } return divs } function postRenderNameCell_Node(nameCell, nodeData, includeCollapseControls, conflict=null) { // For nodes, go through the edges to display a badge next to the name for blocked and paused edges let edgesInConflictCount = 0 let edgesPausedCount = 0 const edgeNames = Object.keys(nodeData.edges) if (nodeData.edges) { edgeNames.forEach( (edgeKey) => { const edgeData = nodeData.edges[edgeKey] if (edgeData.is_blocked || edgeData.retry_cl > 0) { edgesInConflictCount++ } else if (edgeData.is_paused) { edgesPausedCount++ } }) } const totalBlockages = edgesInConflictCount + !!nodeData.is_blocked let badges = [] if (totalBlockages > 0) { if (edgesInConflictCount === edgeNames.length) { badges.push($('
') .html('') .attr('title', 'ALL EDGES BLOCKED') .css('color', 'DARKRED') ) } badges.push($('').text(totalBlockages)) } if (edgesPausedCount > 0) { badges.push($('').text(edgesPausedCount)) } const nameHeader = nameCell.find('div.element-name').first() let collapseLink = null if (includeCollapseControls && nodeData.edges && Object.keys(nodeData.edges).length !== 0) { // Replace the context with collapse controls nameHeader.empty() const name = getDisplayName(nodeData) collapseLink = $('').appendTo(nameHeader) collapseLink.attr('data-target', '#' + getNodeEdgesRowId(nodeData.bot, name)) let icon = $('').appendTo(collapseLink) // If we have any edges in conflict, we default to showing the edge table. Display the caret down if (edgesInConflictCount > 0) { icon.addClass('fa-caret-down') } else { icon.addClass('fa-caret-right') // Collapsed } collapseLink.append(` ${name}`, badges) } else { nameHeader.append(badges) } // Append Pause Info if (!nodeData.is_available) { nameCell.append(createPauseDivs(nodeData, conflict)) } // We need to represent if edges need attention if (nodeData.conflicts && nodeData.edges && Array.isArray(nodeData.conflicts) && Array.isArray(nodeData.edges) && nodeData.conflicts.length >= Object.keys(nodeData.edges).length) { // Alert if all edges are blocked by putting a danger icon beside the name if (collapseLink) { collapseLink.append(dangerSymbol) } else { nameHeader } } } function postRenderNameCell_Edge(nameCell, edgeData, conflict=null) { // Append Pause Info if (!edgeData.is_available) { nameCell.append(createPauseDivs(edgeData, conflict)) } } // Helper function to create status data cell function preRenderStatusCell_Node(nodeData) { // Create holder for the stylized status text let statusCell = $('
') if (!nodeData.def.isMonitored) { statusCell.addClass("unmonitored-text") statusCell.text('Unmonitored') } if (nodeData.is_blocked) { $('
') .text('ENTIRELY BLOCKED') .addClass('important-status') .css('color', 'DARKRED') .appendTo(statusCell) } return statusCell } function preRenderStatusCell_Edge(nodeData, edgeData) { // Create holder for the stylized status text let statusCell = $('
').addClass('statuscell') if (edgeData.is_blocked) { $('
') .text('BLOCKED') .addClass('important-status') .css('color', 'DARKRED') .appendTo(statusCell) } else if (!edgeData.is_paused) { // If we're not paused, but the node is paused or blocked, display a message informing users // that we'll not be receiving any work. if (nodeData.is_paused || nodeData.is_blocked || nodeData.retry_cl) { $('
') .text('NODE ' + (nodeData.is_paused ? 'PAUSED' : 'BLOCKED')) .addClass('important-status') .css('color', 'ORANGE') .prependTo(statusCell) } else if (edgeData.gateClosedMessage) { let msg = edgeData.gateClosedMessage if (edgeData.nextWindowOpenTime) { const timestamp = Date.parse(edgeData.nextWindowOpenTime) if (!isNaN(timestamp)) { msg += ` (opens: ${(new Date(timestamp)).toLocaleString()})` } } let statusMsg = edgeData.waitingForCISLink ? $(``).prop('target', '_blank') : $("
") statusMsg.addClass('status-msg').text(msg).appendTo(statusCell) } } return statusCell } function renderStatusCell_Common(statusCell, data) { // Don't bother with unmonitored nodes if (statusCell.hasClass('unmonitored-text')) { return } if (data.is_paused) { let pauseInfo = $('
') .text('PAUSED') .addClass('important-status') .css('color', 'ORANGE') .prependTo(statusCell) let [pausedDurationStr, pausedDurationColor] = printDurationInfo('Paused', Date.now() - new Date(data.manual_pause.startedAt).getTime()) $('
') .html(`${pausedDurationStr} by ${data.manual_pause.owner}`) .css('color', pausedDurationColor) .insertAfter(pauseInfo) } if (data.is_blocked) { const blockageinfoDiv = statusCell.find('.blockageinfo') // If we have an acknowledged date, print acknowledged text if (data.blockage.acknowledgedAt) { let acknowledgedSince = new Date(data.blockage.acknowledgedAt); let [ackDurationStr, ackDurationColor] = printDurationInfo("Acknowledged", Date.now() - acknowledgedSince.getTime()) $('
') .css('color', ackDurationColor) .html(`${ackDurationStr} by ${data.blockage.acknowledger}`) .insertAfter(blockageinfoDiv) } // Determine who is responsible for resolving this. else { let blockedSince = new Date(data.blockage.startedAt) let [blockedDurationStr, blockedDurationColor] = printDurationInfo("Blocked", Date.now() - blockedSince.getTime()) const pauseCulprit = data.blockage.owner || data.blockage.author || "unknown culprit"; const blockageDetailsDiv = $('
').insertAfter(blockageinfoDiv) $('
') .css('color', blockedDurationColor) .html(blockedDurationStr) .appendTo(blockageDetailsDiv) $('
') .html(`Resolver: ${pauseCulprit} (unacknowledged)`) .appendTo(blockageDetailsDiv) } } let text = null, color = 'GREEN' // If the node is currently processing (active) display as such if (data.retry_cl) { text = 'RETRYING' color = 'ORANGE' } else if (data.is_active) { text = 'ACTIVE' } // Otherwise, if we're available and we haven't populated any data inside statusCell, print that we're running else if (data.is_available && statusCell.children().length === 0) { text = 'RUNNING' } if (text) { $('
') .text(text) .css('color', color) .prependTo(statusCell) } return statusCell } // Helper function to create actions cell function preRenderActionsCell_Node(nodeData) { let actionCell = $('
') // If the branch is not monitored, we can't provide actions. Simply provide text response and return. if (!nodeData.def.isMonitored) { actionCell.append($('
').text("No Actions Available")) actionCell.addClass('unactionable') } else if (!nodeData.edges) { actionCell.append($('
').text("No Edges Configured")) actionCell.addClass('unactionable') } return actionCell } function preRenderActionsCell_Edge(_edgeData) { return $('
') } // operationArgs should be a string array with these elements: // 0 - bot name // 1 - branch/node name // 2 - edge name (if applicable) function renderActionsCell_Common(actionCell, data, operationFunction, operationArgs, conflict=null) { // Don't bother with unmonitored nodes if (actionCell.hasClass('unactionable-text')) { return } const botname = operationArgs[0] const branchNameForAPI = operationArgs[1] const optEdgeName = operationArgs[2] const dataDisplayName = getDisplayName(data) const dataFullName = `${botname}:${dataDisplayName}` // Start collecting available actions in our button group // Keep track if we have an individual action button forming our default option for a given NodeBot --- // this controls how we render the dropdown menu let specificActionButton = null // If manually paused, the default option should be to unpause it if (data.is_paused) { specificActionButton = createActionButton( `Unpause`, function() { let unpauseConfirmation = confirm(`Are you sure you want to unpause ${dataDisplayName}?`) if (unpauseConfirmation) { operationFunction(...operationArgs, '/unpause', function(success) { if (success) { displaySuccessfulMessage(`Unpaused ${dataDisplayName}.`) } else { displayErrorMessage(`Error unpausing ${dataFullName}, please check logs.`) $('#helpButton').trigger('click') } updateBranchList(botname) }) } }, `Resume Robomerge monitoring of ${dataDisplayName}` ) } // If the whole node is paused on a blocker, the acknowledge functions should be the default options else if (data.is_blocked) { let ackQueryData = toQuery({ cl: data.blockage.change }) let ackFunc = function() { operationFunction(...operationArgs, '/acknowledge?' + ackQueryData, function(success) { displaySuccessfulMessage(`Acknowledged blockage in ${dataFullName}.`) updateBranchList(botname) }, function(failure) { displayErrorMessage(`Error acknowledging blockage in ${dataFullName}, please contact Robomerge support. Error message: ${failure}`) $('#helpButton').trigger('click') updateBranchList(botname) } ) } let unackFunc = function() { operationFunction(...operationArgs, '/unacknowledge?' + ackQueryData, function(success) { displaySuccessfulMessage(`Unacknowledged blockage in ${dataFullName}.`) updateBranchList(botname) }, function(failure) { displayErrorMessage(`Error unacknowledging blockage in ${dataFullName}, please contact Robomerge support. Error message: ${failure}`) $('#helpButton').trigger('click') updateBranchList(botname) } ) } // Acknowledge if (!data.blockage.acknowledger) { specificActionButton = createActionButton( [$(''), ' ', "Acknowledge"], ackFunc, 'Take ownership of this blockage' ) } // Unacknowledge else if (robomergeUser && data.blockage.acknowledger === robomergeUser.userName) { specificActionButton = createActionButton( [$(''), ' ', "Unacknowledge"], unackFunc, 'Back-pedal: someone else should fix this' ) } // Take ownership else { specificActionButton = createActionButton( [$(''), ' ', "Take Ownership"], ackFunc, `Take ownership of this blockage over ${data.blockage.acknowledger}` ) } } // collect actions that should be added to a drop-down, if any const dropdownEntries = [] if (data.is_blocked) { // If this object has a conflict, report it if (conflict) { const queryParams = toQuery({ bot: botname, branch: branchNameForAPI, target: conflict.target, cl: conflict.cl }) // Create Shelf let shelfRequest = '/op/createshelf?' + queryParams if (conflict.targetStream) { shelfRequest += '&' + toQuery({ targetStream: conflict.targetStream }) } shelfRequest += location.hash const toTargetText = conflict.target ? ' -> ' + conflict.target : ''; const shelfOption = createActionOption('Create Shelf for ' + conflict.cl + toTargetText, function() { window.location.href = shelfRequest; }, `Retry merge of ${conflict.cl} and create a shelf in a specified P4 workspace`) // Stomp const stompRequest = '/op/stomp?' + queryParams + location.hash const stompOption = createActionOption('Stomp Changes using ' + conflict.cl + toTargetText, function() { window.location.href = stompRequest; }, `Use ${conflict.cl} to stomp binary changes in ${conflict.target}`); // Unlock files const unlockFilesRequest = '/op/unlock?' + queryParams const unlockOption = createActionOption('Unlock Files blocking ' + conflict.cl + toTargetText, function() { window.location.href = unlockFilesRequest; }, `Unlock files blocking ${conflict.cl} in ${conflict.target}`); // Can only create shelves for merge conflicts and commit failures if (conflict.kind !== 'Merge conflict' && conflict.kind !== 'Commit failure' && conflict.kind !== 'Transfer error') { shelfOption.addClass("disabled") shelfOption.off('click') // Bootstrap magic converts 'title' to 'data-original-title' shelfOption.attr('data-original-title', `Shelving not available for ${conflict.kind.toLowerCase()}.`) } dropdownEntries.push(shelfOption) // Can only perform stomps for merge conflicts if (conflict.kind === 'Merge conflict') { dropdownEntries.push(stompOption) } else { stompOption.addClass("disabled") stompOption.off('click') stompOption.attr('data-original-title', `Stomp not available for ${conflict.kind.toLowerCase()}.`) } if (conflict.kind === 'Exclusive check-out') { dropdownEntries.push(unlockOption) } else { unlockOption.addClass("disabled") unlockOption.off('click') unlockOption.attr('data-original-title', `Unlock not required for ${conflict.kind.toLowerCase()}.`) } } // Exposing this section in the case of a blockage but no conflict // Retry CL # const retryOption = createActionOption(`Retry ${data.blockage.change}`, function() { operationFunction(...operationArgs, '/retry', function(success) { if (success) { displaySuccessfulMessage(`Resuming ${dataDisplayName} and retrying ${data.blockage.change}`) } else { displayErrorMessage(`Error resuming ${dataDisplayName} and retrying ${data.blockage.change}, please check logs.`) $('#helpButton').trigger('click') } updateBranchList(botname) }) }, `Resume Robomerge monitoring of ${dataDisplayName} and retry ${data.blockage.change}`) dropdownEntries.push(retryOption) dropdownEntries.push('