<html> <head> <meta charset="UTF-8"> <title>Test scheduler</title> <style> body { font-family: 'Gill Sans', 'Gill Sans MT', Calibri, 'Trebuchet MS', sans-serif; } table { border: 0px; border-spacing: 0px 3px; } td { padding: 1em; border: 0px; margin: 0px; } td.schedule-path, th.schedule-path { width: 20em; } .iconButton { height: 2.2em; width: 2.2em; } .menuEntry { display: block; color: whitesmoke; text-decoration: none; margin: .3em; padding: 1em; } .menuEntry:hover { background-color: darkslategray; } .menuView { position: absolute; display: block; background-color: black; padding: .5em; border-radius: .5em; } .logView { overflow-y: auto; border: 2px solid #ddd; top: 30px; bottom: 0px; right: 0px; left: 0px; padding: .4em; font-family: monospace; position: absolute; } .logViewWindow { position: fixed; float: left; left: 10%; right: 10%; top: 10%; bottom: 10%; background-color: white; width: 75%; height: 75%; } .logViewTab { height: 30px; } </style> </head> <body> <script> 'use strict'; (() => { const scheduleTable = document.createElement('table'); const schedule = {}; let logView = null; let menuView = null; function init() { scheduleTable.style.display = 'none'; document.body.appendChild(scheduleTable); scheduleTable.appendChild(makeScheduleRow(['ID', 'Created At', 'Path', 'Template', 'State', 'Actions'], 'th')); document.addEventListener("keydown", function(event) { if (event.which == 27) { closeLogView(); closeJobMenu(); } }); const bodyClickListener = event => { const modals = [ {element: menuView, callback: closeJobMenu}, {element: logView, callback: closeLogView} ]; for (const modal of modals) { if (modal.element === null || !document.body.contains(modal.element)) { continue; } if (!modal.element.contains(event.target)) { console.log("Clicked outside of " + modal.element.className); modal.callback(); } } } document.addEventListener('click', bodyClickListener); } function onStatusGet(status) { scheduleTable.style.display = 'block'; const incomingIds = status.schedule.map(s => s.id); const newTasksIds = incomingIds.filter(i => !(i in schedule)); const delTasksIds = Object.keys(schedule).filter(i => incomingIds.indexOf(i) == -1); // Delete tasks that are not on the server anymore for (const i of delTasksIds) { const row = schedule[i].row; row.parentElement.removeChild(row); delete schedule[i]; } // Add rows for new incoming tasks for (const i of newTasksIds) { const row = makeScheduleRow([i, '', '', '', '', '']); const s = { id: i, row }; schedule[s.id] = s; // Find out correct placement in table let ids = Object.keys(schedule); ids.sort(i => i | 0); const idx = ids.indexOf(s.id); if (idx == -1) { throw new Error(s.id + " NOT FOUND"); } else if (idx == 0) { scheduleTable.appendChild(row); } else { const prevId = ids[idx-1]; scheduleTable.insertBefore(row, schedule[prevId].row); } // Add buttons for actions const lastCell = row.cells[row.cells.length - 1]; function makeButton(icon, onClick) { const button = document.createElement('button'); button.innerHTML = icon; button.onclick = onClick; button.className = 'iconButton' return button; } lastCell.appendChild(makeButton('...', (e) => showJobMenu(e, s.id))); } // Update already existing tasks for (const s of status.schedule) { for (const key in s) { schedule[s.id][key] = s[key]; } const row = schedule[s.id].row; updateInnerText(row.cells[1], s.added.substr(0, 16) + '\n' + s.added.substr(17)); updateInnerText(row.cells[2], s.path); updateInnerText(row.cells[3], s.template); updateInnerText(row.cells[4], s.state); if (s.state == 'FAILED') { row.style.backgroundColor = '#fcc'; } else if (s.state == 'SUCCESSFUL') { row.style.backgroundColor = '#cfc'; } else if (s.state == 'RUNNING') { row.style.backgroundColor = '#ff9'; } else { row.style.backgroundColor = '#eee'; } } } function updateInnerText(element, text) { if (element.innerText !== text) { element.innerText = text; } } function showJobMenu(event, jobId) { closeJobMenu(); menuView = document.createElement('div'); menuView.className = 'menuView'; menuView.style.left = 0; menuView.style.top = 0; menuView.style.width = 'auto'; menuView.style.height = 'auto'; function makeEntry(label, onClick) { const button = document.createElement('a'); button.innerHTML = label; button.onclick = (e) => {closeJobMenu(); onClick(e);}; button.href = 'javascript:;'; button.className = 'menuEntry'; return button; } const st = schedule[jobId].state; if (st == 'SUCCESSFUL' || st == 'FAILED') { menuView.appendChild(makeEntry('↺ Retry', () => restartJob(jobId))); } if (st == 'SUCCESSFUL' || st == 'FAILED' || st == 'SCHEDULED') { menuView.appendChild(makeEntry('🗑️ Delete', () => deleteJob(jobId))); } if (st == 'RUNNING') { menuView.appendChild(makeEntry('🗴 Cancel', () => cancelJob(jobId))); } menuView.appendChild(makeEntry('🗎 View Logs', () => viewJobLogs(jobId))); if (st == 'SUCCESSFUL') { menuView.appendChild(makeEntry('↓ Download results zip', () => getResultZip(jobId))); menuView.appendChild(makeEntry('↓ Download results JSON', () => getResultJSON(jobId))); } function onLayout() { // This gets executed after the browser did the layout for the menu, // we have the size of the menu view, now we have to place it close to // the bounding box of the button that was clicked, without putting it // ouside the screen: const a = document.body.getBoundingClientRect(); const b = menuView.getBoundingClientRect(); const r = event.target.getBoundingClientRect(); const vh = window.innerHeight || document.documentElement.clientHeight; const vw = window.innerWidth || document.documentElement.clientWidth; menuView.style.width = b.width + 'px'; menuView.style.height = b.height + 'px'; menuView.style.top = (-a.top + Math.min(r.y, vh - b.height - 20)) + 'px'; menuView.style.left = (-a.left + Math.min(r.x, vw - b.width - 20)) + 'px'; }; setTimeout(() => { // Execution needs to be delayed to prevent the ouside click event // from closing this window document.body.appendChild(menuView); setTimeout(onLayout, 1); }, 0); } function closeJobMenu() { if (menuView === null) { return; } while (menuView.childNodes.length > 0) menuView.removeChild(menuView.lastChild); if (menuView.parentElement) menuView.parentElement.removeChild(menuView); menuView = null; } function restartJob(jobId) { const xhttp = new XMLHttpRequest(); xhttp.open("GET", "/restart_test/" + jobId, true); xhttp.send(); } function deleteJob(jobId) { if (confirm("Confirm deleting job " + jobId + "?")) { const xhttp = new XMLHttpRequest(); xhttp.open("GET", "/delete_test/" + jobId, true); xhttp.send(); } } function cancelJob(jobId) { const xhttp = new XMLHttpRequest(); xhttp.open("GET", "/cancel_test/" + jobId, true); xhttp.send(); } function getResultZip(jobId) { window.open('/results/' + jobId + '/results.zip'); } function getResultJSON(jobId) { window.open('/results/' + jobId + '/results.json'); } function viewJobLogs(jobId) { const logIds = [0,1,2,3]; const logNames = ['make stdout', 'make stderr', 'test stdout', 'test stderr']; logView = document.createElement('div'); logView.className = 'logViewWindow'; const closeLogViewButton = document.createElement('button'); logView.appendChild(closeLogViewButton); closeLogViewButton.innerText = "X"; closeLogViewButton.style.display = 'block'; closeLogViewButton.style.right = '0px'; closeLogViewButton.style.top = '0px'; closeLogViewButton.style.position = 'absolute'; closeLogViewButton.onclick = closeLogView; const tabs = logIds.map(logId => { const v = document.createElement('button'); v.innerText = logNames[logIds.indexOf(logId)]; v.className = 'logViewTab'; logView.appendChild(v); return v; }); logView.appendChild(document.createElement('br')); const views = logIds.map(logId => { const v = document.createElement('div'); v.style.display = logId == logIds[0] ? 'block' : 'none'; v.className = 'logView'; logView.appendChild(v); const xhttp = new XMLHttpRequest(); xhttp.onreadystatechange = function() { if (this.readyState == 4 && this.status == 200) { let log = this.responseText; v.innerText = log; log = v.innerHTML; log = log.replace(/\n/g, '<br/>'); v.innerHTML = log; } }; xhttp.open("GET", "/view_log/" + jobId + "/" + logId, true); xhttp.send(); return v; }); for (const i in logIds) { const tab = tabs[i]; const view = views[i]; tab.onclick = () => { views.forEach(v => {v.style.display = 'none';}); view.style.display = 'block'; }; } setTimeout(() => { // Execution needs to be delayed to prevent the ouside click event // from closing this window document.body.appendChild(logView); }, 0); } function closeLogView() { if (logView === null) { return; } while (logView.childElementCount > 1) { logView.removeChild(logView.lastElementChild); } if (logView.parentElement) { logView.parentElement.removeChild(logView); } logView = null; } function requestStatus() { const xhttp = new XMLHttpRequest(); xhttp.onreadystatechange = function() { if (this.readyState == 4) { setTimeout(requestStatus, 1000); if (this.status == 200) { onStatusGet(JSON.parse(this.responseText)); } } }; xhttp.open("GET", "/status", true); xhttp.send(); } function makeScheduleRow(list, cellTagName='td') { const headerRow = document.createElement('tr'); const classes = ['schedule-id', 'schedule-date', 'schedule-path', 'schedule-template', 'schedule-state', 'schedule-actions']; (list.map((text, idx) => { const cell = document.createElement(cellTagName); cell.className = classes[idx]; cell.appendChild(document.createTextNode(text)); return cell; })).forEach((n) => headerRow.appendChild(n)); return headerRow; }; init(); requestStatus(); })(); </script> </body> </html>