diff --git a/apps/dashboard/app/assets/stylesheets/application.scss b/apps/dashboard/app/assets/stylesheets/application.scss index e36d0543d6..6b715bc322 100644 --- a/apps/dashboard/app/assets/stylesheets/application.scss +++ b/apps/dashboard/app/assets/stylesheets/application.scss @@ -173,6 +173,7 @@ small.form-text { @import "editor"; @import "icon_picker"; @import "pinned_apps"; +@import "xdmod"; @import "support_ticket"; @import "data_tables"; @import "projects"; diff --git a/apps/dashboard/app/assets/stylesheets/xdmod.scss b/apps/dashboard/app/assets/stylesheets/xdmod.scss new file mode 100644 index 0000000000..67a06c8f6b --- /dev/null +++ b/apps/dashboard/app/assets/stylesheets/xdmod.scss @@ -0,0 +1,59 @@ +/** Job Analytics **/ +#jobsPanelDiv { + .hiddenRow { + padding: 0 !important; + } + + i.app-icon { + width: 0.9rem; + height: 0.9rem; + font-size: 0.9rem; + } + + tr { + position: relative; + } + + tr[aria-expanded=true] .closed { + display: none; + } + + tr[aria-expanded=false] .open { + display: none; + } + + tr[aria-expanded=true] td:not(.job-analytics) { + padding-bottom: 45px; + } + + tr.error[aria-expanded=true] td:not(.job-analytics) { + padding-bottom: 200px; + } + + tr.error td.job-analytics { + border-bottom: none; + } + + td.job-analytics { + position: absolute; + top: 35px; + left: 0; + z-index: 1000; + width: 100%; + padding: 0; + + div.job-analytics-content { + display: flex; + justify-content: space-between; + padding: 0.5rem 0.5rem; + + strong { + font-weight: 600; + } + + .badge { + vertical-align: 1px; + } + } + } +} \ No newline at end of file diff --git a/apps/dashboard/app/javascript/utils.js b/apps/dashboard/app/javascript/utils.js index 6e13fb5e0f..dd46dcc0d1 100644 --- a/apps/dashboard/app/javascript/utils.js +++ b/apps/dashboard/app/javascript/utils.js @@ -1,3 +1,4 @@ +import {analyticsPath} from "./config"; export function cssBadgeForState(state){ switch (state) { @@ -102,3 +103,12 @@ export function setInnerHTML(element, html) { currentElement.parentNode.replaceChild(newElement, currentElement); }); } + +// Helper method to report errors from the front end via AJAX +export function reportErrorForAnalytics(path, error) { + // error - report back for analytics purposes + const analyticsUrl = new URL(analyticsPath(path), document.location); + analyticsUrl.searchParams.append('error', error); + // Fire and Forget + fetch(analyticsUrl); +} diff --git a/apps/dashboard/app/javascript/xdmod.js b/apps/dashboard/app/javascript/xdmod.js index 01371a4a04..cc7f304837 100644 --- a/apps/dashboard/app/javascript/xdmod.js +++ b/apps/dashboard/app/javascript/xdmod.js @@ -1,13 +1,14 @@ import _ from 'lodash'; import {xdmodUrl, analyticsPath} from './config'; -import {today, startOfYear, thirtyDaysAgo} from './utils'; +import {today, startOfYear, thirtyDaysAgo, reportErrorForAnalytics} from './utils'; import { jobsPanel } from './xdmod/jobs'; import Handlebars from 'handlebars'; const jobsPageLimit = 10; const jobHelpers = { + realm: 'Jobs', title: function(){ return "Recently Completed Jobs"; }, @@ -44,19 +45,19 @@ const jobHelpers = { return `${month}/${day}`; }, - job_url: function(id){ return `${xdmodUrl()}/#job_viewer?action=show&realm=SUPREMM&jobref=${id}`; }, - cpu_label: function(cpu){ - let value = (parseFloat(cpu)*100).toFixed(1), - label = "N/A"; + job_url: function(id){ return `${xdmodUrl()}/#job_viewer?action=show&realm=${this.realm}&jobref=${id}`; }, + efficiency_label: function(efficiencyValue, inverse = false){ + const value = (parseFloat(efficiencyValue)*100).toFixed(1); + let label = "N/A"; if(! isNaN(value)){ let severity = "warning"; - if(cpu > 0.74){ - severity = "success"; + if(efficiencyValue > 0.74){ + severity = inverse ? "danger" : "success"; } - else if(cpu < 0.25){ - severity = "danger"; + else if(efficiencyValue < 0.25){ + severity = inverse ? "success" : "danger"; } label = `${Handlebars.escapeExpression(value.toString().padStart(4,0))}`; @@ -84,12 +85,12 @@ var efficiencyHelpers = { } }; -function promiseLoginToXDMoD(xdmodUrl){ +function promiseLoginToXDMoD(){ return new Promise(function(resolve, reject){ var promise_to_receive_message_from_iframe = new Promise(function(resolve, reject){ window.addEventListener("message", function(event){ - if (event.origin !== xdmodUrl){ + if (event.origin !== xdmodUrl()){ console.log('Received message from untrusted origin, discarding'); return; } @@ -106,8 +107,8 @@ function promiseLoginToXDMoD(xdmodUrl){ }, false); }); - fetch(xdmodUrl + '/rest/auth/idpredirect?returnTo=%2Fgui%2Fgeneral%2Flogin.php') - .then(response => response.ok ? Promise.resolve(response) : Promise.reject()) + fetch(xdmodUrl() + '/rest/auth/idpredirect?returnTo=%2Fgui%2Fgeneral%2Flogin.php') + .then(response => response.ok ? Promise.resolve(response) : Promise.reject(new Error('Login failed: IDP redirect failed'))) .then(response => response.json()) .then(function(data){ return new Promise(function(resolve, reject){ @@ -153,6 +154,7 @@ var promiseLoggedIntoXDMoD = (function(){ }) .then((user_data) => { if(user_data && user_data.success && user_data.results && user_data.results.person_id){ + jobHelpers.realm = user_data.results.raw_data_allowed_realms?.includes('SUPREMM') ? 'SUPREMM' : 'Jobs'; return Promise.resolve(user_data); } else{ @@ -169,7 +171,7 @@ function jobsUrl(user){ url.searchParams.set('_dc', Date.now()); url.searchParams.set('start_date', thirtyDaysAgo()); url.searchParams.set('end_date', today()); - url.searchParams.set('realm', user?.results?.raw_data_allowed_realms?.includes('SUPREMM') ? 'SUPREMM' : 'Jobs'); + url.searchParams.set('realm', jobHelpers.realm); url.searchParams.set('limit', jobsPageLimit); url.searchParams.set('start', 0); url.searchParams.set('verbose', true); @@ -239,10 +241,7 @@ function createJobsWidget() { console.error(error); renderJobs({error: error}); - // error - report back for analytics purposes - const analyticsUrl = new URL(analyticsPath('xdmod_jobs_widget_error'), document.location); - analyticsUrl.searchParams.append('error', error); - fetch(analyticsUrl); + reportErrorForAnalytics('xdmod_jobs_widget_error', error); }); } @@ -254,7 +253,7 @@ function createEfficiencyWidgets() { return; } - promiseLoggedIntoXDMoD(xdmodUrl) + promiseLoggedIntoXDMoD() .then((user_data) => fetch(aggregateDataUrl(user_data), { credentials: 'include' })) .then(response => response.ok ? Promise.resolve(response) : Promise.reject(new Error(response.statusText))) .then(response => response.json()) @@ -287,10 +286,7 @@ function createEfficiencyWidgets() { renderJobsEfficiency({error: error}); renderCoreHoursEfficiency({error: error}); - // error - report back for analytics purposes - const analyticsUrl = new URL(analyticsPath('xdmod_jobs_widget_error'), document.location); - analyticsUrl.searchParams.append('error', error); - fetch(analyticsUrl); + reportErrorForAnalytics('xdmod_jobs_widget_error', error); }); } diff --git a/apps/dashboard/app/javascript/xdmod/jobs.js b/apps/dashboard/app/javascript/xdmod/jobs.js index ac32aa4ee1..5c48fc8fbe 100644 --- a/apps/dashboard/app/javascript/xdmod/jobs.js +++ b/apps/dashboard/app/javascript/xdmod/jobs.js @@ -1,5 +1,7 @@ 'use strict'; +import {reportErrorForAnalytics} from '../utils'; + export function jobsPanel(context, helpers){ const div = document.createElement('div'); div.classList.add('xdmod'); @@ -77,15 +79,17 @@ function table(context, helpers) { // tableElement.classList.add('table', 'table-sm', 'table-striped', 'table-condensed'); - thead = document.createElement('thead'); + const thead = document.createElement('thead'); + // Empty th to accommodate for the job analytics button thead.innerHTML = ' \ - \ - \ - \ - \ + \ + \ + \ + \ + \ '; - tbody = document.createElement('tbody'); + const tbody = document.createElement('tbody'); tbody.append(...tableRows(context, helpers)); tableElement.append(thead); @@ -97,12 +101,12 @@ function table(context, helpers) { } function tableRows(context, helpers) { - jobs = context.results; + const jobs = context.results; if (jobs === undefined || jobs.length == 0) { return [ noDataRow() ]; } - rows = []; + const rows = []; // // @@ -113,6 +117,19 @@ function tableRows(context, helpers) { jobs.forEach(job => { const tr = document.createElement('tr'); tr.title = `${job.job_name} - ${job.local_job_id}`; + // Job Analytics metadata => Required for the AJAX request and collapse behaviour + tr.setAttribute('data-xdmod-jobid', job.jobid); + tr.setAttribute('data-bs-toggle', 'collapse'); + tr.setAttribute('data-bs-target', `#details_${job.jobid}`); + tr.setAttribute('aria-expanded', 'false'); + + // Job analytics collapse icons + const td0 = document.createElement('td'); + td0.innerHTML = ` + ` // + // Not used with new analytics data + // const td4 = document.createElement('td'); + // td4.innerHTML = helpers.efficiency_label(job.cpu_user); + + // Add job analytics placeholder const td4 = document.createElement('td'); - td4.innerHTML = helpers.cpu_label(job.cpu_user); + td4.id = `details_${job.jobid}`; + td4.classList.add('job-analytics', 'collapse'); + td4.innerHTML = '
LOADING...
'; + // Call JobAnalytics API after the collapse is fully open to avoid awkward animation. + td4.addEventListener('shown.bs.collapse', function(event) { + getJobAnalytics(job, helpers); + }, { once: true }); - tr.append(td1, td2, td3, td4); + tr.append(td0, td1, td2, td3, td4); rows.push(tr); }); @@ -166,3 +194,54 @@ function noDataRow() { return tr; } + +function renderJobAnalytics(analyticsData, jobData, containerId, helpers) { + if(analyticsData.error !== undefined) { + const errorMessage = errorBody(analyticsData.error, helpers); + const analyticsContainer = document.getElementById(containerId); + analyticsContainer.closest('tr').classList.add('error'); + analyticsContainer.replaceChildren(errorMessage); + return; + } + + // Index Job analytics data by analytics key + const dataByKey = analyticsData.data.reduce((acc, obj) => { + acc[obj.key] = obj; + return acc; + }, {}); + + // Default to jobData form the job search results. + // As the Jobs realm might not have any analytics metrics. + const cpuEfficiency = dataByKey['CPU User']?.value || jobData.cpu_user; + const memEfficiency = dataByKey['Memory Headroom']?.value; + const walltimeEfficiency = dataByKey['Walltime Accuracy']?.value || jobData.walltime_accuracy; + const div = document.createElement('div'); + div.classList.add('job-analytics-content'); + div.innerHTML = `CPU: ${helpers.efficiency_label(cpuEfficiency, false)} + Mem: ${helpers.efficiency_label(memEfficiency, true)} + Walltime: ${helpers.efficiency_label(walltimeEfficiency, false)}`; + + document.getElementById(containerId).replaceChildren(div); +} + +function jobAnalyticsUrl(jobId, helpers){ + let url = new URL(`${helpers.xdmod_url()}/rest/v1.0/warehouse/search/jobs/analytics`); + url.searchParams.set('_dc', Date.now()); + url.searchParams.set('realm', helpers.realm); + url.searchParams.set('jobid', jobId); + return url; +} + +function getJobAnalytics(jobData, helpers) { + const analyticsContainer = `details_${jobData.jobid}`; + fetch(jobAnalyticsUrl(jobData.jobid, helpers), { credentials: 'include' }) + .then(response => response.ok ? Promise.resolve(response) : Promise.reject(new Error(response.statusText))) + .then(response => response.json()) + .then((data) => renderJobAnalytics(data, jobData, analyticsContainer, helpers)) + .catch((error) => { + console.error(error); + renderJobAnalytics({error: error}, jobData, analyticsContainer, helpers); + + reportErrorForAnalytics('xdmod_jobs_analytics_widget_error', error); + }); +} diff --git a/apps/dashboard/app/views/widgets/_xdmod_widget_jobs.html.erb b/apps/dashboard/app/views/widgets/_xdmod_widget_jobs.html.erb index 3e91f6e2b6..6dc454e346 100644 --- a/apps/dashboard/app/views/widgets/_xdmod_widget_jobs.html.erb +++ b/apps/dashboard/app/views/widgets/_xdmod_widget_jobs.html.erb @@ -1,59 +1,6 @@ <%= javascript_include_tag 'xdmod', nonce: true %>
+ <%# This XDMoD widget is rendered using Javascript %>
- - -
diff --git a/apps/dashboard/test/models/projects_test.rb b/apps/dashboard/test/models/projects_test.rb index aaea1faea3..0acb8c9f1f 100644 --- a/apps/dashboard/test/models/projects_test.rb +++ b/apps/dashboard/test/models/projects_test.rb @@ -134,7 +134,7 @@ class ProjectsTest < ActiveSupport::TestCase test 'creates manifest.yml in .ondemand config directory' do Dir.mktmpdir do |tmp| projects_path = Pathname.new(tmp) - project = create_project(projects_path) + project = create_project(projects_path, id: "test-#{Project.next_id}") assert project.errors.inspect assert_equal "#{projects_path}/projects/#{project.id}", project.directory.to_s @@ -172,7 +172,8 @@ class ProjectsTest < ActiveSupport::TestCase test 'update project manifest.yml file' do Dir.mktmpdir do |tmp| projects_path = Pathname.new(tmp) - project = create_project(projects_path) + + project = create_project(projects_path, id: "test-#{Project.next_id}") name = 'test-project-2' description = 'my test project'
IDNameDateCPUAnalytics ToggleIDNameDateAnalytics
{{local_job_id}}  // {{local_job_id}}  @@ -132,10 +149,21 @@ function tableRows(context, helpers) { td3.innerText = helpers.date(job); // {{cpu_label cpu_user}}