Skip to content

Commit

Permalink
Added XDMoD analytics metrics to jobs widget (#3789)
Browse files Browse the repository at this point in the history
Added XDMoD analytics metrics to jobs widget to include CPU, Memory and walltime where previously it was just CPU.
  • Loading branch information
abujeda authored Oct 8, 2024
1 parent de41471 commit 7af67d2
Show file tree
Hide file tree
Showing 7 changed files with 182 additions and 89 deletions.
1 change: 1 addition & 0 deletions apps/dashboard/app/assets/stylesheets/application.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
59 changes: 59 additions & 0 deletions apps/dashboard/app/assets/stylesheets/xdmod.scss
Original file line number Diff line number Diff line change
@@ -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;
}
}
}
}
10 changes: 10 additions & 0 deletions apps/dashboard/app/javascript/utils.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import {analyticsPath} from "./config";

export function cssBadgeForState(state){
switch (state) {
Expand Down Expand Up @@ -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);
}
42 changes: 19 additions & 23 deletions apps/dashboard/app/javascript/xdmod.js
Original file line number Diff line number Diff line change
@@ -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";
},
Expand Down Expand Up @@ -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 = `<span class="badge bg-${severity}">${Handlebars.escapeExpression(value.toString().padStart(4,0))}</span>`;
Expand Down Expand Up @@ -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;
}
Expand All @@ -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){
Expand Down Expand Up @@ -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{
Expand All @@ -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);
Expand Down Expand Up @@ -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);
});
}

Expand All @@ -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())
Expand Down Expand Up @@ -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);
});
}

Expand Down
99 changes: 89 additions & 10 deletions apps/dashboard/app/javascript/xdmod/jobs.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
'use strict';

import {reportErrorForAnalytics} from '../utils';

export function jobsPanel(context, helpers){
const div = document.createElement('div');
div.classList.add('xdmod');
Expand Down Expand Up @@ -77,15 +79,17 @@ function table(context, helpers) {
// <table class="table table-sm table-striped table-condensed">
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 = '<tr> \
<th>ID</th> \
<th>Name</th> \
<th>Date</th> \
<th>CPU</th> \
<th class="sr-only">Analytics Toggle</th> \
<th class="id">ID</th> \
<th class="name">Name</th> \
<th class="date">Date</th> \
<th class="sr-only">Analytics</th> \
</tr>';

tbody = document.createElement('tbody');
const tbody = document.createElement('tbody');
tbody.append(...tableRows(context, helpers));

tableElement.append(thead);
Expand All @@ -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 = [];

// <tr title="{{job_name}} - {{local_job_id}}">
// <td class="text-nowrap"><a target="_blank" href="{{job_url}}">{{local_job_id}}&nbsp;<span class="fa fa-external-link-square-alt"></span></a></td>
Expand All @@ -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 = `
<button class="btn btn-default btn-xs">
<i class="fa fa-plus fa-fw app-icon closed" aria-hidden="true"></i>
<i class="fa fa-minus fa-fw app-icon open" aria-hidden="true"></i>
</button>`

// <td class="text-nowrap">
// <a target="_blank" href="{{job_url}}">{{local_job_id}}&nbsp;<span class="fa fa-external-link-square-alt"></span>
Expand All @@ -132,10 +149,21 @@ function tableRows(context, helpers) {
td3.innerText = helpers.date(job);

// <td>{{cpu_label cpu_user}}</td>
// 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 = '<div class="job-analytics-content"><span>LOADING...</span></div>';
// 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);
});
Expand Down Expand Up @@ -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 = `<span><strong>CPU:</strong> ${helpers.efficiency_label(cpuEfficiency, false)}</span>
<span><strong>Mem:</strong> ${helpers.efficiency_label(memEfficiency, true)}</span>
<span><strong>Walltime:</strong> ${helpers.efficiency_label(walltimeEfficiency, false)}</span>`;

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);
});
}
Loading

0 comments on commit 7af67d2

Please sign in to comment.