refs #5493
[arvados.git] / apps / workbench / app / assets / javascripts / job_log_graph.js
1 /* Assumes existence of:
2   window.jobGraphData = [];
3   window.jobGraphSeries = [];
4   window.jobGraphSortedSeries = [];
5   window.jobGraphMaxima = {};
6  */
7 function processLogLineForChart( logLine ) {
8     try {
9         var match = logLine.match(/^(\S+) (\S+) (\S+) (\S+) stderr crunchstat: (\S+) (.*)/);
10         if( !match ) {
11             match = logLine.match(/^((?:Sun|Mon|Tue|Wed|Thu|Fri|Sat) (?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) \d{1,2} \d\d:\d\d:\d\d \d{4}) (\S+) (\S+) (\S+) stderr crunchstat: (\S+) (.*)/);
12             if( match ) {
13                 match[1] = (new Date(match[1] + ' UTC')).toISOString().replace('Z','');
14             }
15         }
16         if( match ) {
17             var rawDetailData = '';
18             var datum = null;
19
20             // the timestamp comes first
21             var timestamp = match[1].replace('_','T') + 'Z';
22
23             // we are interested in "-- interval" recordings
24             var intervalMatch = match[6].match(/(.*) -- interval (.*)/);
25             if( intervalMatch ) {
26                 var intervalData = intervalMatch[2].trim().split(' ');
27                 var dt = parseFloat(intervalData[0]);
28                 var dsum = 0.0;
29                 for(var i=2; i < intervalData.length; i += 2 ) {
30                     dsum += parseFloat(intervalData[i]);
31                 }
32                 datum = dsum/dt;
33
34                 if( datum < 0 ) {
35                     // not interested in negative deltas
36                     return;
37                 }
38
39                 rawDetailData = intervalMatch[2];
40
41                 // for the series name use the task number (4th term) and then the first word after 'crunchstat:'
42                 var series = 'T' + match[4] + '-' + match[5];
43
44                 // special calculation for cpus
45                 if( /-cpu$/.test(series) ) {
46                     // divide the stat by the number of cpus unless the time count is less than the interval length
47                     if( dsum.toFixed(1) > dt.toFixed(1) ) {
48                         var cpuCountMatch = intervalMatch[1].match(/(\d+) cpus/);
49                         if( cpuCountMatch ) {
50                             datum = datum / cpuCountMatch[1];
51                         }
52                     }
53                 }
54
55                 addJobGraphDatum( timestamp, datum, series, rawDetailData );
56             } else {
57                 // we are also interested in memory ("mem") recordings
58                 var memoryMatch = match[6].match(/(\d+) cache (\d+) swap (\d+) pgmajfault (\d+) rss/);
59                 if( memoryMatch ) {
60                     rawDetailData = match[6];
61                     // one datapoint for rss and one for swap - only show the rawDetailData for rss
62                     addJobGraphDatum( timestamp, parseInt(memoryMatch[4]), 'T' + match[4] + "-rss", rawDetailData );
63                     addJobGraphDatum( timestamp, parseInt(memoryMatch[2]), 'T' + match[4] + "-swap", '' );
64                 } else {
65                     // not interested
66                     return;
67                 }
68             }
69
70             window.redraw = true;
71         }
72     } catch( err ) {
73         console.log( 'Ignoring error trying to process log line: ' + err);
74     }
75 }
76
77 function addJobGraphDatum(timestamp, datum, series, rawDetailData) {
78     // check for new series
79     if( $.inArray( series, jobGraphSeries ) < 0 ) {
80         var newIndex = jobGraphSeries.push(series) - 1;
81         jobGraphSortedSeries.push(newIndex);
82         jobGraphSortedSeries.sort( function(a,b) {
83             var matchA = jobGraphSeries[a].match(/^T(\d+)-(.*)/);
84             var matchB = jobGraphSeries[b].match(/^T(\d+)-(.*)/);
85             var termA = ('000000' + matchA[1]).slice(-6) + matchA[2];
86             var termB = ('000000' + matchB[1]).slice(-6) + matchB[2];
87             return termA > termB ? 1 : -1;
88         });
89         jobGraphMaxima[series] = null;
90         window.recreate = true;
91     }
92
93     if( datum !== 0 && ( jobGraphMaxima[series] === null || jobGraphMaxima[series] < datum ) ) {
94         if( isJobSeriesRescalable(series) ) {
95             // use old maximum to get a scale conversion
96             var scaleConversion = jobGraphMaxima[series]/datum;
97             // set new maximum and rescale the series
98             jobGraphMaxima[series] = datum;
99             rescaleJobGraphSeries( series, scaleConversion );
100         }
101     }
102
103     // scale
104     var scaledDatum = null;
105     if( isJobSeriesRescalable(series) && jobGraphMaxima[series] !== null && jobGraphMaxima[series] !== 0 ) {
106         scaledDatum = datum/jobGraphMaxima[series]
107     } else {
108         scaledDatum = datum;
109     }
110     // identify x axis point, searching from the end of the array (most recent)
111     var found = false;
112     for( var i = jobGraphData.length - 1; i >= 0; i-- ) {
113         if( jobGraphData[i]['t'] === timestamp ) {
114             found = true;
115             jobGraphData[i][series] = scaledDatum;
116             jobGraphData[i]['raw-'+series] = rawDetailData;
117             break;
118         } else if( jobGraphData[i]['t'] < timestamp  ) {
119             // we've gone far enough back in time and this data is supposed to be sorted
120             break;
121         }
122     }
123     // index counter from previous loop will have gone one too far, so add one
124     var insertAt = i+1;
125     if(!found) {
126         // create a new x point for this previously unrecorded timestamp
127         var entry = { 't': timestamp };
128         entry[series] = scaledDatum;
129         entry['raw-'+series] = rawDetailData;
130         jobGraphData.splice( insertAt, 0, entry );
131         var shifted = [];
132         // now let's see about "scrolling" the graph, dropping entries that are too old (>10 minutes)
133         while( jobGraphData.length > 0
134                  && (Date.parse( jobGraphData[0]['t'] ) + 10*60000 < Date.parse( jobGraphData[jobGraphData.length-1]['t'] )) ) {
135             shifted.push(jobGraphData.shift());
136         }
137         if( shifted.length > 0 ) {
138             // from those that we dropped, were any of them maxima? if so we need to rescale
139             jobGraphSeries.forEach( function(series) {
140                 // test that every shifted entry in this series was either not a number (in which case we don't care)
141                 // or else approximately (to 2 decimal places) smaller than the scaled maximum (i.e. 1),
142                 // because otherwise we just scrolled off something that was a maximum point
143                 // and so we need to recalculate a new maximum point by looking at all remaining displayed points in the series
144                 if( isJobSeriesRescalable(series) && jobGraphMaxima[series] !== null
145                       && !shifted.every( function(e) { return( !$.isNumeric(e[series]) || e[series].toFixed(2) < 1.0 ) } ) ) {
146                     // check the remaining displayed points and find the new (scaled) maximum
147                     var seriesMax = null;
148                     jobGraphData.forEach( function(entry) {
149                         if( $.isNumeric(entry[series]) && (seriesMax === null || entry[series] > seriesMax)) {
150                             seriesMax = entry[series];
151                         }
152                     });
153                     if( seriesMax !== null && seriesMax !== 0 ) {
154                         // set new actual maximum using the new maximum as the conversion conversion and rescale the series
155                         jobGraphMaxima[series] *= seriesMax;
156                         var scaleConversion = 1/seriesMax;
157                         rescaleJobGraphSeries( series, scaleConversion );
158                     }
159                     else {
160                         // we no longer have any data points displaying for this series
161                         jobGraphMaxima[series] = null;
162                     }
163                 }
164             });
165         }
166         // add a 10 minute old null data point to keep the chart honest if the oldest point is less than 9.9 minutes old
167         if( jobGraphData.length > 0 ) {
168             var earliestTimestamp = jobGraphData[0]['t'];
169             var mostRecentTimestamp = jobGraphData[jobGraphData.length-1]['t'];
170             if( (Date.parse( earliestTimestamp ) + 9.9*60000 > Date.parse( mostRecentTimestamp )) ) {
171                 var tenMinutesBefore = (new Date(Date.parse( mostRecentTimestamp ) - 600*1000)).toISOString();
172                 jobGraphData.unshift( { 't': tenMinutesBefore } );
173             }
174         }
175     }
176
177 }
178
179 function createJobGraph(elementName) {
180     delete jobGraph;
181     var emptyGraph = false;
182     if( jobGraphData.length === 0 ) {
183         // If there is no data we still want to show an empty graph,
184         // so add an empty datum and placeholder series to fool it
185         // into displaying itself.  Note that when finally a new
186         // series is added, the graph will be recreated anyway.
187         jobGraphData.push( {} );
188         jobGraphSeries.push( '' );
189         emptyGraph = true;
190     }
191     var graphteristics = {
192         element: elementName,
193         data: jobGraphData,
194         ymax: 1.0,
195         yLabelFormat: function () { return ''; },
196         xkey: 't',
197         ykeys: jobGraphSeries,
198         labels: jobGraphSeries,
199         resize: true,
200         hideHover: 'auto',
201         parseTime: true,
202         hoverCallback: function(index, options, content) {
203             var s = '';
204             for (var i=0; i < jobGraphSortedSeries.length; i++) {
205                 var sortedIndex = jobGraphSortedSeries[i];
206                 var series = options.ykeys[sortedIndex];
207                 var datum = options.data[index][series];
208                 var point = ''
209                 point += "<div class='morris-hover-point' style='color: ";
210                 point += options.lineColors[sortedIndex % options.lineColors.length];
211                 point += "'>";
212                 var labelMatch = options.labels[sortedIndex].match(/^T(\d+)-(.*)/);
213                 point += 'Task ' + labelMatch[1] + ' ' + labelMatch[2];
214                 point += ": ";
215                 if ( datum !== undefined ) {
216                     if( isJobSeriesRescalable( series ) ) {
217                         datum *= jobGraphMaxima[series];
218                     }
219                     if( parseFloat(datum) !== 0 ) {
220                         if( /-cpu$/.test(series) ){
221                             datum = $.number(datum * 100, 1) + '%';
222                         } else if( datum < 10 ) {
223                             datum = $.number(datum, 2);
224                         } else {
225                             datum = $.number(datum);
226                         }
227                         if(options.data[index]['raw-'+series]) {
228                             datum += ' (' + options.data[index]['raw-'+series] + ')';
229                         }
230                     }
231                     point += datum;
232                 } else {
233                     continue;
234                 }
235                 point += "</div> ";
236                 s += point;
237             }
238             if (s === '') {
239                 // No Y coordinates? This isn't a real data point,
240                 // it's just the placeholder we use to make sure the
241                 // graph can render when empty. Don't show a tooltip.
242                 return '';
243             }
244             return ("<div class='morris-hover-row-label'>" +
245                     options.data[index][options.xkey] +
246                     "</div> " + s);
247         }
248     }
249     if( emptyGraph ) {
250         graphteristics['axes'] = false;
251         graphteristics['parseTime'] = false;
252         graphteristics['hideHover'] = 'always';
253     }
254     $('#' + elementName).html('');
255     window.jobGraph = Morris.Line( graphteristics );
256     if( emptyGraph ) {
257         jobGraphData = [];
258         jobGraphSeries = [];
259     }
260 }
261
262 function rescaleJobGraphSeries( series, scaleConversion ) {
263     if( isJobSeriesRescalable() ) {
264         $.each( jobGraphData, function( i, entry ) {
265             if( entry[series] !== null && entry[series] !== undefined ) {
266                 entry[series] *= scaleConversion;
267             }
268         });
269     }
270 }
271
272 // that's right - we never do this for the 'cpu' series, which will always be between 0 and 1 anyway
273 function isJobSeriesRescalable( series ) {
274     return !/-cpu$/.test(series);
275 }
276
277 function processLogEventForGraph(event, eventData) {
278     if( eventData.properties.text ) {
279         eventData.properties.text.split('\n').forEach( function( logLine ) {
280             processLogLineForChart( logLine );
281         } );
282     }
283 }
284
285 $(document).on('arv-log-event', '#log_graph_div', function(event, eventData) {
286     processLogEventForGraph(event, eventData);
287     if (!window.jobGraphShown) {
288         // Draw immediately, instead of waiting for the 5-second
289         // timer.
290         redrawIfNeeded.call(window, this);
291     }
292 });
293
294 function redrawIfNeeded(graph_div) {
295     if (!window.redraw) {
296         return;
297     }
298     window.redraw = false;
299
300     if (window.recreate) {
301         // Series have changed: we need to draw an entirely new graph.
302         // Running createJobGraph in a show() callback ensures the div
303         // is fully shown when morris uses it to size its svg element.
304         $(graph_div).show(0, createJobGraph.bind(window, $(graph_div).attr('id')));
305         window.jobGraphShown = true;
306         window.recreate = false;
307     } else {
308         window.jobGraph.setData(window.jobGraphData);
309     }
310 }
311
312 $(document).on('ready ajax:complete', function() {
313     $('#log_graph_div').not('.graph-is-setup').addClass('graph-is-setup').each( function( index, graph_div ) {
314         window.jobGraphShown = false;
315         window.jobGraphData = [];
316         window.jobGraphSeries = [];
317         window.jobGraphSortedSeries = [];
318         window.jobGraphMaxima = {};
319         window.recreate = false;
320         window.redraw = false;
321
322         $.get('/jobs/' + $(graph_div).data('object-uuid') + '/logs.json', function(data) {
323             data.forEach( function( entry ) {
324                 processLogEventForGraph({}, entry);
325             });
326             // Update the graph now to show the recent data points
327             // received via /logs.json (along with any new data points
328             // we received via websockets while waiting for /logs.json
329             // to respond).
330             redrawIfNeeded(graph_div);
331         });
332
333         setInterval(redrawIfNeeded.bind(window, graph_div), 5000);
334     });
335 });