Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added XDMoD analytics metrics to jobs widget #3789

Merged
merged 10 commits into from
Oct 8, 2024
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> \
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just to be clear - the button is in the 1st column and that expands that data in the 5th column?

I think there's still something off about this. The 5th column reads as blank if you've ever expanded it before and collapsed it. And if you've never collapsed it, it skips the column entirely, when it should read something like analytics column <something about needing to expand it>.

That said - I can merge this now and pick it up later just because this has dragged on for a while.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just to be clear - the button is in the 1st column and that expands that data in the 5th column?

Yes , that is the solution I came up with to display the analytics without creating a new row.

</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
Loading