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
34 changes: 34 additions & 0 deletions apps/dashboard/app/assets/stylesheets/xdmod.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/** Job Analytics **/
#jobsPanelDiv {
.hiddenRow {
padding: 0 !important;
}

i.app-icon {
width: 0.9rem;
height: 0.9rem;
font-size: 0.9rem;
}

tr[aria-expanded=true] .closed {
display: none;
}

tr[aria-expanded=false] .open {
display: none;
}

.job-analytics {
display: flex;
justify-content: space-between;
padding: 0.75rem 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);
}
56 changes: 33 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,13 +241,24 @@ 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);
});
}

function addAnalyticsToJob(jobId) {
const analyticsContainer = `#details_${jobId}`;
fetch(jobAnalyticsUrl(jobId), { credentials: 'include' })
johrstrom marked this conversation as resolved.
Show resolved Hide resolved
.then(response => response.ok ? Promise.resolve(response) : Promise.reject(new Error(response.statusText)))
.then(response => response.json())
.then((data) => renderJobAnalytics(data, analyticsContainer))
.catch((error) => {
console.error(error);
renderJobAnalytics({error: error}, analyticsContainer);

reportErrorForAnalytics('xdmod_jobs_analytics_widget_error', error);
});
}

function createEfficiencyWidgets() {
const jobPanel = $(`#${jobEfficiencyPanelId}`);
const corePanel = $(`#${coreEfficiencyPanelId}`);
Expand All @@ -254,7 +267,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 +300,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
95 changes: 87 additions & 8 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,16 @@ 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></th> \
Copy link
Contributor

Choose a reason for hiding this comment

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

This should have a heading. You can use the class sr-only, but I think actual text here could be nice too (if it's a bit short/succinct).

Copy link
Contributor Author

Choose a reason for hiding this comment

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

will add some text

<th>ID</th> \
<th>Name</th> \
<th>Date</th> \
<th>CPU</th> \
</tr>';

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

tableElement.append(thead);
Expand All @@ -97,12 +100,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 +116,24 @@ 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');

tr.addEventListener('click', function(event) {
const jobId = event.currentTarget.getAttribute("data-xdmod-jobid");
getJobAnalytics(jobId, helpers)
}, { once: true });

// 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,12 +153,25 @@ function tableRows(context, helpers) {
td3.innerText = helpers.date(job);

// <td>{{cpu_label cpu_user}}</td>
const td4 = document.createElement('td');
td4.innerHTML = helpers.cpu_label(job.cpu_user);
// Not used with new analytics data
// const td4 = document.createElement('td');
// td4.innerHTML = helpers.efficiency_label(job.cpu_user);

tr.append(td1, td2, td3, td4);
tr.append(td0, td1, td2, td3);

rows.push(tr);

// Add job analytics placeholder
const analyticsRow = document.createElement('tr');
Copy link
Contributor

Choose a reason for hiding this comment

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

I think there's something odd about creating a new table row here. Screen readers will get tripped up when row N (a row they've looked at) is now suddenly different because it was the initial row, but after this expansion it's now this new data.

Shouldn't it put all this stuff in the original td and just use some styling to achieve the same look & feel?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I can improve the accessibility of the hidden rows with aria-hidden and aria-expanded and aria-controls. Wouldn't that address the screen readers issue?

I am not sure how feasible is to add content to the original td that contains the +/- icon and make it break out of the existing table structure, push the other rows down, and appear in the same way. Or even to have a hidden td and do the same. Will need to investigate.

It would be more feasible to appear as a small pop up / tooltip, but I think that would be less user friendly.

Copy link
Contributor

Choose a reason for hiding this comment

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

I can improve the accessibility of the hidden rows with aria-hidden and aria-expanded and aria-controls. Wouldn't that address the screen readers issue?

No it's the fact that it's a tr that's being created. Imagine this - you saw row 2 which the screen reader read out as row 2. Then you expand this thing in row 1 and now all the sudden row 2 is this new information, not what you'd previously read through.

I am not sure how feasible is to add content to the original td that contains the +/- icon and make it break out of the existing table structure, push the other rows down, and appear in the same way.

Yea I'm not sure it'd be easy with the way it is now, but I'm sure there's a flex-flow or flex-direction trick we can employ here. Or maybe it'd be easier if expanding the td shifts everything to the right down so the new information shows on top of the row instead of on the bottom?

Copy link
Contributor Author

@abujeda abujeda Oct 7, 2024

Choose a reason for hiding this comment

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

I have removed the extra tr for the analytics data. I am using now a td to render and display the contents.

analyticsRow.innerHTML = `
<td colspan="4" class="hiddenRow">
<div class="collapse" id="details_${job.jobid}">
<div class="job-analytics">
<span>LOADING...</span>
</div>
</div>
</td>`;
rows.push(analyticsRow);
});

return rows;
Expand Down Expand Up @@ -166,3 +200,48 @@ function noDataRow() {

return tr;
}

function renderJobAnalytics(context, containerId, helpers) {
if(context.error !== undefined) {
const errorMessage = errorBody(context.error, helpers);
document.getElementById(containerId).replaceChildren(errorMessage);
return;
}

const dataByKey = context.data.reduce((acc, obj) => {
acc[obj.key] = obj;
return acc;
}, {});
const cpuEfficiency = helpers.efficiency_label(dataByKey['CPU User']?.value, false)
const memEfficiency = helpers.efficiency_label(dataByKey['Memory Headroom']?.value, true)
const walltimeEfficiency = helpers.efficiency_label(dataByKey['Walltime Accuracy']?.value, false)
const div = document.createElement('div');
div.classList.add('job-analytics');
div.innerHTML = `<span><strong>CPU:</strong> ${cpuEfficiency}</span>
<span><strong>Mem:</strong> ${memEfficiency}</span>
<span><strong>Walltime:</strong> ${walltimeEfficiency}</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(jobId, helpers) {
const analyticsContainer = `details_${jobId}`;
fetch(jobAnalyticsUrl(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, analyticsContainer, helpers))
.catch((error) => {
console.error(error);
renderJobAnalytics({error: error}, analyticsContainer, helpers);

reportErrorForAnalytics('xdmod_jobs_analytics_widget_error', error);
});
}
Loading
Loading