rack-mini-profiler 0.1.22 → 0.1.27

Sign up to get free protection for your applications and to get access to all the features.

Potentially problematic release.


This version of rack-mini-profiler might be problematic. Click here for more details.

Files changed (35) hide show
  1. checksums.yaml +7 -0
  2. data/Ruby/CHANGELOG +69 -35
  3. data/Ruby/README.md +47 -9
  4. data/Ruby/lib/html/flamegraph.html +351 -0
  5. data/Ruby/lib/html/includes.css +451 -75
  6. data/Ruby/lib/html/includes.js +134 -23
  7. data/Ruby/lib/html/includes.less +38 -35
  8. data/Ruby/lib/html/includes.tmpl +40 -15
  9. data/Ruby/lib/html/jquery.1.7.1.js +1 -1
  10. data/Ruby/lib/html/jquery.tmpl.js +1 -1
  11. data/Ruby/lib/html/list.js +7 -6
  12. data/Ruby/lib/html/profile_handler.js +1 -62
  13. data/Ruby/lib/mini_profiler/client_timer_struct.rb +1 -1
  14. data/Ruby/lib/mini_profiler/config.rb +58 -52
  15. data/Ruby/lib/mini_profiler/context.rb +11 -10
  16. data/Ruby/lib/mini_profiler/custom_timer_struct.rb +22 -0
  17. data/Ruby/lib/mini_profiler/flame_graph.rb +54 -0
  18. data/Ruby/lib/mini_profiler/gc_profiler.rb +8 -4
  19. data/Ruby/lib/mini_profiler/page_timer_struct.rb +7 -2
  20. data/Ruby/lib/mini_profiler/profiler.rb +207 -157
  21. data/Ruby/lib/mini_profiler/profiling_methods.rb +131 -108
  22. data/Ruby/lib/mini_profiler/request_timer_struct.rb +20 -1
  23. data/Ruby/lib/mini_profiler/sql_timer_struct.rb +1 -1
  24. data/Ruby/lib/mini_profiler/storage/abstract_store.rb +31 -27
  25. data/Ruby/lib/mini_profiler/storage/file_store.rb +111 -109
  26. data/Ruby/lib/mini_profiler/storage/memcache_store.rb +11 -9
  27. data/Ruby/lib/mini_profiler/storage/memory_store.rb +65 -63
  28. data/Ruby/lib/mini_profiler/storage/redis_store.rb +54 -44
  29. data/Ruby/lib/mini_profiler/version.rb +5 -0
  30. data/Ruby/lib/mini_profiler_rails/railtie.rb +44 -40
  31. data/Ruby/lib/patches/net_patches.rb +14 -0
  32. data/Ruby/lib/patches/sql_patches.rb +89 -48
  33. data/Ruby/lib/rack-mini-profiler.rb +1 -0
  34. data/rack-mini-profiler.gemspec +1 -1
  35. metadata +41 -52
@@ -1,5 +1,6 @@
1
1
  "use strict";
2
- var MiniProfiler = (function ($) {
2
+ var MiniProfiler = (function () {
3
+ var $;
3
4
 
4
5
  var options,
5
6
  container,
@@ -60,11 +61,10 @@ var MiniProfiler = (function ($) {
60
61
  }
61
62
  };
62
63
 
63
- var getClientPerformance = function () {
64
+ var getClientPerformance = function() {
64
65
  return window.performance == null ? null : window.performance;
65
- }
66
+ };
66
67
 
67
- var waitedForEnd = 0;
68
68
  var fetchResults = function (ids) {
69
69
  var clientPerformance, clientProbes, i, j, p, id, idx;
70
70
 
@@ -126,6 +126,7 @@ var MiniProfiler = (function ($) {
126
126
  url: options.path + 'results',
127
127
  data: { id: id, clientPerformance: clientPerformance, clientProbes: clientProbes, popup: 1 },
128
128
  dataType: 'json',
129
+ global: false,
129
130
  type: 'POST',
130
131
  success: function (json) {
131
132
  fetchedIds.push(id);
@@ -364,6 +365,9 @@ var MiniProfiler = (function ($) {
364
365
  popupHide(button, popup);
365
366
  }
366
367
  });
368
+ $(document).bind('keydown', options.toggleShortcut, function(e) {
369
+ $('.profiler-results').toggle();
370
+ });
367
371
  };
368
372
 
369
373
  var initFullView = function () {
@@ -432,6 +436,7 @@ var MiniProfiler = (function ($) {
432
436
  // get master page profiler results
433
437
  fetchResults(options.ids);
434
438
  });
439
+ if (options.startHidden) container.hide();
435
440
  }
436
441
  else {
437
442
  fetchResults(options.ids);
@@ -507,20 +512,92 @@ var MiniProfiler = (function ($) {
507
512
  });
508
513
  }
509
514
 
515
+ if (typeof (MooTools) != 'undefined' && typeof (Request) != 'undefined') {
516
+ Request.prototype.addEvents({
517
+ onComplete: function() {
518
+ var stringIds = this.xhr.getResponseHeader('X-MiniProfiler-Ids');
519
+ if (stringIds) {
520
+ var ids = typeof JSON != 'undefined' ? JSON.parse(stringIds) : eval(stringIds);
521
+ fetchResults(ids);
522
+ }
523
+ }
524
+ });
525
+ }
526
+
527
+ // add support for AngularJS, which use the basic XMLHttpRequest object.
528
+ if (window.angular && typeof (XMLHttpRequest) != 'undefined') {
529
+ var _send = XMLHttpRequest.prototype.send;
530
+
531
+ XMLHttpRequest.prototype.send = function sendReplacement(data) {
532
+ this._onreadystatechange = this.onreadystatechange;
533
+
534
+ this.onreadystatechange = function onReadyStateChangeReplacement() {
535
+ if (this.readyState == 4) {
536
+ var stringIds = this.getResponseHeader('X-MiniProfiler-Ids');
537
+ if (stringIds) {
538
+ var ids = typeof JSON != 'undefined' ? JSON.parse(stringIds) : eval(stringIds);
539
+ fetchResults(ids);
540
+ }
541
+ }
542
+
543
+ return this._onreadystatechange.apply(this, arguments);
544
+ }
545
+
546
+ return _send.apply(this, arguments);
547
+ }
548
+ }
549
+
510
550
  // some elements want to be hidden on certain doc events
511
551
  bindDocumentEvents();
512
552
  };
513
553
 
514
554
  return {
515
555
 
516
- init: function (opt) {
517
-
518
- options = opt || {};
556
+ init: function () {
557
+ var script = document.getElementById('mini-profiler');
558
+ if (!script || !script.getAttribute) return;
559
+
560
+ options = (function () {
561
+ var version = script.getAttribute('data-version');
562
+ var path = script.getAttribute('data-path');
563
+
564
+ var currentId = script.getAttribute('data-current-id');
565
+
566
+ var ids = script.getAttribute('data-ids');
567
+ if (ids) ids = ids.split(',');
568
+
569
+ var position = script.getAttribute('data-position');
570
+
571
+ var toggleShortcut = script.getAttribute('data-toggle-shortcut');
572
+
573
+ if (script.getAttribute('data-max-traces'))
574
+ var maxTraces = parseInt(script.getAttribute('data-max-traces'));
575
+
576
+ if (script.getAttribute('data-trivial') === 'true') var trivial = true;
577
+ if (script.getAttribute('data-children') == 'true') var children = true;
578
+ if (script.getAttribute('data-controls') == 'true') var controls = true;
579
+ if (script.getAttribute('data-authorized') == 'true') var authorized = true;
580
+ if (script.getAttribute('data-start-hidden') == 'true') var startHidden = true;
581
+
582
+ return {
583
+ ids: ids,
584
+ path: path,
585
+ version: version,
586
+ renderPosition: position,
587
+ showTrivial: trivial,
588
+ showChildrenTime: children,
589
+ maxTracesToShow: maxTraces,
590
+ showControls: controls,
591
+ currentId: currentId,
592
+ authorized: authorized,
593
+ toggleShortcut: toggleShortcut,
594
+ startHidden: startHidden
595
+ }
596
+ })();
519
597
 
520
598
  var doInit = function () {
521
599
  // when rendering a shared, full page, this div will exist
522
600
  container = $('.profiler-result-full');
523
-
524
601
  if (container.length) {
525
602
  if (window.location.href.indexOf("&trivial=1") > 0) {
526
603
  options.showTrivial = true
@@ -547,24 +624,49 @@ var MiniProfiler = (function ($) {
547
624
  document.getElementsByTagName('head')[0].appendChild(sc);
548
625
  };
549
626
 
550
- if (options.authorized) {
551
- var url = options.path + "includes.css?v=" + options.version;
552
- if (document.createStyleSheet) {
553
- document.createStyleSheet(url);
627
+ var wait = 0;
628
+ var finish = false;
629
+ var deferInit = function() {
630
+ if (finish) return;
631
+ if (window.performance && window.performance.timing && window.performance.timing.loadEventEnd == 0 && wait < 10000) {
632
+ setTimeout(deferInit, 100);
633
+ wait += 100;
554
634
  } else {
555
- $('head').append($('<link rel="stylesheet" type="text/css" href="' + url + '" />'));
635
+ finish = true;
636
+ init();
556
637
  }
638
+ };
557
639
 
558
- if (!$.tmpl) {
559
- load(options.path + 'jquery.tmpl.js?v=' + options.version, doInit);
640
+ var init = function() {
641
+ if (options.authorized) {
642
+ var url = options.path + "includes.css?v=" + options.version;
643
+ if (document.createStyleSheet) {
644
+ document.createStyleSheet(url);
645
+ } else {
646
+ $('head').append($('<link rel="stylesheet" type="text/css" href="' + url + '" />'));
647
+ }
648
+ if (!$.tmpl) {
649
+ load(options.path + 'jquery.tmpl.js?v=' + options.version, doInit);
650
+ } else {
651
+ doInit();
652
+ }
560
653
  } else {
561
654
  doInit();
562
655
  }
656
+ };
657
+
658
+ if (typeof(jQuery) == 'function') {
659
+ var jQueryVersion = jQuery.fn.jquery.split('.');
563
660
  }
564
- else {
565
- doInit();
661
+ if (jQueryVersion && (parseInt(jQueryVersion[0]) == 2) || (parseInt(jQueryVersion[0]) < 2 && parseInt(jQueryVersion[1]) >= 7)) {
662
+ MiniProfiler.jQuery = $ = jQuery;
663
+ $(deferInit);
664
+ } else {
665
+ load(options.path + "jquery.1.7.1.js?v=" + options.version, function() {
666
+ MiniProfiler.jQuery = $ = jQuery.noConflict(true);
667
+ $(deferInit);
668
+ });
566
669
  }
567
-
568
670
  },
569
671
 
570
672
  getClientTimingByName: function (clientTiming, name) {
@@ -651,7 +753,7 @@ var MiniProfiler = (function ($) {
651
753
  // start adding at the root and recurse down
652
754
  addToResults(root);
653
755
 
654
- var removeDuration = function (list, duration) {
756
+ var removeDuration = function(list, duration) {
655
757
 
656
758
  var newList = [];
657
759
  for (var i = 0; i < list.length; i++) {
@@ -675,7 +777,7 @@ var MiniProfiler = (function ($) {
675
777
  }
676
778
 
677
779
  return newList;
678
- }
780
+ };
679
781
 
680
782
  var processTimes = function (elem, parent) {
681
783
  var duration = { start: elem.StartMilliseconds, finish: (elem.StartMilliseconds + elem.DurationMilliseconds) };
@@ -697,7 +799,7 @@ var MiniProfiler = (function ($) {
697
799
  // sort results by time
698
800
  result.sort(function (a, b) { return a.StartMilliseconds - b.StartMilliseconds; });
699
801
 
700
- var determineOverlap = function (gap, node) {
802
+ var determineOverlap = function(gap, node) {
701
803
  var overlap = 0;
702
804
  for (var i = 0; i < node.richTiming.length; i++) {
703
805
  var current = node.richTiming[i];
@@ -711,7 +813,7 @@ var MiniProfiler = (function ($) {
711
813
  overlap += Math.min(gap.finish, current.finish) - Math.max(gap.start, current.start);
712
814
  }
713
815
  return overlap;
714
- }
816
+ };
715
817
 
716
818
  var determineGap = function (gap, node, match) {
717
819
  var overlap = determineOverlap(gap, node);
@@ -784,7 +886,16 @@ var MiniProfiler = (function ($) {
784
886
  return (duration || 0).toFixed(1);
785
887
  }
786
888
  };
787
- })(jQueryMP);
889
+ })();
890
+
891
+ MiniProfiler.init();
892
+
893
+ // jquery.hotkeys.js
894
+ // https://github.com/jeresig/jquery.hotkeys/blob/master/jquery.hotkeys.js
895
+
896
+ (function(d){function h(g){if("string"===typeof g.data){var h=g.handler,j=g.data.toLowerCase().split(" ");g.handler=function(b){if(!(this!==b.target&&(/textarea|select/i.test(b.target.nodeName)||"text"===b.target.type))){var c="keypress"!==b.type&&d.hotkeys.specialKeys[b.which],e=String.fromCharCode(b.which).toLowerCase(),a="",f={};b.altKey&&"alt"!==c&&(a+="alt+");b.ctrlKey&&"ctrl"!==c&&(a+="ctrl+");b.metaKey&&(!b.ctrlKey&&"meta"!==c)&&(a+="meta+");b.shiftKey&&"shift"!==c&&(a+="shift+");c?f[a+c]=
897
+ !0:(f[a+e]=!0,f[a+d.hotkeys.shiftNums[e]]=!0,"shift+"===a&&(f[d.hotkeys.shiftNums[e]]=!0));c=0;for(e=j.length;c<e;c++)if(f[j[c]])return h.apply(this,arguments)}}}}d.hotkeys={version:"0.8",specialKeys:{8:"backspace",9:"tab",13:"return",16:"shift",17:"ctrl",18:"alt",19:"pause",20:"capslock",27:"esc",32:"space",33:"pageup",34:"pagedown",35:"end",36:"home",37:"left",38:"up",39:"right",40:"down",45:"insert",46:"del",96:"0",97:"1",98:"2",99:"3",100:"4",101:"5",102:"6",103:"7",104:"8",105:"9",106:"*",107:"+",
898
+ 109:"-",110:".",111:"/",112:"f1",113:"f2",114:"f3",115:"f4",116:"f5",117:"f6",118:"f7",119:"f8",120:"f9",121:"f10",122:"f11",123:"f12",144:"numlock",145:"scroll",191:"/",224:"meta"},shiftNums:{"`":"~",1:"!",2:"@",3:"#",4:"$",5:"%",6:"^",7:"&",8:"*",9:"(","0":")","-":"_","=":"+",";":": ","'":'"',",":"<",".":">","/":"?","\\":"|"}};d.each(["keydown","keyup","keypress"],function(){d.event.special[this]={add:h}})})(jQuery);
788
899
 
789
900
  // prettify.js
790
901
  // http://code.google.com/p/google-code-prettify/
@@ -49,7 +49,7 @@
49
49
  // styles shared between popup view and full view
50
50
  .profiler-result
51
51
  {
52
-
52
+
53
53
  .profiler-toggle-duration-with-children
54
54
  {
55
55
  float: right;
@@ -143,6 +143,9 @@
143
143
  float:left;
144
144
  margin-left:0px;
145
145
  }
146
+ &.profiler-custom-link {
147
+ float:left;
148
+ }
146
149
  }
147
150
  }
148
151
  }
@@ -189,15 +192,15 @@
189
192
  text-align:right;
190
193
  margin-bottom:5px;
191
194
  }
192
-
195
+
193
196
  .profiler-gap-info, .profiler-gap-info td { background-color: #ccc;}
194
197
  .profiler-gap-info {
195
198
  .profiler-unit {color: #777;}
196
199
  .profiler-info {text-align: right}
197
200
  &.profiler-trivial-gaps {display: none}
198
- }
199
-
200
- .profiler-trivial-gap-container { text-align: center;}
201
+ }
202
+
203
+ .profiler-trivial-gap-container { text-align: center;}
201
204
 
202
205
  // prettify colors
203
206
  .str{color:maroon}
@@ -287,24 +290,24 @@
287
290
  }
288
291
  }
289
292
 
290
- .profiler-controls {
291
- display: block;
292
- font-size:12px;
293
- font-family: @codeFonts;
294
- cursor:default;
295
- text-align: center;
296
-
297
- span {
298
- border-right: 1px solid @mutedColor;
299
- padding-right: 5px;
300
- margin-right: 5px;
301
- cursor:pointer;
302
- }
303
-
304
- span:last-child {
305
- border-right: none;
306
- }
307
- }
293
+ .profiler-controls {
294
+ display: block;
295
+ font-size:12px;
296
+ font-family: @codeFonts;
297
+ cursor:default;
298
+ text-align: center;
299
+
300
+ span {
301
+ border-right: 1px solid @mutedColor;
302
+ padding-right: 5px;
303
+ margin-right: 5px;
304
+ cursor:pointer;
305
+ }
306
+
307
+ span:last-child {
308
+ border-right: none;
309
+ }
310
+ }
308
311
 
309
312
  .profiler-popup {
310
313
  display:none;
@@ -367,19 +370,19 @@
367
370
  }
368
371
  }
369
372
 
370
- &.profiler-min .profiler-result {
371
- display: none;
372
- }
373
+ &.profiler-min .profiler-result {
374
+ display: none;
375
+ }
373
376
 
374
- &.profiler-min .profiler-controls span {
375
- display: none;
376
- }
377
+ &.profiler-min .profiler-controls span {
378
+ display: none;
379
+ }
377
380
 
378
- &.profiler-min .profiler-controls .profiler-min-max {
379
- border-right: none;
380
- padding: 0px;
381
- margin: 0px;
382
- }
381
+ &.profiler-min .profiler-controls .profiler-min-max {
382
+ border-right: none;
383
+ padding: 0px;
384
+ margin: 0px;
385
+ }
383
386
  }
384
387
 
385
388
  // popup results' queries will be displayed in front of this
@@ -465,4 +468,4 @@
465
468
  }
466
469
  }
467
470
  }
468
- }
471
+ }
@@ -27,10 +27,13 @@
27
27
  {{if HasSqlTimings}}
28
28
  <th colspan="2">query time (ms)</th>
29
29
  {{/if}}
30
+ {{each CustomTimingNames}}
31
+ <th colspan="2">${$value.toLowerCase()} (ms)</th>
32
+ {{/each}}
30
33
  </tr>
31
34
  </thead>
32
35
  <tbody>
33
- {{tmpl(Root) "#timingTemplate"}}
36
+ {{tmpl({timing:Root, page:this.data}) "#timingTemplate"}}
34
37
  </tbody>
35
38
  <tfoot>
36
39
  <tr>
@@ -46,6 +49,12 @@
46
49
  <span class="profiler-unit">% in sql</span>
47
50
  </td>
48
51
  {{/if}}
52
+ {{each CustomTimingNames}}
53
+ <td colspan="2" class="profiler-number profiler-percentage-in-sql" title="${CustomTimingStats[$value].Count} ${$value.toLowerCase()} invocations spent ${MiniProfiler.formatDuration(CustomTimingStats[$value].Duration)} ms of total request time">
54
+ ${MiniProfiler.formatDuration(CustomTimingStats[$value].Duration / DurationMilliseconds * 100)}
55
+ <span class="profiler-unit">% in ${$value.toLowerCase()}</span>
56
+ </td>
57
+ {{/each}}
49
58
  </tr>
50
59
  </tfoot>
51
60
  </table>
@@ -114,6 +123,9 @@
114
123
 
115
124
  <script id="linksTemplate" type="text/x-jquery-tmpl">
116
125
  <a href="${MiniProfiler.shareUrl(Id)}" class="profiler-share-profiler-results" target="_blank">share</a>
126
+ {{if CustomLink}}
127
+ <a href="${CustomLink}" class="profiler-custom-link" target="_blank">${CustomLinkName}</a>
128
+ {{/if}}
117
129
  {{if HasTrivialTimings}}
118
130
  <a class="profiler-toggle-trivial" data-show-on-load="${HasAllTrivialTimings}" title="toggles any rows with &lt; ${TrivialDurationThresholdMilliseconds} ms">
119
131
  show trivial
@@ -123,37 +135,50 @@
123
135
 
124
136
  <script id="timingTemplate" type="text/x-jquery-tmpl">
125
137
 
126
- <tr class="{{if IsTrivial }}profiler-trivial{{/if}}" data-timing-id="${Id}">
127
- <td class="profiler-label" title="{{if Name && Name.length > 45 }}${Name}{{/if}}">
128
- <span class="profiler-indent">${MiniProfiler.renderIndent(Depth)}</span> ${Name.slice(0,45)}{{if Name && Name.length > 45 }}...{{/if}}
138
+ <tr class="{{if timing.IsTrivial }}profiler-trivial{{/if}}" data-timing-id="${timing.Id}">
139
+ <td class="profiler-label" title="{{if timing.Name && timing.Name.length > 45 }}${timing.Name}{{/if}}">
140
+ <span class="profiler-indent">${MiniProfiler.renderIndent(timing.Depth)}</span> ${timing.Name.slice(0,45)}{{if timing.Name && timing.Name.length > 45 }}...{{/if}}
129
141
  </td>
130
142
  <td class="profiler-duration" title="duration of this step without any children's durations">
131
- ${MiniProfiler.formatDuration(DurationWithoutChildrenMilliseconds)}
143
+ ${MiniProfiler.formatDuration(timing.DurationWithoutChildrenMilliseconds)}
132
144
  </td>
133
145
  <td class="profiler-duration profiler-duration-with-children" title="duration of this step and its children">
134
- ${MiniProfiler.formatDuration(DurationMilliseconds)}
146
+ ${MiniProfiler.formatDuration(timing.DurationMilliseconds)}
135
147
  </td>
136
148
  <td class="profiler-duration time-from-start" title="time elapsed since profiling started">
137
- <span class="profiler-unit">+</span>${MiniProfiler.formatDuration(StartMilliseconds)}
149
+ <span class="profiler-unit">+</span>${MiniProfiler.formatDuration(timing.StartMilliseconds)}
138
150
  </td>
139
151
 
140
- {{if HasSqlTimings}}
141
- <td class="profiler-duration {{if HasDuplicateSqlTimings}}profiler-warning{{/if}}" title="{{if HasDuplicateSqlTimings}}duplicate queries detected - {{/if}}{{if ExecutedReaders > 0 || ExecutedScalars > 0 || ExecutedNonQueries > 0}}${ExecutedReaders} reader, ${ExecutedScalars} scalar, ${ExecutedNonQueries} non-query statements executed{{/if}}">
152
+ {{if timing.HasSqlTimings}}
153
+ <td class="profiler-duration {{if timing.HasDuplicateSqlTimings}}profiler-warning{{/if}}" title="{{if timing.HasDuplicateSqlTimings}}duplicate queries detected - {{/if}}{{if timing.ExecutedReaders > 0 || timing.ExecutedScalars > 0 || timing.ExecutedNonQueries > 0}}${timing.ExecutedReaders} reader, ${timing.ExecutedScalars} scalar, ${timing.ExecutedNonQueries} non-query statements executed{{/if}}">
142
154
  <a class="profiler-queries-show">
143
- {{if HasDuplicateSqlTimings}}<span class="profiler-nuclear">!</span>{{/if}}
144
- ${SqlTimings.length} <span class="profiler-unit">sql</span>
155
+ {{if timing.HasDuplicateSqlTimings}}<span class="profiler-nuclear">!</span>{{/if}}
156
+ ${timing.SqlTimings.length} <span class="profiler-unit">sql</span>
145
157
  </a>
146
158
  </td>
147
159
  <td class="profiler-duration" title="aggregate duration of all queries in this step (excludes children)">
148
- ${MiniProfiler.formatDuration(SqlTimingsDurationMilliseconds)}
160
+ ${MiniProfiler.formatDuration(timing.SqlTimingsDurationMilliseconds)}
149
161
  </td>
150
162
  {{/if}}
151
163
 
164
+ {{each page.CustomTimingNames}}
165
+ {{if timing.CustomTimings && timing.CustomTimings[$value]}}
166
+ <td class="profiler-duration" title="aggregate number of all ${$value.toLowerCase()} invocations in this step (excludes children)">
167
+ ${timing.CustomTimings[$value].length} ${$value.toLowerCase()}
168
+ </td>
169
+ <td class="profiler-duration" title="aggregate duration of all ${$value.toLowerCase()} invocations in this step (excludes children)">
170
+ ${MiniProfiler.formatDuration(timing.CustomTimingStats[$value].Duration)}
171
+ </td>
172
+ {{else}}
173
+ <td colspan="2"></td>
174
+ {{/if}}
175
+ {{/each}}
176
+
152
177
  </tr>
153
178
 
154
- {{if HasChildren}}
155
- {{each Children}}
156
- {{tmpl($value) "#timingTemplate"}}
179
+ {{if timing.HasChildren}}
180
+ {{each timing.Children}}
181
+ {{tmpl({timing: $value, page: page}) "#timingTemplate"}}
157
182
  {{/each}}
158
183
  {{/if}}
159
184