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