Repository: catchpoint/WebPageTest (language: php)

Issue:
In the waterfall request details, Early Hints are breaking on commas
The commas in hints end up causing new list items in our early hint formatter. 


Discussion / hints:

Fix patch:
diff --git a/.prettierignore b/.prettierignore
index b8957c49c3..3980b79d76 100644
--- a/.prettierignore
+++ b/.prettierignore
@@ -2,4 +2,4 @@ www/chrome
 www/css/vendor
 www/js/catchpoint-rum.js
 www/js/dygraph-combined.js
-www/js/codeflask.min.js
+www/js/codeflask.module.js
diff --git a/docs/adr/0001-use-adrs.md b/docs/adr/0001-use-adrs.md
index fb2443b71f..223888c83d 100644
--- a/docs/adr/0001-use-adrs.md
+++ b/docs/adr/0001-use-adrs.md
@@ -9,7 +9,7 @@ There are some specific goals that we have for WebPageTest to ensure that key de
 - We want to make sure that we've thought carefully about architectural decisions, considered alternatives, and arrived at a decision the team agrees is best for the product and community.
 - We want to be as transparent as possible in our decision-making process.
 - We want contributors, both external and internal, to be able to have a strong understanding of why certain architectural decisions were made.
-- We want to leave a trail of docoumentation for ourselves so that we can revisit our decisions to see why they were made and if they still make sense.
+- We want to leave a trail of documentation for ourselves so that we can revisit our decisions to see why they were made and if they still make sense.
 
 ## 🤔 Decision
 We will document our architecure decisions for WebPageTest with an [Architecture Decision Record](https://cognitect.com/blog/2011/11/15/documenting-architecture-decisions)—structured, relatively lightweight records of architectural decisions.
diff --git a/docs/adr/0003-remove-jquery-dependency.md b/docs/adr/0003-remove-jquery-dependency.md
new file mode 100644
index 0000000000..a815342c78
--- /dev/null
+++ b/docs/adr/0003-remove-jquery-dependency.md
@@ -0,0 +1,46 @@
+# Remove jQuery and jQuery UI dependencies
+
+📆 **Updated**: Aug 17, 2022
+
+🙋🏽‍♀️ **Status** Proposed
+
+## ℹ️ Context
+
+- The current use of jQuery 1.7.1 is a low vulnerability (XSS)
+- The version is old and new changes are a bit of a pain (e.g. need to read old API docs)
+- The version can be upgraded but that takes effort (same as above, APIs have changed)
+- It's currently responsible for 80%+ (LOC) in `site.js`
+- We don't support legacy browsers, such as IE, anymore
+
+## 🤔 Decision
+
+Remove jQuery and jQuery UI dependencies. We will be using vanilla JavaScript, rather than immediately switching to another framework, to keep the performance overhead to a minimum.
+
+## 🎬 Consequences
+
+- Remove XSS vulnerability
+- Lighter pages
+- We can take advantage of everything the web platform had to offer without a layer of a polyfill-style library
+
+## Process
+
+We need to move each jQuery/jQueryUI piece one at a time and once we're done, delete the jQ\* code. A list of features we currently use (possibly incomplete):
+
+- dialogs
+- tabs
+- dragging and resizing
+- viewport offsets
+- sizing elements
+- getting OS scrollbar width
+- displaying local time
+- tooltips
+- scroll handling and animation
+- editting test labels via async requests (`$.ajax`)
+- sortable tables (Request Details) which is implementation #3 in addition to a new DIY one and one from Google JS APIs
+
+As prep work, move all jQ* dependencies out of `site.js` so it's easier to tell application callsites vs internal dependencies. Some of the jQ* code is not minified so it's not immediately obvious if this is application code.
+
+## 📝 Changelog
+
+- 07/07/2022 Proposed
+- 08/17/2022 Comments addressed
diff --git a/docs/adr/0004-adopt-laravel.md b/docs/adr/0004-adopt-laravel.md
new file mode 100644
index 0000000000..518dc206e5
--- /dev/null
+++ b/docs/adr/0004-adopt-laravel.md
@@ -0,0 +1,106 @@
+# Adopt Laravel
+📆 **Updated**: Aug 17, 2022
+
+🙋🏽‍♀️ **Status** Proposed
+
+## ℹ️ Context
+WPT codebase has grown from mostly one- or two-person project to a fairly complex codebase with a tons of features and a team of engineers plus community contributors working simultaneously.
+
+We want contributors, both community and internal, to be able to get in, figure out the relevant place in the code, make their change, and get out, as quickly and painlessly as possible.
+
+## 🤔 Decision and scope of this ADR
+Adopt [Laravel](https://laravel.com/) for the www codebase. Laravel can handle API requests, cron jobs, command line (console) tools, but we'll limit this ADR to web only. Follow up ADRs will deal with additional Laravel adoption.
+
+
+## 🎬 Consequences
+Migrating the home-grown codebase to a well-documented popular framework will help:
+- people orient easier (and find answers to common questions on stackoverflow) and make a change in confidence.
+- hire engineers with potentially some Laravel experience
+
+## Process
+We want the migration to be gradual and as unobtrusive as possible regarding day-to-day coding. We can attack the pieces (Routing, Models, Views, Controllers) separately and asynchronously.
+
+The following as suggestions based on what we have in codebase, personal opinions, and an [informative article](https://www.phparch.com/2019/03/migrating-legacy-web-applications-to-laravel/) describing similar migrations.
+
+### Phases
+
+The migration can proceed in three phases:
+* phase 1: prep (could take as long time as needed)
+* phase 2: flip the Laravel switch (as short as possible, it's the disruptive one during which any other development is not recommended)
+* phase 3: continuing adoption and clean up (as long as needed)
+
+### Routing
+
+In phase 1, We want to expose as little code to public www as possible as a preparation.
+
+Laravel does all its web routing in `[LARAVELROOT]/routes/web.php`. In preparation we will move all routing away from server configs to `/www/index.php` which will then include the required script (controller), e.g. `testlog.php` which will be moved away from `www/` and into a formerly-www directory called `/wpt`.
+
+The `/www` will contain only `/www/index.php` and static assets such as `/www/favicon.ico`, `/www/assets/js/`, `/www/assets/css/`
+
+In phase 2 we move all routing from `/www/index.php` to `[LARAVELROOT]/routes/web.php`.
+
+NOTE: `[LARAVELROOT]` could be anything, for example `/possum`. This is where all Laravel code lives. I'll use `/possum` in this ADR so it stands out. Later we can decide to rename it back to `/www` to avoid confusion. In this directory, only `/possum/public` is publicly facing.
+
+In phase 2 we also move all the old code from `/wpt` to inside `/possum` (see the tree below).
+
+In phase 3 we convert each of the old entry points from `/wpt` to Laravel controllers.
+
+### Directory structure at phase 2
+
+```sh
+.
+├── batchtool/
+├── bulktest/
+├── composer.json
+├── docker/
+├── docs/
+└── possum/ # aka [LARAVELROOT]
+    ├── app/
+    │   ├── wpt/ # old www code, delete at the end of phase 3
+    │   ├── Http/
+    │   │   └── Controllers/
+    │   ├── Models/
+    │   └── View/ # overall layouts here, e.g. guest vs logged in
+    ├── config/
+    ├── public/
+    │   ├── index.php # handled by Laravel now
+    │   ├── favicon.ico
+    │   └── assets/
+    │       └── images/
+    ├── resources/
+    │   ├── css/
+    │   ├── js/
+    │   └── views/ # templates go here
+    ├── routes/
+    │   └── web.php # routing happens here
+    ├── tests/ # moved from /www/tests
+    └── vendor/ # moved from /www/vendor
+ ```
+
+
+### Views
+
+In phase 1 we adopt the Blade templating engine. This is already [under way](https://github.com/WPO-Foundation/webpagetest/pull/2228) and the templates live in `www/resources`
+
+In the same phase we convert the prior template work from `www/templates` (which has API similar to Blade) to Blade, so we avoid having things like three competing footers.
+
+In phase 2 `www/resources` stays as-is in `possum/resources/` and we delete the glue code `www/resources/view.php` which now comes with Laravel.
+
+In phase 3 we continue converting old code to Blade templates.
+
+In essence the three phases are one long phase, interrupted by the Laravel switch (phase 2).
+
+### Controllers
+
+- Phase 1 - chill
+- Phase 2 - create one controller (one idea is to use a 404 controller) that includes old code. Similar to what `www/index.php` does in phase 1.
+- Phase 3 - convert old entry points to Laravel controllers
+
+### Models
+
+- Phase 1 and 2 - rest
+- Phase 3 - yank spaghetti off of places with names like `common`, `.inc`, etc and into beautiful classes in `possum/app/wpt`. Or to `possum/app/Models` if it's database heavy.
+
+## 📝 Changelog
+- 07/17/2022 Proposed
+
diff --git a/www/common.inc b/www/common.inc
index 5e0e12816c..f65bb00f99 100644
--- a/www/common.inc
+++ b/www/common.inc
@@ -197,9 +197,9 @@ define('VER_ACCOUNT_CSS', @md5_file(__DIR__ . '/css/account.css')); // version o
 define('VER_PRICE_CHANGER_JS', @md5_file(__DIR__ . '/js/signup-price-changer.js'));
 define('VER_FAQ_JS', @md5_file(__DIR__ . '/js/accessible-faq.js'));
 define('VER_BUTTON_CSS', @md5_file(__DIR__ . '/css/button.css'));   // version of the button css file
-define('VER_JS', 42);                 // version of the sitewide javascript file
-define('VER_JS_TEST', 48);            // version of the javascript specific to the test pages
-define('VER_JS_RUNNING', 1);          // version of the javascript specific to the test running status page
+define('VER_JS', @md5_file(__DIR__ . '/js/site.js'));                 // version of the sitewide javascript file
+define('VER_JS_TEST', @md5_file(__DIR__ . '/js/test.js'));            // version of the javascript specific to the test pages
+define('VER_JS_RUNNING', @md5_file(__DIR__ . '/js/running.js'));          // version of the javascript specific to the test running status page
 define('VER_JS_ACCOUNT', @md5_file(__DIR__ . '/js/account.js'));          // version of the javascript specific to the test running status page
 define('UNKNOWN_TIME', -1);           // Value used as a flag for an unknown time.
 // To find code that fails to check that a time
diff --git a/www/consolelog.php b/www/consolelog.php
new file mode 100644
index 0000000000..9f4c508dac
--- /dev/null
+++ b/www/consolelog.php
@@ -0,0 +1,26 @@
+<?php
+
+// this output buffer hack avoids a bit of circularity
+// on one hand we want the contents of header.inc
+// on the other we want to get access to TestRunResults prior to that
+ob_start();
+define('NOBANNER', true); // otherwise Twitch banner shows 2x
+$tab = 'Test Result';
+$subtab = 'Console Log';
+include_once 'header.inc';
+$results_header = ob_get_contents();
+ob_end_clean();
+
+// all prerequisite libs were already required in header.inc
+$fileHandler = new FileHandler();
+$testInfo = TestInfo::fromFiles($testPath);
+$testRunResults = TestRunResults::fromFiles($testInfo, $run, $cached, $fileHandler);
+
+// template
+require_once __DIR__ . '/resources/view.php';
+echo view('pages.consolelog', [
+    'test_results_view' => true,
+    'body_class' => 'result',
+    'results_header' => $results_header,
+    'log' => $testRunResults->getStepResult(1)->getConsoleLog(),
+]);
diff --git a/www/customWaterfall.php b/www/customWaterfall.php
index 5cf5fb3a9f..1f1eb4833c 100644
--- a/www/customWaterfall.php
+++ b/www/customWaterfall.php
@@ -17,164 +17,171 @@
 $testRunResults = TestRunResults::fromFiles($testInfo, $run, $cached, null);
 $data = loadPageRunData($testPath, $run, $cached, $test['testinfo']);
 
-$page_keywords = array('Custom','Waterfall','WebPageTest','Website Speed Test');
+$page_keywords = array('Custom', 'Waterfall', 'WebPageTest', 'Website Speed Test');
 $page_description = "Website speed test custom waterfall$testLabel";
 ?>
 <!DOCTYPE html>
 <html lang="en-us">
-    <head>
-        <title>WebPageTest Custom Waterfall<?php echo $testLabel; ?></title>
-        <?php $gaTemplate = 'Custom Waterfall';
-        include('head.inc'); ?>
-    </head>
-    <body id="custom-waterfall">
-            <?php
-            $tab = null;
-            include 'header.inc';
-            ?>
-            <div class="customwaterfall_hed">
-                <h1>Generate a Custom Waterfall</h1>
-                <details open class="box details_panel">
-                    <summary  class="details_panel_hed"><span><i class="icon_plus"></i> <span>Waterfall Settings</span></span></summary>
-                    <form class="details_panel_content" name="urlEntry" action="javascript:UpdateWaterfall();" method="GET">
-                        <fieldset>
-                            <legend>Chart Type</legend>
-                                <label><input type="radio" name="type" value="waterfall" checked>Waterfall</label>
-                                <label><input type="radio" name="type" value="connection"> Connection View</label>
-                        </fieldset>
-                        <fieldset>
-                            <legend>Chart Coloring</legend>
-                            <label><input type="radio" name="coloring" value="classic"> Classic</label>
-                            <label><input type="radio" name="coloring" value="mime" checked="checked"> By MIME Type</label>
-                        </fieldset> 
-                        <fieldset>
-                            <label>Image Width <em>(Pixels, 300-2000)</em>: <input id="width" type="text" name="width" style="width:3em" value="930"></label>
-                            <label>Maximum Time <em>(In seconds, leave blank for automatic)</em>: <input id="max" type="text" name="max" style="width:2em" value=""></label>
-                            <label>Requests <em>(i.e. 1,2,3,4-9,8)</em>: <input id="requests" type="text" name="requests" value=""></label>
-                        </fieldset>
-                        <fieldset>
-                            <legend>Show/Hide Extras</legend>
-                            <label><input id="showUT" type="checkbox" checked>Lines for User Timing Marks</label>
-                            <label><input id="showCPU" type="checkbox" checked>CPU Utilization</label>
-                            <label><input id="showBW" type="checkbox" checked>Bandwidth Utilization</label>
-                            <label><input id="showDots" type="checkbox" checked>Ellipsis (...) for missing items</label>
-                            <label><input id="showLabels" type="checkbox" checked>Labels for requests (URL)</label>
-                            <label><input id="showChunks" type="checkbox" checked>Download chunks</label>
-                            <label><input id="showJS" type="checkbox" checked>JS Execution chunks</label>
-                            <label><input id="showWait" type="checkbox" checked>Wait Time</label>
-                         </fieldset>
-                        <button id="update" onclick="javascript:UpdateWaterfall();">Update Waterfall</button><br>
-                    </form>
-                </details>
-            </div>
-            <div class="box">
-                
-            <?php
 
-            $waterfallSnippet = new WaterfallViewHtmlSnippet($testInfo, $testRunResults->getStepResult(1));
-                        echo $waterfallSnippet->create(true, '&cpu=1&bw=1&ut=1&mime=1&js=1&wait=1');
+<head>
+    <title>WebPageTest Custom Waterfall<?php echo $testLabel; ?></title>
+    <?php $gaTemplate = 'Custom Waterfall';
+    include('head.inc'); ?>
+</head>
 
-                $extension = 'php';
-            if (FRIENDLY_URLS) {
-                $extension = 'png';
-            }
-                echo "<div class=\"waterfall-container\"><img id=\"waterfallImage\" style=\"display: block; margin-left: auto; margin-right: auto;\" alt=\"Waterfall\" src=\"/waterfall.$extension?test=$id&run=$run&cached=$cached&step=$step&cpu=1&bw=1&ut=1&mime=1&js=1&wait=1\"></div>";
-                echo "<p class=\"customwaterfall_download\"><a class=\"pill\" download href=\"/waterfall.$extension?test=$id&run=$run&cached=$cached&step=$step&cpu=1&bw=1&ut=1&mime=1&js=1&wait=1\">Download Waterfall Image</a></p>";
+<body id="custom-waterfall">
+    <?php
+    $tab = null;
+    include 'header.inc';
+    ?>
+    <div class="customwaterfall_hed">
+        <h1>Generate a Custom Waterfall</h1>
+        <details open class="box details_panel">
+            <summary class="details_panel_hed"><span><i class="icon_plus"></i> <span>Waterfall Settings</span></span></summary>
+            <form class="details_panel_content" name="urlEntry" action="javascript:UpdateWaterfall();" method="GET">
+                <fieldset>
+                    <legend>Chart Type</legend>
+                    <label><input type="radio" name="type" value="waterfall" checked>Waterfall</label>
+                    <label><input type="radio" name="type" value="connection"> Connection View</label>
+                </fieldset>
+                <fieldset>
+                    <legend>Chart Coloring</legend>
+                    <label><input type="radio" name="coloring" value="classic"> Classic</label>
+                    <label><input type="radio" name="coloring" value="mime" checked="checked"> By MIME Type</label>
+                </fieldset>
+                <fieldset>
+                    <label>Image Width <em>(Pixels, 300-2000)</em>: <input id="width" type="text" name="width" style="width:3em" value="930"></label>
+                    <label>Maximum Time <em>(In seconds, leave blank for automatic)</em>: <input id="max" type="text" name="max" style="width:2em" value=""></label>
+                    <label>Requests <em>(i.e. 1,2,3,4-9,8)</em>: <input id="requests" type="text" name="requests" value=""></label>
+                </fieldset>
+                <fieldset>
+                    <legend>Show/Hide Extras</legend>
+                    <label><input id="showUT" type="checkbox" checked>Lines for User Timing Marks</label>
+                    <label><input id="showCPU" type="checkbox" checked>CPU Utilization</label>
+                    <label><input id="showBW" type="checkbox" checked>Bandwidth Utilization</label>
+                    <label><input id="showLT" type="checkbox" checked>Long tasks</label>
+                    <label><input id="showDots" type="checkbox" checked>Ellipsis (...) for missing items</label>
+                    <label><input id="showLabels" type="checkbox" checked>Labels for requests (URL)</label>
+                    <label><input id="showChunks" type="checkbox" checked>Download chunks</label>
+                    <label><input id="showJS" type="checkbox" checked>JS Execution chunks</label>
+                    <label><input id="showWait" type="checkbox" checked>Wait Time</label>
+                </fieldset>
+                <button id="update" onclick="javascript:UpdateWaterfall();">Update Waterfall</button><br>
+            </form>
+        </details>
+    </div>
+    <div class="box">
 
-            ?>
-            </div>
-            <?php include('footer.inc'); ?>
-        
-
-        <script>
-            $(document).ready(function(){
-
-                // handle when the selection changes for the location
-                $("input[name=type]").click(function(){
-                    // disable the requests for connection view
-                    var type = $('input[name=type]:checked').val();
-                    if( type == 'connection' )
-                        $('#requests').attr("disabled", "disabled");
-                    else
-                        $('#requests').removeAttr("disabled");
-
-                    UpdateWaterfall();
-                });
-
-                $("input[name=coloring], input[type=checkbox]").click( UpdateWaterfall );
-                $("input[type=text]:not(#requests)").on( "input", UpdateWaterfall );
-                $("input#requests").on( "change", UpdateWaterfall );
-
-
-                // reset the wait cursor when the image loads
-                $('#waterfallImage').load(function(){
-                    $('body').css('cursor', 'default');
-                });
-            });
+        <?php
+
+        $waterfallSnippet = new WaterfallViewHtmlSnippet($testInfo, $testRunResults->getStepResult(1));
+        echo $waterfallSnippet->create(true, '&cpu=1&bw=1&lt=1&ut=1&mime=1&js=1&wait=1');
 
-            function UpdateWaterfall()
-            {
-                $('body').css('cursor', 'wait');
+        $extension = 'php';
+        if (FRIENDLY_URLS) {
+            $extension = 'png';
+        }
+        echo "<div class=\"waterfall-container\"><img id=\"waterfallImage\" style=\"display: block; margin-left: auto; margin-right: auto;\" alt=\"Waterfall\" src=\"/waterfall.$extension?test=$id&run=$run&cached=$cached&step=$step&cpu=1&bw=1&lt=1&ut=1&mime=1&js=1&wait=1\"></div>";
+        echo "<p class=\"customwaterfall_download\"><a class=\"pill\" download href=\"/waterfall.$extension?test=$id&run=$run&cached=$cached&step=$step&cpu=1&bw=1&lt=1&ut=1&mime=1&js=1&wait=1\">Download Waterfall Image</a></p>";
+
+        ?>
+    </div>
+    <?php include('footer.inc'); ?>
+
+
+    <script>
+        $(document).ready(function() {
+
+            // handle when the selection changes for the location
+            $("input[name=type]").click(function() {
+                // disable the requests for connection view
                 var type = $('input[name=type]:checked').val();
-                var coloring = $('input[name=coloring]:checked').val();
-                var mime = 0;
-                if (coloring == 'mime')
-                    mime = 1;
-                var width = $('#width').val();
-                var max = $('#max').val();
-                var requests = $('#requests').val();
-                var showUT = 0;
-                if( $('#showUT').attr('checked') )
-                    showUT = 1;
-                var showCPU = 0;
-                if( $('#showCPU').attr('checked') )
-                    showCPU = 1;
-                var showBW = 0;
-                if( $('#showBW').attr('checked') )
-                    showBW = 1;
-                var showDots = 0;
-                if( $('#showDots').attr('checked') )
-                    showDots = 1;
-                var showLabels = 0;
-                if( $('#showLabels').attr('checked') )
-                    showLabels = 1;
-                var showChunks = 0;
-                if( $('#showChunks').attr('checked') )
-                    showChunks = 1;
-                var showJS = 0;
-                if( $('#showJS').attr('checked') )
-                    showJS = 1;
-                var showWait = 0;
-                if( $('#showWait').attr('checked') )
-                    showWait = 1;
-                <?php
-                echo "var testId='$id';\n";
-                echo "var testRun='$run';\n";
-                echo "var cached='$cached';\n";
-                echo "var extension='$extension';\n";
-                echo "var step='$step';\n";
-                ?>
-
-                var src = '/waterfall.' + extension + '?test=' + testId +
-                          '&run=' + testRun +
-                          '&cached=' + cached +
-                          '&step=' + step +
-                          '&max=' + max +
-                          '&width=' + width +
-                          '&type=' + type +
-                          '&mime=' + mime +
-                          '&ut=' + showUT +
-                          '&cpu=' + showCPU +
-                          '&bw=' + showBW +
-                          '&dots=' + showDots +
-                          '&labels=' + showLabels +
-                          '&chunks=' + showChunks +
-                          '&js=' + showJS +
-                          '&wait=' + showWait +
-                          '&requests=' + requests;
-                $('#waterfallImage').attr("src", src);
-                $('.customwaterfall_download a').attr("href", src);
-            };
-        </script>
-    </body>
-</html>
+                if (type == 'connection')
+                    $('#requests').attr("disabled", "disabled");
+                else
+                    $('#requests').removeAttr("disabled");
+
+                UpdateWaterfall();
+            });
+
+            $("input[name=coloring], input[type=checkbox]").click(UpdateWaterfall);
+            $("input[type=text]:not(#requests)").on("input", UpdateWaterfall);
+            $("input#requests").on("change", UpdateWaterfall);
+
+
+            // reset the wait cursor when the image loads
+            $('#waterfallImage').load(function() {
+                $('body').css('cursor', 'default');
+            });
+        });
+
+        function UpdateWaterfall() {
+            $('body').css('cursor', 'wait');
+            var type = $('input[name=type]:checked').val();
+            var coloring = $('input[name=coloring]:checked').val();
+            var mime = 0;
+            if (coloring == 'mime')
+                mime = 1;
+            var width = $('#width').val();
+            var max = $('#max').val();
+            var requests = $('#requests').val();
+            var showUT = 0;
+            if ($('#showUT').attr('checked'))
+                showUT = 1;
+            var showCPU = 0;
+            if ($('#showCPU').attr('checked'))
+                showCPU = 1;
+            var showBW = 0;
+            if ($('#showBW').attr('checked'))
+                showBW = 1;
+            var showLT = 0;
+            if ($('#showLT').attr('checked'))
+                showLT = 1;
+            var showDots = 0;
+            if ($('#showDots').attr('checked'))
+                showDots = 1;
+            var showLabels = 0;
+            if ($('#showLabels').attr('checked'))
+                showLabels = 1;
+            var showChunks = 0;
+            if ($('#showChunks').attr('checked'))
+                showChunks = 1;
+            var showJS = 0;
+            if ($('#showJS').attr('checked'))
+                showJS = 1;
+            var showWait = 0;
+            if ($('#showWait').attr('checked'))
+                showWait = 1;
+            <?php
+            echo "var testId='$id';\n";
+            echo "var testRun='$run';\n";
+            echo "var cached='$cached';\n";
+            echo "var extension='$extension';\n";
+            echo "var step='$step';\n";
+            ?>
+
+            var src = '/waterfall.' + extension + '?test=' + testId +
+                '&run=' + testRun +
+                '&cached=' + cached +
+                '&step=' + step +
+                '&max=' + max +
+                '&width=' + width +
+                '&type=' + type +
+                '&mime=' + mime +
+                '&ut=' + showUT +
+                '&cpu=' + showCPU +
+                '&bw=' + showBW +
+                '&lt=' + showLT +
+                '&dots=' + showDots +
+                '&labels=' + showLabels +
+                '&chunks=' + showChunks +
+                '&js=' + showJS +
+                '&wait=' + showWait +
+                '&requests=' + requests;
+            $('#waterfallImage').attr("src", src);
+            $('.customwaterfall_download a').attr("href", src);
+        };
+    </script>
+</body>
+
+</html>
\ No newline at end of file
diff --git a/www/header.inc b/www/header.inc
index c9d86675f0..e2c7875cd3 100644
--- a/www/header.inc
+++ b/www/header.inc
@@ -63,9 +63,9 @@ if (!defined('EMBED')) {
     $alert_expiration = Util::getSetting('alert_expiration');
     if (isset($client_error)) {
         echo '<div class="error-banner">' . $client_error . '</div>';
-    } elseif ($alert && $alert_expiration && new DateTime() < new DateTime($alert_expiration)) {
+    } elseif (!defined('NOBANNER') && $alert && $alert_expiration && new DateTime() < new DateTime($alert_expiration)) {
         echo '<div class="alert-banner">' . $alert . '</div>';
-    } elseif ($alert && empty($alert_expiration)) {
+    } elseif (!defined('NOBANNER') && $alert && empty($alert_expiration)) {
         echo '<div class="alert-banner">' . $alert . '</div>';
     }
 
@@ -79,14 +79,9 @@ if (!defined('EMBED')) {
 <div id="main">
 
 <?php
-
-
-
-
-
-
 //If we're looking at a test result, include the extra header section and sub-menu
 if (!strcasecmp('Test Result', $tab) && (!isset($nosubheader) || !@$nosubheader) && !defined('EMBED')) {
+
     // make sure the test is actually complete
     if (isset($test['test']['completeTime'])) {
         if (!isset($testResults) || !isset($testInfo)) {
@@ -409,6 +404,7 @@ if (!strcasecmp('Test Result', $tab) && (!isset($nosubheader) || !@$nosubheader)
 
             $tabs['Content'] = $menuUrlGenerator->resultPage("breakdown", $endParams);
             $tabs['Domains'] = $menuUrlGenerator->resultPage("domains", $endParams);
+            $tabs['Console Log'] = $menuUrlGenerator->resultPage("consolelog", $endParams);
             if ($gradedRunResults->hasBreakdownTimeline()) {
             // currently only supported by standard urls
                 $menuStandardUrlGenerator = UrlGenerator::create(false, "", $id, $run, !empty($cached));
diff --git a/www/js/codeflask.min.js b/www/js/codeflask.min.js
deleted file mode 100644
index d186f85307..0000000000
--- a/www/js/codeflask.min.js
+++ /dev/null
@@ -1,1 +0,0 @@
-!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t():"function"==typeof define&&define.amd?define(t):e.CodeFlask=t()}(this,function(){"use strict";var e,t,n,a='"SFMono-Regular", Consolas, "Liberation Mono", Menlo, Courier, monospace',s="\n  .codeflask {\n    position: absolute;\n    width: 100%;\n    height: 100%;\n    overflow: hidden;\n  }\n\n  .codeflask, .codeflask * {\n    box-sizing: border-box;\n  }\n\n  .codeflask__pre {\n    pointer-events: none;\n    z-index: 3;\n    overflow: hidden;\n  }\n\n  .codeflask__textarea {\n    background: none;\n    border: none;\n    color: "+(e="caret-color",t="#000",("undefined"!=typeof CSS?CSS.supports(e,t):"undefined"!=typeof document&&(n=(n=e).split("-").filter(function(e){return!!e}).map(function(e){return e[0].toUpperCase()+e.substr(1)}).join(""))[0].toLowerCase()+n.substr(1)in document.body.style)?"#fff":"#ccc")+";\n    z-index: 1;\n    resize: none;\n    font-family: "+a+";\n    -webkit-appearance: pre;\n    caret-color: #111;\n    z-index: 2;\n    width: 100%;\n    height: 100%;\n  }\n\n  .codeflask--has-line-numbers .codeflask__textarea {\n    width: calc(100% - 40px);\n  }\n\n  .codeflask__code {\n    display: block;\n    font-family: "+a+";\n    overflow: hidden;\n  }\n\n  .codeflask__flatten {\n    padding: 10px;\n    font-size: 13px;\n    line-height: 20px;\n    white-space: pre;\n    position: absolute;\n    top: 0;\n    left: 0;\n    overflow: auto;\n    margin: 0 !important;\n    outline: none;\n    text-align: left;\n  }\n\n  .codeflask--has-line-numbers .codeflask__flatten {\n    width: calc(100% - 40px);\n    left: 40px;\n  }\n\n  .codeflask__line-highlight {\n    position: absolute;\n    top: 10px;\n    left: 0;\n    width: 100%;\n    height: 20px;\n    background: rgba(0,0,0,0.1);\n    z-index: 1;\n  }\n\n  .codeflask__lines {\n    padding: 10px 4px;\n    font-size: 12px;\n    line-height: 20px;\n    font-family: 'Cousine', monospace;\n    position: absolute;\n    left: 0;\n    top: 0;\n    width: 40px;\n    height: 100%;\n    text-align: right;\n    color: #999;\n    z-index: 2;\n  }\n\n  .codeflask__lines__line {\n    display: block;\n  }\n\n  .codeflask.codeflask--has-line-numbers {\n    padding-left: 40px;\n  }\n\n  .codeflask.codeflask--has-line-numbers:before {\n    content: '';\n    position: absolute;\n    left: 0;\n    top: 0;\n    width: 40px;\n    height: 100%;\n    background: #eee;\n    z-index: 1;\n  }\n";function i(e,t,n){var a=t||"codeflask-style",s=n||document.head;if(!e)return!1;if(document.getElementById(a))return!0;var i=document.createElement("style");return i.innerHTML=e,i.id=a,s.appendChild(i),!0}var r={"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;","/":"&#x2F;","`":"&#x60;","=":"&#x3D;"};function o(e){return String(e).replace(/[&<>"'`=/]/g,function(e){return r[e]})}var l="undefined"!=typeof globalThis?globalThis:"undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof self?self:{};var c,u=(function(e){var t=function(e){var t=/\blang(?:uage)?-([\w-]+)\b/i,n=0,a={manual:e.Prism&&e.Prism.manual,disableWorkerMessageHandler:e.Prism&&e.Prism.disableWorkerMessageHandler,util:{encode:function(e){return e instanceof s?new s(e.type,a.util.encode(e.content),e.alias):Array.isArray(e)?e.map(a.util.encode):e.replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/\u00a0/g," ")},type:function(e){return Object.prototype.toString.call(e).slice(8,-1)},objId:function(e){return e.__id||Object.defineProperty(e,"__id",{value:++n}),e.__id},clone:function e(t,n){var s,i,r=a.util.type(t);switch(n=n||{},r){case"Object":if(i=a.util.objId(t),n[i])return n[i];for(var o in s={},n[i]=s,t)t.hasOwnProperty(o)&&(s[o]=e(t[o],n));return s;case"Array":return i=a.util.objId(t),n[i]?n[i]:(s=[],n[i]=s,t.forEach(function(t,a){s[a]=e(t,n)}),s);default:return t}}},languages:{extend:function(e,t){var n=a.util.clone(a.languages[e]);for(var s in t)n[s]=t[s];return n},insertBefore:function(e,t,n,s){var i=(s=s||a.languages)[e],r={};for(var o in i)if(i.hasOwnProperty(o)){if(o==t)for(var l in n)n.hasOwnProperty(l)&&(r[l]=n[l]);n.hasOwnProperty(o)||(r[o]=i[o])}var c=s[e];return s[e]=r,a.languages.DFS(a.languages,function(t,n){n===c&&t!=e&&(this[t]=r)}),r},DFS:function e(t,n,s,i){i=i||{};var r=a.util.objId;for(var o in t)if(t.hasOwnProperty(o)){n.call(t,o,t[o],s||o);var l=t[o],c=a.util.type(l);"Object"!==c||i[r(l)]?"Array"!==c||i[r(l)]||(i[r(l)]=!0,e(l,n,o,i)):(i[r(l)]=!0,e(l,n,null,i))}}},plugins:{},highlightAll:function(e,t){a.highlightAllUnder(document,e,t)},highlightAllUnder:function(e,t,n){var s={callback:n,selector:'code[class*="language-"], [class*="language-"] code, code[class*="lang-"], [class*="lang-"] code'};a.hooks.run("before-highlightall",s);for(var i,r=s.elements||e.querySelectorAll(s.selector),o=0;i=r[o++];)a.highlightElement(i,!0===t,s.callback)},highlightElement:function(n,s,i){for(var r,o,l=n;l&&!t.test(l.className);)l=l.parentNode;l&&(r=(l.className.match(t)||[,""])[1].toLowerCase(),o=a.languages[r]),n.className=n.className.replace(t,"").replace(/\s+/g," ")+" language-"+r,n.parentNode&&(l=n.parentNode,/pre/i.test(l.nodeName)&&(l.className=l.className.replace(t,"").replace(/\s+/g," ")+" language-"+r));var c={element:n,language:r,grammar:o,code:n.textContent},u=function(e){c.highlightedCode=e,a.hooks.run("before-insert",c),c.element.innerHTML=c.highlightedCode,a.hooks.run("after-highlight",c),a.hooks.run("complete",c),i&&i.call(c.element)};if(a.hooks.run("before-sanity-check",c),c.code)if(a.hooks.run("before-highlight",c),c.grammar)if(s&&e.Worker){var d=new Worker(a.filename);d.onmessage=function(e){u(e.data)},d.postMessage(JSON.stringify({language:c.language,code:c.code,immediateClose:!0}))}else u(a.highlight(c.code,c.grammar,c.language));else u(a.util.encode(c.code));else a.hooks.run("complete",c)},highlight:function(e,t,n){var i={code:e,grammar:t,language:n};return a.hooks.run("before-tokenize",i),i.tokens=a.tokenize(i.code,i.grammar),a.hooks.run("after-tokenize",i),s.stringify(a.util.encode(i.tokens),i.language)},matchGrammar:function(e,t,n,i,r,o,l){for(var c in n)if(n.hasOwnProperty(c)&&n[c]){if(c==l)return;var u=n[c];u="Array"===a.util.type(u)?u:[u];for(var d=0;d<u.length;++d){var p=u[d],h=p.inside,g=!!p.lookbehind,f=!!p.greedy,b=0,m=p.alias;if(f&&!p.pattern.global){var y=p.pattern.toString().match(/[imuy]*$/)[0];p.pattern=RegExp(p.pattern.source,y+"g")}p=p.pattern||p;for(var k=i,x=r;k<t.length;x+=t[k].length,++k){var v=t[k];if(t.length>e.length)return;if(!(v instanceof s)){if(f&&k!=t.length-1){if(p.lastIndex=x,!(S=p.exec(e)))break;for(var w=S.index+(g?S[1].length:0),F=S.index+S[0].length,A=k,C=x,T=t.length;A<T&&(C<F||!t[A].type&&!t[A-1].greedy);++A)w>=(C+=t[A].length)&&(++k,x=C);if(t[k]instanceof s)continue;_=A-k,v=e.slice(x,C),S.index-=x}else{p.lastIndex=0;var S=p.exec(v),_=1}if(S){g&&(b=S[1]?S[1].length:0);F=(w=S.index+b)+(S=S[0].slice(b)).length;var L=v.slice(0,w),E=v.slice(F),N=[k,_];L&&(++k,x+=L.length,N.push(L));var j=new s(c,h?a.tokenize(S,h):S,m,S,f);if(N.push(j),E&&N.push(E),Array.prototype.splice.apply(t,N),1!=_&&a.matchGrammar(e,t,n,k,x,!0,c),o)break}else if(o)break}}}}},tokenize:function(e,t){var n=[e],s=t.rest;if(s){for(var i in s)t[i]=s[i];delete t.rest}return a.matchGrammar(e,n,t,0,0,!1),n},hooks:{all:{},add:function(e,t){var n=a.hooks.all;n[e]=n[e]||[],n[e].push(t)},run:function(e,t){var n=a.hooks.all[e];if(n&&n.length)for(var s,i=0;s=n[i++];)s(t)}},Token:s};function s(e,t,n,a,s){this.type=e,this.content=t,this.alias=n,this.length=0|(a||"").length,this.greedy=!!s}if(e.Prism=a,s.stringify=function(e,t,n){if("string"==typeof e)return e;if(Array.isArray(e))return e.map(function(n){return s.stringify(n,t,e)}).join("");var i={type:e.type,content:s.stringify(e.content,t,n),tag:"span",classes:["token",e.type],attributes:{},language:t,parent:n};if(e.alias){var r=Array.isArray(e.alias)?e.alias:[e.alias];Array.prototype.push.apply(i.classes,r)}a.hooks.run("wrap",i);var o=Object.keys(i.attributes).map(function(e){return e+'="'+(i.attributes[e]||"").replace(/"/g,"&quot;")+'"'}).join(" ");return"<"+i.tag+' class="'+i.classes.join(" ")+'"'+(o?" "+o:"")+">"+i.content+"</"+i.tag+">"},!e.document)return e.addEventListener?(a.disableWorkerMessageHandler||e.addEventListener("message",function(t){var n=JSON.parse(t.data),s=n.language,i=n.code,r=n.immediateClose;e.postMessage(a.highlight(i,a.languages[s],s)),r&&e.close()},!1),a):a;var i=document.currentScript||[].slice.call(document.getElementsByTagName("script")).pop();return i&&(a.filename=i.src,a.manual||i.hasAttribute("data-manual")||("loading"!==document.readyState?window.requestAnimationFrame?window.requestAnimationFrame(a.highlightAll):window.setTimeout(a.highlightAll,16):document.addEventListener("DOMContentLoaded",a.highlightAll))),a}("undefined"!=typeof window?window:"undefined"!=typeof WorkerGlobalScope&&self instanceof WorkerGlobalScope?self:{});e.exports&&(e.exports=t),void 0!==l&&(l.Prism=t),t.languages.markup={comment:/<!--[\s\S]*?-->/,prolog:/<\?[\s\S]+?\?>/,doctype:/<!DOCTYPE[\s\S]+?>/i,cdata:/<!\[CDATA\[[\s\S]*?]]>/i,tag:{pattern:/<\/?(?!\d)[^\s>\/=$<%]+(?:\s(?:\s*[^\s>\/=]+(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s'">=]+(?=[\s>]))|(?=[\s/>])))+)?\s*\/?>/i,greedy:!0,inside:{tag:{pattern:/^<\/?[^\s>\/]+/i,inside:{punctuation:/^<\/?/,namespace:/^[^\s>\/:]+:/}},"attr-value":{pattern:/=\s*(?:"[^"]*"|'[^']*'|[^\s'">=]+)/i,inside:{punctuation:[/^=/,{pattern:/^(\s*)["']|["']$/,lookbehind:!0}]}},punctuation:/\/?>/,"attr-name":{pattern:/[^\s>\/]+/,inside:{namespace:/^[^\s>\/:]+:/}}}},entity:/&#?[\da-z]{1,8};/i},t.languages.markup.tag.inside["attr-value"].inside.entity=t.languages.markup.entity,t.hooks.add("wrap",function(e){"entity"===e.type&&(e.attributes.title=e.content.replace(/&amp;/,"&"))}),Object.defineProperty(t.languages.markup.tag,"addInlined",{value:function(e,n){var a={};a["language-"+n]={pattern:/(^<!\[CDATA\[)[\s\S]+?(?=\]\]>$)/i,lookbehind:!0,inside:t.languages[n]},a.cdata=/^<!\[CDATA\[|\]\]>$/i;var s={"included-cdata":{pattern:/<!\[CDATA\[[\s\S]*?\]\]>/i,inside:a}};s["language-"+n]={pattern:/[\s\S]+/,inside:t.languages[n]};var i={};i[e]={pattern:RegExp(/(<__[\s\S]*?>)(?:<!\[CDATA\[[\s\S]*?\]\]>\s*|[\s\S])*?(?=<\/__>)/.source.replace(/__/g,e),"i"),lookbehind:!0,greedy:!0,inside:s},t.languages.insertBefore("markup","cdata",i)}}),t.languages.xml=t.languages.extend("markup",{}),t.languages.html=t.languages.markup,t.languages.mathml=t.languages.markup,t.languages.svg=t.languages.markup,function(e){var t=/("|')(?:\\(?:\r\n|[\s\S])|(?!\1)[^\\\r\n])*\1/;e.languages.css={comment:/\/\*[\s\S]*?\*\//,atrule:{pattern:/@[\w-]+?[\s\S]*?(?:;|(?=\s*\{))/i,inside:{rule:/@[\w-]+/}},url:RegExp("url\\((?:"+t.source+"|.*?)\\)","i"),selector:RegExp("[^{}\\s](?:[^{};\"']|"+t.source+")*?(?=\\s*\\{)"),string:{pattern:t,greedy:!0},property:/[-_a-z\xA0-\uFFFF][-\w\xA0-\uFFFF]*(?=\s*:)/i,important:/!important\b/i,function:/[-a-z0-9]+(?=\()/i,punctuation:/[(){};:,]/},e.languages.css.atrule.inside.rest=e.languages.css;var n=e.languages.markup;n&&(n.tag.addInlined("style","css"),e.languages.insertBefore("inside","attr-value",{"style-attr":{pattern:/\s*style=("|')(?:\\[\s\S]|(?!\1)[^\\])*\1/i,inside:{"attr-name":{pattern:/^\s*style/i,inside:n.tag.inside},punctuation:/^\s*=\s*['"]|['"]\s*$/,"attr-value":{pattern:/.+/i,inside:e.languages.css}},alias:"language-css"}},n.tag))}(t),t.languages.clike={comment:[{pattern:/(^|[^\\])\/\*[\s\S]*?(?:\*\/|$)/,lookbehind:!0},{pattern:/(^|[^\\:])\/\/.*/,lookbehind:!0,greedy:!0}],string:{pattern:/(["'])(?:\\(?:\r\n|[\s\S])|(?!\1)[^\\\r\n])*\1/,greedy:!0},"class-name":{pattern:/((?:\b(?:class|interface|extends|implements|trait|instanceof|new)\s+)|(?:catch\s+\())[\w.\\]+/i,lookbehind:!0,inside:{punctuation:/[.\\]/}},keyword:/\b(?:if|else|while|do|for|return|in|instanceof|function|new|try|throw|catch|finally|null|break|continue)\b/,boolean:/\b(?:true|false)\b/,function:/\w+(?=\()/,number:/\b0x[\da-f]+\b|(?:\b\d+\.?\d*|\B\.\d+)(?:e[+-]?\d+)?/i,operator:/--?|\+\+?|!=?=?|<=?|>=?|==?=?|&&?|\|\|?|\?|\*|\/|~|\^|%/,punctuation:/[{}[\];(),.:]/},t.languages.javascript=t.languages.extend("clike",{"class-name":[t.languages.clike["class-name"],{pattern:/(^|[^$\w\xA0-\uFFFF])[_$A-Z\xA0-\uFFFF][$\w\xA0-\uFFFF]*(?=\.(?:prototype|constructor))/,lookbehind:!0}],keyword:[{pattern:/((?:^|})\s*)(?:catch|finally)\b/,lookbehind:!0},{pattern:/(^|[^.])\b(?:as|async(?=\s*(?:function\b|\(|[$\w\xA0-\uFFFF]|$))|await|break|case|class|const|continue|debugger|default|delete|do|else|enum|export|extends|for|from|function|get|if|implements|import|in|instanceof|interface|let|new|null|of|package|private|protected|public|return|set|static|super|switch|this|throw|try|typeof|undefined|var|void|while|with|yield)\b/,lookbehind:!0}],number:/\b(?:(?:0[xX][\dA-Fa-f]+|0[bB][01]+|0[oO][0-7]+)n?|\d+n|NaN|Infinity)\b|(?:\b\d+\.?\d*|\B\.\d+)(?:[Ee][+-]?\d+)?/,function:/[_$a-zA-Z\xA0-\uFFFF][$\w\xA0-\uFFFF]*(?=\s*(?:\.\s*(?:apply|bind|call)\s*)?\()/,operator:/-[-=]?|\+[+=]?|!=?=?|<<?=?|>>?>?=?|=(?:==?|>)?|&[&=]?|\|[|=]?|\*\*?=?|\/=?|~|\^=?|%=?|\?|\.{3}/}),t.languages.javascript["class-name"][0].pattern=/(\b(?:class|interface|extends|implements|instanceof|new)\s+)[\w.\\]+/,t.languages.insertBefore("javascript","keyword",{regex:{pattern:/((?:^|[^$\w\xA0-\uFFFF."'\])\s])\s*)\/(\[(?:[^\]\\\r\n]|\\.)*]|\\.|[^/\\\[\r\n])+\/[gimyu]{0,5}(?=\s*($|[\r\n,.;})\]]))/,lookbehind:!0,greedy:!0},"function-variable":{pattern:/[_$a-zA-Z\xA0-\uFFFF][$\w\xA0-\uFFFF]*(?=\s*[=:]\s*(?:async\s*)?(?:\bfunction\b|(?:\((?:[^()]|\([^()]*\))*\)|[_$a-zA-Z\xA0-\uFFFF][$\w\xA0-\uFFFF]*)\s*=>))/,alias:"function"},parameter:[{pattern:/(function(?:\s+[_$A-Za-z\xA0-\uFFFF][$\w\xA0-\uFFFF]*)?\s*\(\s*)(?!\s)(?:[^()]|\([^()]*\))+?(?=\s*\))/,lookbehind:!0,inside:t.languages.javascript},{pattern:/[_$a-z\xA0-\uFFFF][$\w\xA0-\uFFFF]*(?=\s*=>)/i,inside:t.languages.javascript},{pattern:/(\(\s*)(?!\s)(?:[^()]|\([^()]*\))+?(?=\s*\)\s*=>)/,lookbehind:!0,inside:t.languages.javascript},{pattern:/((?:\b|\s|^)(?!(?:as|async|await|break|case|catch|class|const|continue|debugger|default|delete|do|else|enum|export|extends|finally|for|from|function|get|if|implements|import|in|instanceof|interface|let|new|null|of|package|private|protected|public|return|set|static|super|switch|this|throw|try|typeof|undefined|var|void|while|with|yield)(?![$\w\xA0-\uFFFF]))(?:[_$A-Za-z\xA0-\uFFFF][$\w\xA0-\uFFFF]*\s*)\(\s*)(?!\s)(?:[^()]|\([^()]*\))+?(?=\s*\)\s*\{)/,lookbehind:!0,inside:t.languages.javascript}],constant:/\b[A-Z](?:[A-Z_]|\dx?)*\b/}),t.languages.insertBefore("javascript","string",{"template-string":{pattern:/`(?:\\[\s\S]|\${[^}]+}|[^\\`])*`/,greedy:!0,inside:{interpolation:{pattern:/\${[^}]+}/,inside:{"interpolation-punctuation":{pattern:/^\${|}$/,alias:"punctuation"},rest:t.languages.javascript}},string:/[\s\S]+/}}}),t.languages.markup&&t.languages.markup.tag.addInlined("script","javascript"),t.languages.js=t.languages.javascript,"undefined"!=typeof self&&self.Prism&&self.document&&document.querySelector&&(self.Prism.fileHighlight=function(e){e=e||document;var n={js:"javascript",py:"python",rb:"ruby",ps1:"powershell",psm1:"powershell",sh:"bash",bat:"batch",h:"c",tex:"latex"};Array.prototype.slice.call(e.querySelectorAll("pre[data-src]")).forEach(function(e){if(!e.hasAttribute("data-src-loaded")){for(var a,s=e.getAttribute("data-src"),i=e,r=/\blang(?:uage)?-([\w-]+)\b/i;i&&!r.test(i.className);)i=i.parentNode;if(i&&(a=(e.className.match(r)||[,""])[1]),!a){var o=(s.match(/\.(\w+)$/)||[,""])[1];a=n[o]||o}var l=document.createElement("code");l.className="language-"+a,e.textContent="",l.textContent="Loading…",e.appendChild(l);var c=new XMLHttpRequest;c.open("GET",s,!0),c.onreadystatechange=function(){4==c.readyState&&(c.status<400&&c.responseText?(l.textContent=c.responseText,t.highlightElement(l),e.setAttribute("data-src-loaded","")):c.status>=400?l.textContent="✖ Error "+c.status+" while fetching file: "+c.statusText:l.textContent="✖ Error: File does not exist or is empty")},c.send(null)}}),t.plugins.toolbar&&t.plugins.toolbar.registerButton("download-file",function(e){var t=e.element.parentNode;if(t&&/pre/i.test(t.nodeName)&&t.hasAttribute("data-src")&&t.hasAttribute("data-download-link")){var n=t.getAttribute("data-src"),a=document.createElement("a");return a.textContent=t.getAttribute("data-download-link-label")||"Download",a.setAttribute("download",""),a.href=n,a}})},document.addEventListener("DOMContentLoaded",function(){self.Prism.fileHighlight()}))}(c={exports:{}},c.exports),c.exports),d=function(e,t){if(!e)throw Error("CodeFlask expects a parameter which is Element or a String selector");if(!t)throw Error("CodeFlask expects an object containing options as second parameter");if(e.nodeType)this.editorRoot=e;else{var n=document.querySelector(e);n&&(this.editorRoot=n)}this.opts=t,this.startEditor()};return d.prototype.startEditor=function(){if(!i(s,null,this.opts.styleParent))throw Error("Failed to inject CodeFlask CSS.");this.createWrapper(),this.createTextarea(),this.createPre(),this.createCode(),this.runOptions(),this.listenTextarea(),this.populateDefault(),this.updateCode(this.code)},d.prototype.createWrapper=function(){this.code=this.editorRoot.innerHTML,this.editorRoot.innerHTML="",this.elWrapper=this.createElement("div",this.editorRoot),this.elWrapper.classList.add("codeflask")},d.prototype.createTextarea=function(){this.elTextarea=this.createElement("textarea",this.elWrapper),this.elTextarea.classList.add("codeflask__textarea","codeflask__flatten")},d.prototype.createPre=function(){this.elPre=this.createElement("pre",this.elWrapper),this.elPre.classList.add("codeflask__pre","codeflask__flatten")},d.prototype.createCode=function(){this.elCode=this.createElement("code",this.elPre),this.elCode.classList.add("codeflask__code","language-"+(this.opts.language||"html"))},d.prototype.createLineNumbers=function(){this.elLineNumbers=this.createElement("div",this.elWrapper),this.elLineNumbers.classList.add("codeflask__lines"),this.setLineNumber()},d.prototype.createElement=function(e,t){var n=document.createElement(e);return t.appendChild(n),n},d.prototype.runOptions=function(){this.opts.rtl=this.opts.rtl||!1,this.opts.tabSize=this.opts.tabSize||2,this.opts.enableAutocorrect=this.opts.enableAutocorrect||!1,this.opts.lineNumbers=this.opts.lineNumbers||!1,this.opts.defaultTheme=!1!==this.opts.defaultTheme,this.opts.areaId=this.opts.areaId||null,this.opts.ariaLabelledby=this.opts.ariaLabelledby||null,this.opts.readonly=this.opts.readonly||null,"boolean"!=typeof this.opts.handleTabs&&(this.opts.handleTabs=!0),"boolean"!=typeof this.opts.handleSelfClosingCharacters&&(this.opts.handleSelfClosingCharacters=!0),"boolean"!=typeof this.opts.handleNewLineIndentation&&(this.opts.handleNewLineIndentation=!0),!0===this.opts.rtl&&(this.elTextarea.setAttribute("dir","rtl"),this.elPre.setAttribute("dir","rtl")),!1===this.opts.enableAutocorrect&&(this.elTextarea.setAttribute("spellcheck","false"),this.elTextarea.setAttribute("autocapitalize","off"),this.elTextarea.setAttribute("autocomplete","off"),this.elTextarea.setAttribute("autocorrect","off")),this.opts.lineNumbers&&(this.elWrapper.classList.add("codeflask--has-line-numbers"),this.createLineNumbers()),this.opts.defaultTheme&&i("\n.codeflask {\n  background: #fff;\n  color: #4f559c;\n}\n\n.codeflask .token.punctuation {\n  color: #4a4a4a;\n}\n\n.codeflask .token.keyword {\n  color: #8500ff;\n}\n\n.codeflask .token.operator {\n  color: #ff5598;\n}\n\n.codeflask .token.string {\n  color: #41ad8f;\n}\n\n.codeflask .token.comment {\n  color: #9badb7;\n}\n\n.codeflask .token.function {\n  color: #8500ff;\n}\n\n.codeflask .token.boolean {\n  color: #8500ff;\n}\n\n.codeflask .token.number {\n  color: #8500ff;\n}\n\n.codeflask .token.selector {\n  color: #8500ff;\n}\n\n.codeflask .token.property {\n  color: #8500ff;\n}\n\n.codeflask .token.tag {\n  color: #8500ff;\n}\n\n.codeflask .token.attr-value {\n  color: #8500ff;\n}\n","theme-default",this.opts.styleParent),this.opts.areaId&&this.elTextarea.setAttribute("id",this.opts.areaId),this.opts.ariaLabelledby&&this.elTextarea.setAttribute("aria-labelledby",this.opts.ariaLabelledby),this.opts.readonly&&this.enableReadonlyMode()},d.prototype.updateLineNumbersCount=function(){for(var e="",t=1;t<=this.lineNumber;t++)e=e+'<span class="codeflask__lines__line">'+t+"</span>";this.elLineNumbers.innerHTML=e},d.prototype.listenTextarea=function(){var e=this;this.elTextarea.addEventListener("input",function(t){e.code=t.target.value,e.elCode.innerHTML=o(t.target.value),e.highlight(),setTimeout(function(){e.runUpdate(),e.setLineNumber()},1)}),this.elTextarea.addEventListener("keydown",function(t){e.handleTabs(t),e.handleSelfClosingCharacters(t),e.handleNewLineIndentation(t)}),this.elTextarea.addEventListener("scroll",function(t){e.elPre.style.transform="translate3d(-"+t.target.scrollLeft+"px, -"+t.target.scrollTop+"px, 0)",e.elLineNumbers&&(e.elLineNumbers.style.transform="translate3d(0, -"+t.target.scrollTop+"px, 0)")})},d.prototype.handleTabs=function(e){if(this.opts.handleTabs){if(9!==e.keyCode)return;e.preventDefault();var t=this.elTextarea,n=t.selectionDirection,a=t.selectionStart,s=t.selectionEnd,i=t.value,r=i.substr(0,a),o=i.substring(a,s),l=i.substring(s),c=" ".repeat(this.opts.tabSize);if(a!==s&&o.length>=c.length){var u=a-r.split("\n").pop().length,d=c.length,p=c.length;if(e.shiftKey)i.substr(u,c.length)===c?(d=-d,u>a?(o=o.substring(0,u)+o.substring(u+c.length),p=0):u===a?(d=0,p=0,o=o.substring(c.length)):(p=-p,r=r.substring(0,u)+r.substring(u+c.length))):(d=0,p=0),o=o.replace(new RegExp("\n"+c.split("").join("\\"),"g"),"\n");else r=r.substr(0,u)+c+r.substring(u,a),o=o.replace(/\n/g,"\n"+c);t.value=r+o+l,t.selectionStart=a+d,t.selectionEnd=a+o.length+p,t.selectionDirection=n}else t.value=r+c+l,t.selectionStart=a+c.length,t.selectionEnd=a+c.length;var h=t.value;this.updateCode(h),this.elTextarea.selectionEnd=s+this.opts.tabSize}},d.prototype.handleSelfClosingCharacters=function(e){if(this.opts.handleSelfClosingCharacters){var t=e.key;if(["(","[","{","<","'",'"'].includes(t)||[")","]","}",">","'",'"'].includes(t))switch(t){case"(":case")":this.closeCharacter(t);break;case"[":case"]":this.closeCharacter(t);break;case"{":case"}":this.closeCharacter(t);break;case"<":case">":case"'":case'"':this.closeCharacter(t)}}},d.prototype.setLineNumber=function(){this.lineNumber=this.code.split("\n").length,this.opts.lineNumbers&&this.updateLineNumbersCount()},d.prototype.handleNewLineIndentation=function(e){if(this.opts.handleNewLineIndentation&&13===e.keyCode){e.preventDefault();var t=this.elTextarea,n=t.selectionStart,a=t.selectionEnd,s=t.value,i=s.substr(0,n),r=s.substring(a),o=s.lastIndexOf("\n",n-1),l=o+s.slice(o+1).search(/[^ ]|$/),c=l>o?l-o:0,u=i+"\n"+" ".repeat(c)+r;t.value=u,t.selectionStart=n+c+1,t.selectionEnd=n+c+1,this.updateCode(t.value)}},d.prototype.closeCharacter=function(e){var t=this.elTextarea.selectionStart,n=this.elTextarea.selectionEnd;if(this.skipCloseChar(e)){var a=this.code.substr(n,1)===e,s=a?n+1:n,i=!a&&["'",'"'].includes(e)?e:"",r=""+this.code.substring(0,t)+i+this.code.substring(s);this.updateCode(r),this.elTextarea.selectionEnd=++this.elTextarea.selectionStart}else{var o=e;switch(e){case"(":o=String.fromCharCode(e.charCodeAt()+1);break;case"<":case"{":case"[":o=String.fromCharCode(e.charCodeAt()+2)}var l=this.code.substring(t,n),c=""+this.code.substring(0,t)+l+o+this.code.substring(n);this.updateCode(c)}this.elTextarea.selectionEnd=t},d.prototype.skipCloseChar=function(e){var t=this.elTextarea.selectionStart,n=this.elTextarea.selectionEnd,a=Math.abs(n-t)>0;return[")","}","]",">"].includes(e)||["'",'"'].includes(e)&&!a},d.prototype.updateCode=function(e){this.code=e,this.elTextarea.value=e,this.elCode.innerHTML=o(e),this.highlight(),this.setLineNumber(),setTimeout(this.runUpdate.bind(this),1)},d.prototype.updateLanguage=function(e){var t=this.opts.language;this.elCode.classList.remove("language-"+t),this.elCode.classList.add("language-"+e),this.opts.language=e,this.highlight()},d.prototype.addLanguage=function(e,t){u.languages[e]=t},d.prototype.populateDefault=function(){this.updateCode(this.code)},d.prototype.highlight=function(){u.highlightElement(this.elCode,!1)},d.prototype.onUpdate=function(e){if(e&&"[object Function]"!=={}.toString.call(e))throw Error("CodeFlask expects callback of type Function");this.updateCallBack=e},d.prototype.getCode=function(){return this.code},d.prototype.runUpdate=function(){this.updateCallBack&&this.updateCallBack(this.code)},d.prototype.enableReadonlyMode=function(){this.elTextarea.setAttribute("readonly",!0)},d.prototype.disableReadonlyMode=function(){this.elTextarea.removeAttribute("readonly")},d});
diff --git a/www/js/codeflask.module.js b/www/js/codeflask.module.js
new file mode 100644
index 0000000000..a06e7508e8
--- /dev/null
+++ b/www/js/codeflask.module.js
@@ -0,0 +1,1 @@
+var BACKGROUND_COLOR="#fff",LINE_HEIGHT="20px",FONT_SIZE="13px",defaultCssTheme="\n.codeflask {\n  background: "+BACKGROUND_COLOR+";\n  color: #4f559c;\n}\n\n.codeflask .token.punctuation {\n  color: #4a4a4a;\n}\n\n.codeflask .token.keyword {\n  color: #8500ff;\n}\n\n.codeflask .token.operator {\n  color: #ff5598;\n}\n\n.codeflask .token.string {\n  color: #41ad8f;\n}\n\n.codeflask .token.comment {\n  color: #9badb7;\n}\n\n.codeflask .token.function {\n  color: #8500ff;\n}\n\n.codeflask .token.boolean {\n  color: #8500ff;\n}\n\n.codeflask .token.number {\n  color: #8500ff;\n}\n\n.codeflask .token.selector {\n  color: #8500ff;\n}\n\n.codeflask .token.property {\n  color: #8500ff;\n}\n\n.codeflask .token.tag {\n  color: #8500ff;\n}\n\n.codeflask .token.attr-value {\n  color: #8500ff;\n}\n";function cssSupports(e,t){return"undefined"!=typeof CSS?CSS.supports(e,t):"undefined"!=typeof document&&toCamelCase(e)in document.body.style}function toCamelCase(e){return(e=e.split("-").filter(function(e){return!!e}).map(function(e){return e[0].toUpperCase()+e.substr(1)}).join(""))[0].toLowerCase()+e.substr(1)}var FONT_FAMILY='"SFMono-Regular", Consolas, "Liberation Mono", Menlo, Courier, monospace',COLOR=cssSupports("caret-color","#000")?BACKGROUND_COLOR:"#ccc",LINE_NUMBER_WIDTH="40px",editorCss="\n  .codeflask {\n    position: absolute;\n    width: 100%;\n    height: 100%;\n    overflow: hidden;\n  }\n\n  .codeflask, .codeflask * {\n    box-sizing: border-box;\n  }\n\n  .codeflask__pre {\n    pointer-events: none;\n    z-index: 3;\n    overflow: hidden;\n  }\n\n  .codeflask__textarea {\n    background: none;\n    border: none;\n    color: "+COLOR+";\n    z-index: 1;\n    resize: none;\n    font-family: "+FONT_FAMILY+";\n    -webkit-appearance: pre;\n    caret-color: #111;\n    z-index: 2;\n    width: 100%;\n    height: 100%;\n  }\n\n  .codeflask--has-line-numbers .codeflask__textarea {\n    width: calc(100% - "+LINE_NUMBER_WIDTH+");\n  }\n\n  .codeflask__code {\n    display: block;\n    font-family: "+FONT_FAMILY+";\n    overflow: hidden;\n  }\n\n  .codeflask__flatten {\n    padding: 10px;\n    font-size: "+FONT_SIZE+";\n    line-height: "+LINE_HEIGHT+";\n    white-space: pre;\n    position: absolute;\n    top: 0;\n    left: 0;\n    overflow: auto;\n    margin: 0 !important;\n    outline: none;\n    text-align: left;\n  }\n\n  .codeflask--has-line-numbers .codeflask__flatten {\n    width: calc(100% - "+LINE_NUMBER_WIDTH+");\n    left: "+LINE_NUMBER_WIDTH+";\n  }\n\n  .codeflask__line-highlight {\n    position: absolute;\n    top: 10px;\n    left: 0;\n    width: 100%;\n    height: "+LINE_HEIGHT+";\n    background: rgba(0,0,0,0.1);\n    z-index: 1;\n  }\n\n  .codeflask__lines {\n    padding: 10px 4px;\n    font-size: 12px;\n    line-height: "+LINE_HEIGHT+";\n    font-family: 'Cousine', monospace;\n    position: absolute;\n    left: 0;\n    top: 0;\n    width: "+LINE_NUMBER_WIDTH+";\n    height: 100%;\n    text-align: right;\n    color: #999;\n    z-index: 2;\n  }\n\n  .codeflask__lines__line {\n    display: block;\n  }\n\n  .codeflask.codeflask--has-line-numbers {\n    padding-left: "+LINE_NUMBER_WIDTH+";\n  }\n\n  .codeflask.codeflask--has-line-numbers:before {\n    content: '';\n    position: absolute;\n    left: 0;\n    top: 0;\n    width: "+LINE_NUMBER_WIDTH+";\n    height: 100%;\n    background: #eee;\n    z-index: 1;\n  }\n";function injectCss(e,t,n){var a=t||"codeflask-style",s=n||document.head;if(!e)return!1;if(document.getElementById(a))return!0;var o=document.createElement("style");return o.innerHTML=e,o.id=a,s.appendChild(o),!0}var entityMap={"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;","/":"&#x2F;","`":"&#x60;","=":"&#x3D;"};function escapeHtml(e){return String(e).replace(/[&<>"'`=/]/g,function(e){return entityMap[e]})}var commonjsGlobal="undefined"!=typeof globalThis?globalThis:"undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof self?self:{};function createCommonjsModule(e,t){return e(t={exports:{}},t.exports),t.exports}var prism=createCommonjsModule(function(e){var t=function(e){var t=/\blang(?:uage)?-([\w-]+)\b/i,n=0,a={manual:e.Prism&&e.Prism.manual,disableWorkerMessageHandler:e.Prism&&e.Prism.disableWorkerMessageHandler,util:{encode:function(e){return e instanceof s?new s(e.type,a.util.encode(e.content),e.alias):Array.isArray(e)?e.map(a.util.encode):e.replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/\u00a0/g," ")},type:function(e){return Object.prototype.toString.call(e).slice(8,-1)},objId:function(e){return e.__id||Object.defineProperty(e,"__id",{value:++n}),e.__id},clone:function e(t,n){var s,o,i=a.util.type(t);switch(n=n||{},i){case"Object":if(o=a.util.objId(t),n[o])return n[o];for(var r in s={},n[o]=s,t)t.hasOwnProperty(r)&&(s[r]=e(t[r],n));return s;case"Array":return o=a.util.objId(t),n[o]?n[o]:(s=[],n[o]=s,t.forEach(function(t,a){s[a]=e(t,n)}),s);default:return t}}},languages:{extend:function(e,t){var n=a.util.clone(a.languages[e]);for(var s in t)n[s]=t[s];return n},insertBefore:function(e,t,n,s){var o=(s=s||a.languages)[e],i={};for(var r in o)if(o.hasOwnProperty(r)){if(r==t)for(var l in n)n.hasOwnProperty(l)&&(i[l]=n[l]);n.hasOwnProperty(r)||(i[r]=o[r])}var c=s[e];return s[e]=i,a.languages.DFS(a.languages,function(t,n){n===c&&t!=e&&(this[t]=i)}),i},DFS:function e(t,n,s,o){o=o||{};var i=a.util.objId;for(var r in t)if(t.hasOwnProperty(r)){n.call(t,r,t[r],s||r);var l=t[r],c=a.util.type(l);"Object"!==c||o[i(l)]?"Array"!==c||o[i(l)]||(o[i(l)]=!0,e(l,n,r,o)):(o[i(l)]=!0,e(l,n,null,o))}}},plugins:{},highlightAll:function(e,t){a.highlightAllUnder(document,e,t)},highlightAllUnder:function(e,t,n){var s={callback:n,selector:'code[class*="language-"], [class*="language-"] code, code[class*="lang-"], [class*="lang-"] code'};a.hooks.run("before-highlightall",s);for(var o,i=s.elements||e.querySelectorAll(s.selector),r=0;o=i[r++];)a.highlightElement(o,!0===t,s.callback)},highlightElement:function(n,s,o){for(var i,r,l=n;l&&!t.test(l.className);)l=l.parentNode;l&&(i=(l.className.match(t)||[,""])[1].toLowerCase(),r=a.languages[i]),n.className=n.className.replace(t,"").replace(/\s+/g," ")+" language-"+i,n.parentNode&&(l=n.parentNode,/pre/i.test(l.nodeName)&&(l.className=l.className.replace(t,"").replace(/\s+/g," ")+" language-"+i));var c={element:n,language:i,grammar:r,code:n.textContent},d=function(e){c.highlightedCode=e,a.hooks.run("before-insert",c),c.element.innerHTML=c.highlightedCode,a.hooks.run("after-highlight",c),a.hooks.run("complete",c),o&&o.call(c.element)};if(a.hooks.run("before-sanity-check",c),c.code)if(a.hooks.run("before-highlight",c),c.grammar)if(s&&e.Worker){var u=new Worker(a.filename);u.onmessage=function(e){d(e.data)},u.postMessage(JSON.stringify({language:c.language,code:c.code,immediateClose:!0}))}else d(a.highlight(c.code,c.grammar,c.language));else d(a.util.encode(c.code));else a.hooks.run("complete",c)},highlight:function(e,t,n){var o={code:e,grammar:t,language:n};return a.hooks.run("before-tokenize",o),o.tokens=a.tokenize(o.code,o.grammar),a.hooks.run("after-tokenize",o),s.stringify(a.util.encode(o.tokens),o.language)},matchGrammar:function(e,t,n,o,i,r,l){for(var c in n)if(n.hasOwnProperty(c)&&n[c]){if(c==l)return;var d=n[c];d="Array"===a.util.type(d)?d:[d];for(var u=0;u<d.length;++u){var p=d[u],h=p.inside,g=!!p.lookbehind,f=!!p.greedy,m=0,b=p.alias;if(f&&!p.pattern.global){var k=p.pattern.toString().match(/[imuy]*$/)[0];p.pattern=RegExp(p.pattern.source,k+"g")}p=p.pattern||p;for(var y=o,C=i;y<t.length;C+=t[y].length,++y){var F=t[y];if(t.length>e.length)return;if(!(F instanceof s)){if(f&&y!=t.length-1){if(p.lastIndex=C,!(T=p.exec(e)))break;for(var v=T.index+(g?T[1].length:0),x=T.index+T[0].length,w=y,A=C,_=t.length;w<_&&(A<x||!t[w].type&&!t[w-1].greedy);++w)v>=(A+=t[w].length)&&(++y,C=A);if(t[y]instanceof s)continue;E=w-y,F=e.slice(C,A),T.index-=C}else{p.lastIndex=0;var T=p.exec(F),E=1}if(T){g&&(m=T[1]?T[1].length:0);x=(v=T.index+m)+(T=T[0].slice(m)).length;var L=F.slice(0,v),N=F.slice(x),S=[y,E];L&&(++y,C+=L.length,S.push(L));var I=new s(c,h?a.tokenize(T,h):T,b,T,f);if(S.push(I),N&&S.push(N),Array.prototype.splice.apply(t,S),1!=E&&a.matchGrammar(e,t,n,y,C,!0,c),r)break}else if(r)break}}}}},tokenize:function(e,t){var n=[e],s=t.rest;if(s){for(var o in s)t[o]=s[o];delete t.rest}return a.matchGrammar(e,n,t,0,0,!1),n},hooks:{all:{},add:function(e,t){var n=a.hooks.all;n[e]=n[e]||[],n[e].push(t)},run:function(e,t){var n=a.hooks.all[e];if(n&&n.length)for(var s,o=0;s=n[o++];)s(t)}},Token:s};function s(e,t,n,a,s){this.type=e,this.content=t,this.alias=n,this.length=0|(a||"").length,this.greedy=!!s}if(e.Prism=a,s.stringify=function(e,t,n){if("string"==typeof e)return e;if(Array.isArray(e))return e.map(function(n){return s.stringify(n,t,e)}).join("");var o={type:e.type,content:s.stringify(e.content,t,n),tag:"span",classes:["token",e.type],attributes:{},language:t,parent:n};if(e.alias){var i=Array.isArray(e.alias)?e.alias:[e.alias];Array.prototype.push.apply(o.classes,i)}a.hooks.run("wrap",o);var r=Object.keys(o.attributes).map(function(e){return e+'="'+(o.attributes[e]||"").replace(/"/g,"&quot;")+'"'}).join(" ");return"<"+o.tag+' class="'+o.classes.join(" ")+'"'+(r?" "+r:"")+">"+o.content+"</"+o.tag+">"},!e.document)return e.addEventListener?(a.disableWorkerMessageHandler||e.addEventListener("message",function(t){var n=JSON.parse(t.data),s=n.language,o=n.code,i=n.immediateClose;e.postMessage(a.highlight(o,a.languages[s],s)),i&&e.close()},!1),a):a;var o=document.currentScript||[].slice.call(document.getElementsByTagName("script")).pop();return o&&(a.filename=o.src,a.manual||o.hasAttribute("data-manual")||("loading"!==document.readyState?window.requestAnimationFrame?window.requestAnimationFrame(a.highlightAll):window.setTimeout(a.highlightAll,16):document.addEventListener("DOMContentLoaded",a.highlightAll))),a}("undefined"!=typeof window?window:"undefined"!=typeof WorkerGlobalScope&&self instanceof WorkerGlobalScope?self:{});e.exports&&(e.exports=t),void 0!==commonjsGlobal&&(commonjsGlobal.Prism=t),t.languages.markup={comment:/<!--[\s\S]*?-->/,prolog:/<\?[\s\S]+?\?>/,doctype:/<!DOCTYPE[\s\S]+?>/i,cdata:/<!\[CDATA\[[\s\S]*?]]>/i,tag:{pattern:/<\/?(?!\d)[^\s>\/=$<%]+(?:\s(?:\s*[^\s>\/=]+(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s'">=]+(?=[\s>]))|(?=[\s/>])))+)?\s*\/?>/i,greedy:!0,inside:{tag:{pattern:/^<\/?[^\s>\/]+/i,inside:{punctuation:/^<\/?/,namespace:/^[^\s>\/:]+:/}},"attr-value":{pattern:/=\s*(?:"[^"]*"|'[^']*'|[^\s'">=]+)/i,inside:{punctuation:[/^=/,{pattern:/^(\s*)["']|["']$/,lookbehind:!0}]}},punctuation:/\/?>/,"attr-name":{pattern:/[^\s>\/]+/,inside:{namespace:/^[^\s>\/:]+:/}}}},entity:/&#?[\da-z]{1,8};/i},t.languages.markup.tag.inside["attr-value"].inside.entity=t.languages.markup.entity,t.hooks.add("wrap",function(e){"entity"===e.type&&(e.attributes.title=e.content.replace(/&amp;/,"&"))}),Object.defineProperty(t.languages.markup.tag,"addInlined",{value:function(e,n){var a={};a["language-"+n]={pattern:/(^<!\[CDATA\[)[\s\S]+?(?=\]\]>$)/i,lookbehind:!0,inside:t.languages[n]},a.cdata=/^<!\[CDATA\[|\]\]>$/i;var s={"included-cdata":{pattern:/<!\[CDATA\[[\s\S]*?\]\]>/i,inside:a}};s["language-"+n]={pattern:/[\s\S]+/,inside:t.languages[n]};var o={};o[e]={pattern:RegExp(/(<__[\s\S]*?>)(?:<!\[CDATA\[[\s\S]*?\]\]>\s*|[\s\S])*?(?=<\/__>)/.source.replace(/__/g,e),"i"),lookbehind:!0,greedy:!0,inside:s},t.languages.insertBefore("markup","cdata",o)}}),t.languages.xml=t.languages.extend("markup",{}),t.languages.html=t.languages.markup,t.languages.mathml=t.languages.markup,t.languages.svg=t.languages.markup,function(e){var t=/("|')(?:\\(?:\r\n|[\s\S])|(?!\1)[^\\\r\n])*\1/;e.languages.css={comment:/\/\*[\s\S]*?\*\//,atrule:{pattern:/@[\w-]+?[\s\S]*?(?:;|(?=\s*\{))/i,inside:{rule:/@[\w-]+/}},url:RegExp("url\\((?:"+t.source+"|.*?)\\)","i"),selector:RegExp("[^{}\\s](?:[^{};\"']|"+t.source+")*?(?=\\s*\\{)"),string:{pattern:t,greedy:!0},property:/[-_a-z\xA0-\uFFFF][-\w\xA0-\uFFFF]*(?=\s*:)/i,important:/!important\b/i,function:/[-a-z0-9]+(?=\()/i,punctuation:/[(){};:,]/},e.languages.css.atrule.inside.rest=e.languages.css;var n=e.languages.markup;n&&(n.tag.addInlined("style","css"),e.languages.insertBefore("inside","attr-value",{"style-attr":{pattern:/\s*style=("|')(?:\\[\s\S]|(?!\1)[^\\])*\1/i,inside:{"attr-name":{pattern:/^\s*style/i,inside:n.tag.inside},punctuation:/^\s*=\s*['"]|['"]\s*$/,"attr-value":{pattern:/.+/i,inside:e.languages.css}},alias:"language-css"}},n.tag))}(t),t.languages.clike={comment:[{pattern:/(^|[^\\])\/\*[\s\S]*?(?:\*\/|$)/,lookbehind:!0},{pattern:/(^|[^\\:])\/\/.*/,lookbehind:!0,greedy:!0}],string:{pattern:/(["'])(?:\\(?:\r\n|[\s\S])|(?!\1)[^\\\r\n])*\1/,greedy:!0},"class-name":{pattern:/((?:\b(?:class|interface|extends|implements|trait|instanceof|new)\s+)|(?:catch\s+\())[\w.\\]+/i,lookbehind:!0,inside:{punctuation:/[.\\]/}},keyword:/\b(?:if|else|while|do|for|return|in|instanceof|function|new|try|throw|catch|finally|null|break|continue)\b/,boolean:/\b(?:true|false)\b/,function:/\w+(?=\()/,number:/\b0x[\da-f]+\b|(?:\b\d+\.?\d*|\B\.\d+)(?:e[+-]?\d+)?/i,operator:/--?|\+\+?|!=?=?|<=?|>=?|==?=?|&&?|\|\|?|\?|\*|\/|~|\^|%/,punctuation:/[{}[\];(),.:]/},t.languages.javascript=t.languages.extend("clike",{"class-name":[t.languages.clike["class-name"],{pattern:/(^|[^$\w\xA0-\uFFFF])[_$A-Z\xA0-\uFFFF][$\w\xA0-\uFFFF]*(?=\.(?:prototype|constructor))/,lookbehind:!0}],keyword:[{pattern:/((?:^|})\s*)(?:catch|finally)\b/,lookbehind:!0},{pattern:/(^|[^.])\b(?:as|async(?=\s*(?:function\b|\(|[$\w\xA0-\uFFFF]|$))|await|break|case|class|const|continue|debugger|default|delete|do|else|enum|export|extends|for|from|function|get|if|implements|import|in|instanceof|interface|let|new|null|of|package|private|protected|public|return|set|static|super|switch|this|throw|try|typeof|undefined|var|void|while|with|yield)\b/,lookbehind:!0}],number:/\b(?:(?:0[xX][\dA-Fa-f]+|0[bB][01]+|0[oO][0-7]+)n?|\d+n|NaN|Infinity)\b|(?:\b\d+\.?\d*|\B\.\d+)(?:[Ee][+-]?\d+)?/,function:/[_$a-zA-Z\xA0-\uFFFF][$\w\xA0-\uFFFF]*(?=\s*(?:\.\s*(?:apply|bind|call)\s*)?\()/,operator:/-[-=]?|\+[+=]?|!=?=?|<<?=?|>>?>?=?|=(?:==?|>)?|&[&=]?|\|[|=]?|\*\*?=?|\/=?|~|\^=?|%=?|\?|\.{3}/}),t.languages.javascript["class-name"][0].pattern=/(\b(?:class|interface|extends|implements|instanceof|new)\s+)[\w.\\]+/,t.languages.insertBefore("javascript","keyword",{regex:{pattern:/((?:^|[^$\w\xA0-\uFFFF."'\])\s])\s*)\/(\[(?:[^\]\\\r\n]|\\.)*]|\\.|[^/\\\[\r\n])+\/[gimyu]{0,5}(?=\s*($|[\r\n,.;})\]]))/,lookbehind:!0,greedy:!0},"function-variable":{pattern:/[_$a-zA-Z\xA0-\uFFFF][$\w\xA0-\uFFFF]*(?=\s*[=:]\s*(?:async\s*)?(?:\bfunction\b|(?:\((?:[^()]|\([^()]*\))*\)|[_$a-zA-Z\xA0-\uFFFF][$\w\xA0-\uFFFF]*)\s*=>))/,alias:"function"},parameter:[{pattern:/(function(?:\s+[_$A-Za-z\xA0-\uFFFF][$\w\xA0-\uFFFF]*)?\s*\(\s*)(?!\s)(?:[^()]|\([^()]*\))+?(?=\s*\))/,lookbehind:!0,inside:t.languages.javascript},{pattern:/[_$a-z\xA0-\uFFFF][$\w\xA0-\uFFFF]*(?=\s*=>)/i,inside:t.languages.javascript},{pattern:/(\(\s*)(?!\s)(?:[^()]|\([^()]*\))+?(?=\s*\)\s*=>)/,lookbehind:!0,inside:t.languages.javascript},{pattern:/((?:\b|\s|^)(?!(?:as|async|await|break|case|catch|class|const|continue|debugger|default|delete|do|else|enum|export|extends|finally|for|from|function|get|if|implements|import|in|instanceof|interface|let|new|null|of|package|private|protected|public|return|set|static|super|switch|this|throw|try|typeof|undefined|var|void|while|with|yield)(?![$\w\xA0-\uFFFF]))(?:[_$A-Za-z\xA0-\uFFFF][$\w\xA0-\uFFFF]*\s*)\(\s*)(?!\s)(?:[^()]|\([^()]*\))+?(?=\s*\)\s*\{)/,lookbehind:!0,inside:t.languages.javascript}],constant:/\b[A-Z](?:[A-Z_]|\dx?)*\b/}),t.languages.insertBefore("javascript","string",{"template-string":{pattern:/`(?:\\[\s\S]|\${[^}]+}|[^\\`])*`/,greedy:!0,inside:{interpolation:{pattern:/\${[^}]+}/,inside:{"interpolation-punctuation":{pattern:/^\${|}$/,alias:"punctuation"},rest:t.languages.javascript}},string:/[\s\S]+/}}}),t.languages.markup&&t.languages.markup.tag.addInlined("script","javascript"),t.languages.js=t.languages.javascript,"undefined"!=typeof self&&self.Prism&&self.document&&document.querySelector&&(self.Prism.fileHighlight=function(e){e=e||document;var n={js:"javascript",py:"python",rb:"ruby",ps1:"powershell",psm1:"powershell",sh:"bash",bat:"batch",h:"c",tex:"latex"};Array.prototype.slice.call(e.querySelectorAll("pre[data-src]")).forEach(function(e){if(!e.hasAttribute("data-src-loaded")){for(var a,s=e.getAttribute("data-src"),o=e,i=/\blang(?:uage)?-([\w-]+)\b/i;o&&!i.test(o.className);)o=o.parentNode;if(o&&(a=(e.className.match(i)||[,""])[1]),!a){var r=(s.match(/\.(\w+)$/)||[,""])[1];a=n[r]||r}var l=document.createElement("code");l.className="language-"+a,e.textContent="",l.textContent="Loading…",e.appendChild(l);var c=new XMLHttpRequest;c.open("GET",s,!0),c.onreadystatechange=function(){4==c.readyState&&(c.status<400&&c.responseText?(l.textContent=c.responseText,t.highlightElement(l),e.setAttribute("data-src-loaded","")):c.status>=400?l.textContent="✖ Error "+c.status+" while fetching file: "+c.statusText:l.textContent="✖ Error: File does not exist or is empty")},c.send(null)}}),t.plugins.toolbar&&t.plugins.toolbar.registerButton("download-file",function(e){var t=e.element.parentNode;if(t&&/pre/i.test(t.nodeName)&&t.hasAttribute("data-src")&&t.hasAttribute("data-download-link")){var n=t.getAttribute("data-src"),a=document.createElement("a");return a.textContent=t.getAttribute("data-download-link-label")||"Download",a.setAttribute("download",""),a.href=n,a}})},document.addEventListener("DOMContentLoaded",function(){self.Prism.fileHighlight()}))}),CodeFlask=function(e,t){if(!e)throw Error("CodeFlask expects a parameter which is Element or a String selector");if(!t)throw Error("CodeFlask expects an object containing options as second parameter");if(e.nodeType)this.editorRoot=e;else{var n=document.querySelector(e);n&&(this.editorRoot=n)}this.opts=t,this.startEditor()};CodeFlask.prototype.startEditor=function(){if(!injectCss(editorCss,null,this.opts.styleParent))throw Error("Failed to inject CodeFlask CSS.");this.createWrapper(),this.createTextarea(),this.createPre(),this.createCode(),this.runOptions(),this.listenTextarea(),this.populateDefault(),this.updateCode(this.code)},CodeFlask.prototype.createWrapper=function(){this.code=this.editorRoot.innerHTML,this.editorRoot.innerHTML="",this.elWrapper=this.createElement("div",this.editorRoot),this.elWrapper.classList.add("codeflask")},CodeFlask.prototype.createTextarea=function(){this.elTextarea=this.createElement("textarea",this.elWrapper),this.elTextarea.classList.add("codeflask__textarea","codeflask__flatten")},CodeFlask.prototype.createPre=function(){this.elPre=this.createElement("pre",this.elWrapper),this.elPre.classList.add("codeflask__pre","codeflask__flatten")},CodeFlask.prototype.createCode=function(){this.elCode=this.createElement("code",this.elPre),this.elCode.classList.add("codeflask__code","language-"+(this.opts.language||"html"))},CodeFlask.prototype.createLineNumbers=function(){this.elLineNumbers=this.createElement("div",this.elWrapper),this.elLineNumbers.classList.add("codeflask__lines"),this.setLineNumber()},CodeFlask.prototype.createElement=function(e,t){var n=document.createElement(e);return t.appendChild(n),n},CodeFlask.prototype.runOptions=function(){this.opts.rtl=this.opts.rtl||!1,this.opts.tabSize=this.opts.tabSize||2,this.opts.enableAutocorrect=this.opts.enableAutocorrect||!1,this.opts.lineNumbers=this.opts.lineNumbers||!1,this.opts.defaultTheme=!1!==this.opts.defaultTheme,this.opts.areaId=this.opts.areaId||null,this.opts.ariaLabelledby=this.opts.ariaLabelledby||null,this.opts.readonly=this.opts.readonly||null,"boolean"!=typeof this.opts.handleTabs&&(this.opts.handleTabs=!0),"boolean"!=typeof this.opts.handleSelfClosingCharacters&&(this.opts.handleSelfClosingCharacters=!0),"boolean"!=typeof this.opts.handleNewLineIndentation&&(this.opts.handleNewLineIndentation=!0),!0===this.opts.rtl&&(this.elTextarea.setAttribute("dir","rtl"),this.elPre.setAttribute("dir","rtl")),!1===this.opts.enableAutocorrect&&(this.elTextarea.setAttribute("spellcheck","false"),this.elTextarea.setAttribute("autocapitalize","off"),this.elTextarea.setAttribute("autocomplete","off"),this.elTextarea.setAttribute("autocorrect","off")),this.opts.lineNumbers&&(this.elWrapper.classList.add("codeflask--has-line-numbers"),this.createLineNumbers()),this.opts.defaultTheme&&injectCss(defaultCssTheme,"theme-default",this.opts.styleParent),this.opts.areaId&&this.elTextarea.setAttribute("id",this.opts.areaId),this.opts.ariaLabelledby&&this.elTextarea.setAttribute("aria-labelledby",this.opts.ariaLabelledby),this.opts.readonly&&this.enableReadonlyMode()},CodeFlask.prototype.updateLineNumbersCount=function(){for(var e="",t=1;t<=this.lineNumber;t++)e=e+'<span class="codeflask__lines__line">'+t+"</span>";this.elLineNumbers.innerHTML=e},CodeFlask.prototype.listenTextarea=function(){var e=this;this.elTextarea.addEventListener("input",function(t){e.code=t.target.value,e.elCode.innerHTML=escapeHtml(t.target.value),e.highlight(),setTimeout(function(){e.runUpdate(),e.setLineNumber()},1)}),this.elTextarea.addEventListener("keydown",function(t){e.handleTabs(t),e.handleSelfClosingCharacters(t),e.handleNewLineIndentation(t)}),this.elTextarea.addEventListener("scroll",function(t){e.elPre.style.transform="translate3d(-"+t.target.scrollLeft+"px, -"+t.target.scrollTop+"px, 0)",e.elLineNumbers&&(e.elLineNumbers.style.transform="translate3d(0, -"+t.target.scrollTop+"px, 0)")})},CodeFlask.prototype.handleTabs=function(e){if(this.opts.handleTabs){if(9!==e.keyCode)return;e.preventDefault();var t=this.elTextarea,n=t.selectionDirection,a=t.selectionStart,s=t.selectionEnd,o=t.value,i=o.substr(0,a),r=o.substring(a,s),l=o.substring(s),c=" ".repeat(this.opts.tabSize);if(a!==s&&r.length>=c.length){var d=a-i.split("\n").pop().length,u=c.length,p=c.length;if(e.shiftKey)o.substr(d,c.length)===c?(u=-u,d>a?(r=r.substring(0,d)+r.substring(d+c.length),p=0):d===a?(u=0,p=0,r=r.substring(c.length)):(p=-p,i=i.substring(0,d)+i.substring(d+c.length))):(u=0,p=0),r=r.replace(new RegExp("\n"+c.split("").join("\\"),"g"),"\n");else i=i.substr(0,d)+c+i.substring(d,a),r=r.replace(/\n/g,"\n"+c);t.value=i+r+l,t.selectionStart=a+u,t.selectionEnd=a+r.length+p,t.selectionDirection=n}else t.value=i+c+l,t.selectionStart=a+c.length,t.selectionEnd=a+c.length;var h=t.value;this.updateCode(h),this.elTextarea.selectionEnd=s+this.opts.tabSize}},CodeFlask.prototype.handleSelfClosingCharacters=function(e){if(this.opts.handleSelfClosingCharacters){var t=e.key;if(["(","[","{","<","'",'"'].includes(t)||[")","]","}",">","'",'"'].includes(t))switch(t){case"(":case")":this.closeCharacter(t);break;case"[":case"]":this.closeCharacter(t);break;case"{":case"}":this.closeCharacter(t);break;case"<":case">":case"'":case'"':this.closeCharacter(t)}}},CodeFlask.prototype.setLineNumber=function(){this.lineNumber=this.code.split("\n").length,this.opts.lineNumbers&&this.updateLineNumbersCount()},CodeFlask.prototype.handleNewLineIndentation=function(e){if(this.opts.handleNewLineIndentation&&13===e.keyCode){e.preventDefault();var t=this.elTextarea,n=t.selectionStart,a=t.selectionEnd,s=t.value,o=s.substr(0,n),i=s.substring(a),r=s.lastIndexOf("\n",n-1),l=r+s.slice(r+1).search(/[^ ]|$/),c=l>r?l-r:0,d=o+"\n"+" ".repeat(c)+i;t.value=d,t.selectionStart=n+c+1,t.selectionEnd=n+c+1,this.updateCode(t.value)}},CodeFlask.prototype.closeCharacter=function(e){var t=this.elTextarea.selectionStart,n=this.elTextarea.selectionEnd;if(this.skipCloseChar(e)){var a=this.code.substr(n,1)===e,s=a?n+1:n,o=!a&&["'",'"'].includes(e)?e:"",i=""+this.code.substring(0,t)+o+this.code.substring(s);this.updateCode(i),this.elTextarea.selectionEnd=++this.elTextarea.selectionStart}else{var r=e;switch(e){case"(":r=String.fromCharCode(e.charCodeAt()+1);break;case"<":case"{":case"[":r=String.fromCharCode(e.charCodeAt()+2)}var l=this.code.substring(t,n),c=""+this.code.substring(0,t)+l+r+this.code.substring(n);this.updateCode(c)}this.elTextarea.selectionEnd=t},CodeFlask.prototype.skipCloseChar=function(e){var t=this.elTextarea.selectionStart,n=this.elTextarea.selectionEnd,a=Math.abs(n-t)>0;return[")","}","]",">"].includes(e)||["'",'"'].includes(e)&&!a},CodeFlask.prototype.updateCode=function(e){this.code=e,this.elTextarea.value=e,this.elCode.innerHTML=escapeHtml(e),this.highlight(),this.setLineNumber(),setTimeout(this.runUpdate.bind(this),1)},CodeFlask.prototype.updateLanguage=function(e){var t=this.opts.language;this.elCode.classList.remove("language-"+t),this.elCode.classList.add("language-"+e),this.opts.language=e,this.highlight()},CodeFlask.prototype.addLanguage=function(e,t){prism.languages[e]=t},CodeFlask.prototype.populateDefault=function(){this.updateCode(this.code)},CodeFlask.prototype.highlight=function(){prism.highlightElement(this.elCode,!1)},CodeFlask.prototype.onUpdate=function(e){if(e&&"[object Function]"!=={}.toString.call(e))throw Error("CodeFlask expects callback of type Function");this.updateCallBack=e},CodeFlask.prototype.getCode=function(){return this.code},CodeFlask.prototype.runUpdate=function(){this.updateCallBack&&this.updateCallBack(this.code)},CodeFlask.prototype.enableReadonlyMode=function(){this.elTextarea.setAttribute("readonly",!0)},CodeFlask.prototype.disableReadonlyMode=function(){this.elTextarea.removeAttribute("readonly")};export default CodeFlask;
\ No newline at end of file
diff --git a/www/js/site.js b/www/js/site.js
index 4e0a5c2825..627952c492 100644
--- a/www/js/site.js
+++ b/www/js/site.js
@@ -1022,36 +1022,31 @@ var getScrollbarWidth = function() {
 
     // jQuery UI Tabs
     $("#test_subbox-container").tabs();
-    $("#test_subbox-container").on("tabsshow", function (event, ui) {
+    $("#test_subbox-container").on("tabsshow", async function (event, ui) {
       if (
         ui.tab.id === "tab-advanced" &&
-        !document.getElementById("codeflask-script") // already loaded
+        !document.getElementById("injectscript-container")
       ) {
         // load Flask
+        const { default: CodeFlask } = await import("/js/codeflask.module.js");
         var injectScript = document.getElementById("injectScript");
-        var script = document.createElement("script");
-        script.src = "/js/codeflask.min.js";
-        script.id = "codeflask-script";
-        script.onload = function () {
-          // editor container
-          var codeEl = document.createElement("div");
-          codeEl.id = "injectscript-container";
-          injectScript.parentNode.insertBefore(codeEl, injectScript);
-          // switch textarea to a hidden input
-          var hidden = document.createElement("input");
-          hidden.type = "hidden";
-          hidden.id = "injectScript";
-          hidden.name = "injectScript";
-          injectScript.parentNode.replaceChild(hidden, injectScript);
-          // go!
-          var codeArea = new CodeFlask(codeEl, {
-            language: "js",
-          });
-          codeArea.onUpdate(function (code) {
-            hidden.value = code;
-          });
-        };
-        document.body.appendChild(script);
+        // editor container
+        var codeEl = document.createElement("div");
+        codeEl.id = "injectscript-container";
+        injectScript.parentNode.insertBefore(codeEl, injectScript);
+        // switch textarea to a hidden input
+        var hidden = document.createElement("input");
+        hidden.type = "hidden";
+        hidden.id = "injectScript";
+        hidden.name = "injectScript";
+        injectScript.parentNode.replaceChild(hidden, injectScript);
+        // go!
+        var codeArea = new CodeFlask(codeEl, {
+          language: "js",
+        });
+        codeArea.onUpdate(function (code) {
+          hidden.value = code;
+        });
       }
     });
 
diff --git a/www/nginx.conf b/www/nginx.conf
index 2fdfcd2aff..b1a4721163 100644
--- a/www/nginx.conf
+++ b/www/nginx.conf
@@ -34,7 +34,7 @@ error_page 404 /404.php;
 # disable chunked encoding for mp4 files because it breaks flash video on firefox
 location ~* \.mp4 {
         expires max;
-        add_header Cache-Control "public";        
+        add_header Cache-Control "public";
         gzip off;
 }
 
@@ -123,6 +123,11 @@ rewrite ^/result/([a-zA-Z0-9_]+)/([a-zA-Z0-9]+)/optimization_report$ /optimizati
 rewrite ^/result/([a-zA-Z0-9_]+)/([a-zA-Z0-9]+)/optimization_report/$ /optimization_report.php?test=$1&run=$2 last;
 rewrite ^/result/([a-zA-Z0-9_]+)/([a-zA-Z0-9]+)/optimization_report/cached$ /optimization_report.php?test=$1&run=$2&cached=1 last;
 rewrite ^/result/([a-zA-Z0-9_]+)/([a-zA-Z0-9]+)/optimization_report/cached/$ /optimization_report.php?test=$1&run=$2&cached=1 last;
+rewrite ^/result/([a-zA-Z0-9_]+)/([a-zA-Z0-9]+)/consolelog$ /consolelog.php?test=$1&run=$2 last;
+rewrite ^/result/([a-zA-Z0-9_]+)/([a-zA-Z0-9]+)/consolelog/$ /consolelog.php?test=$1&run=$2 last;
+rewrite ^/result/([a-zA-Z0-9_]+)/([a-zA-Z0-9]+)/consolelog/cached$ /consolelog.php?test=$1&run=$2&cached=1 last;
+rewrite ^/result/([a-zA-Z0-9_]+)/([a-zA-Z0-9]+)/consolelog/cached/$ /consolelog.php?test=$1&run=$2&cached=1 last;
+
 rewrite ^/testlog$ /testlog/7/ permanent;
 rewrite ^/testlog/([0-9]+)$ /testlog/$1/ permanent;
 rewrite ^/testlog/([0-9]+)/$ /testlog.php?days=$1 last;
diff --git a/www/resources/views/default.blade.php b/www/resources/views/default.blade.php
new file mode 100644
index 0000000000..7c42aa64b2
--- /dev/null
+++ b/www/resources/views/default.blade.php
@@ -0,0 +1,38 @@
+<!DOCTYPE html>
+<html>
+
+<head>
+    <?php
+
+    require_once __DIR__ . '/../../common.inc';
+
+    global $USER_EMAIL;
+    global $supportsAuth;
+    global $supportsSaml;
+    global $supportsCPAuth;
+    global $request_context;
+    global $_SESSION;
+    global $client_error;
+    global $test_is_private;
+    global $noanalytics;
+
+    $page_title = $page_title ? $page_title : 'WebPageTest';
+    $body_class = $body_class ? ' class=' . $body_class : '';
+
+    ?>
+    <title>{{ $page_title ?? 'WebPageTest' }}</title>
+    <?php require_once __DIR__ . '/../../templates/layouts/head.inc'; ?>
+    @yield('style')
+</head>
+
+<body {{$body_class }}>
+    <?php require_once __DIR__ . '/../../templates/layouts/header.inc'; ?>
+    <div id="main">
+        <?php require_once __DIR__ . '/../../templates/layouts/main_hed.inc'; ?>
+        <?php echo $results_header; ?>
+        @yield('content')
+        <?php require_once __DIR__ . '/../../footer.inc'; ?>
+    </div>
+    </body>
+
+</html>
\ No newline at end of file
diff --git a/www/resources/views/pages/consolelog.blade.php b/www/resources/views/pages/consolelog.blade.php
new file mode 100644
index 0000000000..8f73fa1e59
--- /dev/null
+++ b/www/resources/views/pages/consolelog.blade.php
@@ -0,0 +1,93 @@
+@extends('default')
+
+@section('style')
+<style>
+    #console-log {
+        text-align: left;
+        width: 100%;
+        display: block;
+        margin-left: auto;
+        margin-right: auto;
+    }
+
+    #console-log th {
+        padding: 0.2em 1em;
+        text-align: left;
+    }
+
+    #console-log td {
+        padding: 0.2em 1em;
+        max-width: 100px;
+    }
+
+    #console-log .message {
+        max-width: 700px;
+    }
+
+    #console-log .url {
+        max-width: 300px;
+    }
+
+    div.scrollable {
+        max-width: 100em;
+        margin: 0;
+        padding: 0;
+        overflow: auto;
+    }
+
+    tr.even {
+        background: whitesmoke;
+    }
+</style>
+@endsection
+
+@section('content')
+<?php $even = false; ?>
+<div id="test_results-container">
+    <div id="test-1" class="test_results">
+        <div class="test_results-content">
+            <div class="results_main_contain">
+                <div class="results_main">
+                    <div class="results_and_command">
+                        <div class="results_header">
+                            <h2>Console Log</h2>
+                        </div>
+                    </div>
+                    <div id="result" class="results_body">
+
+                        <div class="overflow-container">
+                            <table id="console-log" class="translucent">
+                                <thead>
+                                <tr>
+                                    <th>Source</th>
+                                    <th>Level</th>
+                                    <th>Message</th>
+                                    <th>URL</th>
+                                    <th>Line</th>
+                                </tr>
+                                </thead>
+                                <tbody>
+                                @foreach ($log as &$log_entry)
+                                <?php $even = !$even ?>
+                                <tr @if ($even) class="even" @endif>
+                                    <td width="50" class="source">{{ $log_entry['source'] }} </td>
+                                    <td width="50" class="level">{{ $log_entry['level'] }} </td>
+                                    <td class="message">
+                                        <div class="scrollable">{{ $log_entry['text'] }}</div>
+                                    </td>
+                                    <td class="url">
+                                        <div class="scrollable"><a href={{ $log_entry['url'] }}>{{ $log_entry['url'] }}</a></div>
+                                    </td>
+                                    <td width="50" class="line">{{ @$log_entry['line'] }}</td>
+                                </tr>
+                                @endforeach
+                                </tbody>
+                            </table>
+                        </div>
+                    </div>
+                </div>
+            </div>
+        </div>
+    </div>
+</div>
+@endsection
\ No newline at end of file
diff --git a/www/templates/layouts/includes/wpt-header.php b/www/templates/layouts/includes/wpt-header.php
index afffc221c9..f7a6214475 100644
--- a/www/templates/layouts/includes/wpt-header.php
+++ b/www/templates/layouts/includes/wpt-header.php
@@ -95,10 +95,9 @@ function addTab($tabName, $tabUrl, $addClass = '')
                         </details>
                     </li>
 
-
-
-                    <?= addTab('Pricing', '/signup'); ?>
-
+                    <?php if ($supportsAuth && !defined('EMBED')): ?>
+                      <?= addTab('Pricing', '/signup'); ?>
+                    <?php endif; ?>
 
                     <li class="wptheader_nav_menu">
                         <details>
diff --git a/www/tips_data/http2_in_action.html b/www/tips_data/http2_in_action.html
index 3e4bc1589d..f48b948b64 100755
--- a/www/tips_data/http2_in_action.html
+++ b/www/tips_data/http2_in_action.html
@@ -1,1 +1,32 @@
-<a href="http://mng.bz/EejO" onclick="try{if(_gaq!=undefined){_gaq.push(['_trackEvent','Outbound','Click','Tip.H2Book']);}}catch(err){}" target="_blank"><img style="float:left; padding-right:1em;" src="/tips_data/http2_in_action.png"></a> Manning has published "<a href="http://mng.bz/EejO" onclick="try{if(_gaq!=undefined){_gaq.push(['_trackEvent','Outbound','Click','Tip.H2Book']);}}catch(err){}" target="_blank">HTTP/2 in Action</a>" by <a href="https://twitter.com/tunetheweb" onclick="try{if(_gaq!=undefined){_gaq.push(['_trackEvent','Outbound','Click','Tip.H2Book']);}}catch(err){}">Barry Pollard</a><br><br><i>HTTP/2 in Action</i> teaches you everything you need to know to use HTTP/2 effectively and how to optimize web performance for your site. Using tools like WebPageTest, this practical guide explores real world examples allowing you to get most out of this recent performance upgrade to the web.<br><br>The full list of contents is available from <a href="http://mng.bz/EejO" onclick="try{if(_gaq!=undefined){_gaq.push(['_trackEvent','Outbound','Click','Tip.H2Book']);}}catch(err){}" target="_blank">Manning</a> and WebPageTest users get 40% off with the discount code <code>webpagetest40%</code>.
\ No newline at end of file
+<a
+  href="http://mng.bz/EejO"
+  onclick="try{if(_gaq!=undefined){_gaq.push(['_trackEvent','Outbound','Click','Tip.H2Book']);}}catch(err){}"
+  target="_blank"
+  ><img
+    style="float: left; padding-right: 1em"
+    src="/tips_data/http2_in_action.png"
+/></a>
+Manning has published "<a
+  href="http://mng.bz/EejO"
+  onclick="try{if(_gaq!=undefined){_gaq.push(['_trackEvent','Outbound','Click','Tip.H2Book']);}}catch(err){}"
+  target="_blank"
+  >HTTP/2 in Action</a
+>" by
+<a
+  href="https://twitter.com/tunetheweb"
+  onclick="try{if(_gaq!=undefined){_gaq.push(['_trackEvent','Outbound','Click','Tip.H2Book']);}}catch(err){}"
+  target="_blank"
+  >Barry Pollard</a
+><br /><br /><i>HTTP/2 in Action</i> teaches you everything you need to know to
+use HTTP/2 effectively and how to optimize web performance for your site. Using
+tools like WebPageTest, this practical guide explores real world examples
+allowing you to get most out of this recent performance upgrade to the web.<br /><br />The
+full list of contents is available from
+<a
+  href="http://mng.bz/EejO"
+  onclick="try{if(_gaq!=undefined){_gaq.push(['_trackEvent','Outbound','Click','Tip.H2Book']);}}catch(err){}"
+  target="_blank"
+  >Manning</a
+>
+and WebPageTest users get 40% off with the discount code
+<code>webpagetest40%</code>.
diff --git a/www/tips_data/node-wrapper.html b/www/tips_data/node-wrapper.html
index 9b6c1e095e..fcbb3ce9eb 100644
--- a/www/tips_data/node-wrapper.html
+++ b/www/tips_data/node-wrapper.html
@@ -1,4 +1,23 @@
-<a href="https://product.webpagetest.org/api?utm_campaign=WPT%20Website%20CTAs&utm_source=website&utm_medium=tips&utm_content=waitingscreentip"><img style="float:right; margin-left:2em; width: 200px;" src="/tips_data/automate-perf-testing.png"></a>
-<p>Integrate WebPageTest with your existing CI/CD tooling? </p>
-<p>WebPageTest API Wrapper is a NPM package that wraps WebPageTest API for NodeJS as a module and a command-line tool. It provides some syntactic sugar over the raw API, enabling easier integration into your existing workflows, including built in polling for results, pingback support and more.</p> 
-<p>Check out one of our <a href="https://product.webpagetest.org/api?utm_campaign=WPT%20Website%20CTAs&utm_source=website&utm_medium=tips&utm_content=waitingscreentip">API subscriptions</a> and unlock the full potential of WebPageTest! </p> 
\ No newline at end of file
+<a
+  href="https://product.webpagetest.org/api?utm_campaign=WPT%20Website%20CTAs&utm_source=website&utm_medium=tips&utm_content=waitingscreentip"
+  target="_blank"
+  ><img
+    style="float: right; margin-left: 2em; width: 200px"
+    src="/tips_data/automate-perf-testing.png"
+/></a>
+<p>Integrate WebPageTest with your existing CI/CD tooling?</p>
+<p>
+  WebPageTest API Wrapper is a NPM package that wraps WebPageTest API for NodeJS
+  as a module and a command-line tool. It provides some syntactic sugar over the
+  raw API, enabling easier integration into your existing workflows, including
+  built in polling for results, pingback support and more.
+</p>
+<p>
+  Check out one of our
+  <a
+    href="https://product.webpagetest.org/api?utm_campaign=WPT%20Website%20CTAs&utm_source=website&utm_medium=tips&utm_content=waitingscreentip"
+    target="_blank"
+    >API subscriptions</a
+  >
+  and unlock the full potential of WebPageTest!
+</p>
diff --git a/www/tips_data/responsible_javascript.html b/www/tips_data/responsible_javascript.html
index f11b27ec69..043438af2b 100755
--- a/www/tips_data/responsible_javascript.html
+++ b/www/tips_data/responsible_javascript.html
@@ -1,1 +1,42 @@
-<picture><source srcset="/tips_data/responsible-javascript-2x.webp 2x, /tips_data/responsible-javascript-1x.webp 1x" type="image/webp"><a href="https://abookapart.com/products/responsible-javascript" onclick="try{if(_gaq!=undefined){_gaq.push(['_trackEvent','Outbound','Click','Tip.ResponsibleJavaScript']);}}catch(err){}" target="_blank"><img style="float:left; padding-right:1em;" width="221" height="330" srcset="/tips_data/responsible-javascript-2x.png 2x, /tips_data/responsible-javascript-1x.png 1x" src="/tips_data/responsible-javascript-1x.png"></a></picture>A Book Apart has published "<a href="https://abookapart.com/products/responsible-javascript" onclick="try{if(_gaq!=undefined){_gaq.push(['_trackEvent','Outbound','Click','Tip.ResponsibleJavaScript']);}}catch(err){}" target="_blank">Responsible JavaScript</a>" by <a href="https://twitter.com/malchata" onclick="try{if(_gaq!=undefined){_gaq.push(['_trackEvent','Outbound','Click','Tip.ResponsibleJavaScript']);}}catch(err){}">Jeremy Wagner</a><br><br>JavaScript plays a powerful role in creating rich interactive experiences. But its power comes at a cost: longer load times, sluggish pages, and inaccessible content. The more we rely on client-side rendering, the more likely we are to exclude visitors with older devices, slower connections, or those who have disabled JavaScript altogether.<br><br>If we want people to fully experience the sites we have worked so hard to craft, then we must be judicious in our use of JavaScript. In thoughtful detail, Jeremy Wagner shows how JavaScript can be used to progressively enhance server-side functionality, while improving speed and access for more visitors. By centering user needs every step of the way&mdash;from toolchains to metrics to testing&mdash;we can all contribute to a more inclusive, accessible, and resilient web.
+<picture
+  ><source
+    srcset="
+      /tips_data/responsible-javascript-2x.webp 2x,
+      /tips_data/responsible-javascript-1x.webp 1x
+    "
+    type="image/webp" />
+  <a
+    href="https://abookapart.com/products/responsible-javascript"
+    onclick="try{if(_gaq!=undefined){_gaq.push(['_trackEvent','Outbound','Click','Tip.ResponsibleJavaScript']);}}catch(err){}"
+    target="_blank"
+    ><img
+      style="float: left; padding-right: 1em"
+      width="221"
+      height="330"
+      srcset="
+        /tips_data/responsible-javascript-2x.png 2x,
+        /tips_data/responsible-javascript-1x.png 1x
+      "
+      src="/tips_data/responsible-javascript-1x.png" /></a></picture
+>A Book Apart has published "<a
+  href="https://abookapart.com/products/responsible-javascript"
+  onclick="try{if(_gaq!=undefined){_gaq.push(['_trackEvent','Outbound','Click','Tip.ResponsibleJavaScript']);}}catch(err){}"
+  target="_blank"
+  >Responsible JavaScript</a
+>" by
+<a
+  href="https://twitter.com/malchata"
+  onclick="try{if(_gaq!=undefined){_gaq.push(['_trackEvent','Outbound','Click','Tip.ResponsibleJavaScript']);}}catch(err){}"
+  target="_blank"
+  >Jeremy Wagner</a
+><br /><br />JavaScript plays a powerful role in creating rich interactive
+experiences. But its power comes at a cost: longer load times, sluggish pages,
+and inaccessible content. The more we rely on client-side rendering, the more
+likely we are to exclude visitors with older devices, slower connections, or
+those who have disabled JavaScript altogether.<br /><br />If we want people to
+fully experience the sites we have worked so hard to craft, then we must be
+judicious in our use of JavaScript. In thoughtful detail, Jeremy Wagner shows
+how JavaScript can be used to progressively enhance server-side functionality,
+while improving speed and access for more visitors. By centering user needs
+every step of the way&mdash;from toolchains to metrics to testing&mdash;we can
+all contribute to a more inclusive, accessible, and resilient web.
diff --git a/www/waterfall.inc b/www/waterfall.inc
index 48cef7202c..9a3478a619 100644
--- a/www/waterfall.inc
+++ b/www/waterfall.inc
@@ -9,10 +9,10 @@ require_once __DIR__ . '/contentColors.inc';
 require_once __DIR__ . '/include/TestPaths.php';
 
 /**
-* Regroup requests into connection rows.
-*
-* @param mixed $requests
-*/
+ * Regroup requests into connection rows.
+ *
+ * @param mixed $requests
+ */
 function GetConnectionRows(&$requests, $show_labels = true)
 {
     $host_connections = array();  // group connections by host
@@ -25,14 +25,14 @@ function GetConnectionRows(&$requests, $show_labels = true)
             }
             if (!array_key_exists($socket, $host_connections[$host])) {
                 $host_connections[$host][$socket] = array(
-                'is_connection' => true,
-                'socket' => intval($socket),
-                'host' => $host,
-                'is_secure' => $request['is_secure'],
-                'start' => $request['all_start'],
-                'end' => $request['all_end'],
-                'requests' => array($request),
-                'renderBlocking' => $request['renderBlocking']
+                    'is_connection' => true,
+                    'socket' => intval($socket),
+                    'host' => $host,
+                    'is_secure' => $request['is_secure'],
+                    'start' => $request['all_start'],
+                    'end' => $request['all_end'],
+                    'requests' => array($request),
+                    'renderBlocking' => $request['renderBlocking']
                 );
             } else {
                 $host_connections[$host][$socket]['end'] = $request['all_end'];
@@ -41,7 +41,7 @@ function GetConnectionRows(&$requests, $show_labels = true)
         }
     }
     if (count($host_connections)) {
-      // merge the hosts together by connection
+        // merge the hosts together by connection
         $rows = array();
         foreach ($host_connections as $host => $connections) {
             foreach ($connections as $socket => $connection) {
@@ -82,8 +82,8 @@ function GetConnectionRows(&$requests, $show_labels = true)
 }
 
 /**
-* Return a row to indicate filtered requests. Helper for GetRequestRows.
-*/
+ * Return a row to indicate filtered requests. Helper for GetRequestRows.
+ */
 function _GetDotsRow()
 {
     return array(
@@ -94,12 +94,12 @@ function _GetDotsRow()
         'is_secure' => true,
         'requests' => array(),
         'renderBlocking' => null
-        );
+    );
 }
 
 /**
-* Return an array of rows to use in waterfall view.
-*/
+ * Return an array of rows to use in waterfall view.
+ */
 function GetRequestRows($requests, $use_dots, $show_labels = true)
 {
     $rows = array();
@@ -135,8 +135,8 @@ function GetRequestRows($requests, $use_dots, $show_labels = true)
 }
 
 /**
-* Return an array of page events selected from the page data.
-*/
+ * Return an array of page events selected from the page data.
+ */
 function GetPageEvents($page_data)
 {
     $ret = array(
@@ -146,19 +146,23 @@ function GetPageEvents($page_data)
         'dom_element' => $page_data['domTime'],
         'aft' =>  $page_data['aft'],
         'fcp' => $page_data['firstContentfulPaint'],
-        'nav_load' => array($page_data['loadEventStart'],
-                            $page_data['loadEventEnd']),
-        'nav_dom' => array($page_data['domContentLoadedEventStart'],
-                           $page_data['domContentLoadedEventEnd']),
+        'nav_load' => array(
+            $page_data['loadEventStart'],
+            $page_data['loadEventEnd']
+        ),
+        'nav_dom' => array(
+            $page_data['domContentLoadedEventStart'],
+            $page_data['domContentLoadedEventEnd']
+        ),
         'nav_dom_interactive' => $page_data['domInteractive'],
         'load' => $page_data['docTime']
-        );
+    );
     return $ret;
 }
 
 /**
-* Return data for an image map of the given rows.
-*/
+ * Return data for an image map of the given rows.
+ */
 function GetWaterfallMap($rows, $url, $options, &$page_data)
 {
     $page_events = array();
@@ -167,10 +171,10 @@ function GetWaterfallMap($rows, $url, $options, &$page_data)
 }
 
 /**
-* Return an image identifier (from imagecreate) for a waterfall chart.
-*
-* @return resource
-*/
+ * Return an image identifier (from imagecreate) for a waterfall chart.
+ *
+ * @return resource
+ */
 function GetWaterfallImage($rows, $url, $page_events, $options, $page_data)
 {
     $is_image_map = false;
@@ -190,10 +194,10 @@ function _BaseDocumentUrl($url)
 }
 
 /**
-* Draw the waterfall view image.
-*
-* @return resource
-*/
+ * Draw the waterfall view image.
+ *
+ * @return resource
+ */
 function _GetMapOrImage($rows, $url, $page_events, $options, $is_image_map, $page_data)
 {
     $is_mime = isset($options['is_mime']) ? (bool)@$options['is_mime'] : (bool)GetSetting('mime_waterfalls', 1);
@@ -231,10 +235,11 @@ function _GetMapOrImage($rows, $url, $page_events, $options, $is_image_map, $pag
     $data_header_height = intval($row_height * 3 / 2);
     $data_footer_height = $row_height;
     $height = ($data_header_height + ($row_height * $row_count) +
-               $data_footer_height + 2);
+        $data_footer_height + 2);
     $data_height = $height;
     $use_cpu = (bool)@$options['use_cpu'];
     $use_bw = (bool)@$options['use_bw'];
+    $use_lt = (bool)@$options['use_lt'];
     $stepId = array_key_exists('step_id', $options) ? (int) $options['step_id'] : 1;
     $isCached = array_key_exists('is_cached', $options) ? $options['is_cached'] : false;
     $localPaths = new TestPaths($options['path'], $options['run_id'], $isCached, $stepId);
@@ -286,17 +291,19 @@ function _GetMapOrImage($rows, $url, $page_events, $options, $is_image_map, $pag
         if (isset($cpu_slices) && is_array($cpu_slices) && isset($cpu_slices['main_thread'])) {
             $perf_height = $is_thumbnail ? 16 : 50;
             $cpu_slice_info = array();
-            $cpu_slice_info[] = array('thread' => $cpu_slices['main_thread'],
-                                  'x1' => 0,
-                                  'x2' => $width - 1,
-                                  'y1' => $height - 1,
-                                  'y2' => $height + $perf_height - 2,
-                                  'height' => $perf_height);
+            $cpu_slice_info[] = array(
+                'thread' => $cpu_slices['main_thread'],
+                'x1' => 0,
+                'x2' => $width - 1,
+                'y1' => $height - 1,
+                'y2' => $height + $perf_height - 2,
+                'height' => $perf_height
+            );
             $height += $perf_height - 1;
         }
     }
     // See if we have main-thread interactive data
-    if (!$is_image_map && gz_is_file($localPaths->interactiveFile())) {
+    if ($use_lt && !$is_image_map && gz_is_file($localPaths->interactiveFile())) {
         $interactive = json_decode(gz_file_get_contents($localPaths->interactiveFile()), true);
         if (isset($interactive)) {
             if (is_array($interactive) && count($interactive) > 0) {
@@ -323,7 +330,7 @@ function _GetMapOrImage($rows, $url, $page_events, $options, $is_image_map, $pag
     if (@$_REQUEST['max'] > 0) {
         $max_ms = (int)($_REQUEST['max'] * 1000.0);
     } else {
-      // Include all page events and resource times in time scale.
+        // Include all page events and resource times in time scale.
         if (isset($page_events) && is_array($page_events) && count($page_events)) {
             foreach ($page_events as $event) {
                 $max_ms = max($max_ms, is_array($event) ? $event[1] : $event);
@@ -489,7 +496,7 @@ function _GetMapOrImage($rows, $url, $page_events, $options, $is_image_map, $pag
             'error' =>   GetColor($im, 255, 96, 96),
             'warning' => GetColor($im, 255, 255, 96),
             'blocking' => GetColor($im, 255, 203, 65)
-            );
+        );
         SetRowColors($rows, $page_events['load'], $bg_colors);
 
         // Draw the background.
@@ -586,7 +593,7 @@ function _GetMapOrImage($rows, $url, $page_events, $options, $is_image_map, $pag
                 if (isset($perfs) && is_array($perfs)) {
                     foreach ($perfs as $p) {
                         if (isset($p['count']) && isset($p['y1']) && isset($p['y2'])) {
-                          // Add gridline to performance chart area.
+                            // Add gridline to performance chart area.
                             imageline($im, $x, $p['y1'] + 1, $x, $p['y2'] - 1, $time_scale_color);
                         }
                     }
@@ -635,7 +642,7 @@ function _GetMapOrImage($rows, $url, $page_events, $options, $is_image_map, $pag
                 'nav_dom_interactive' => GetColor($im, 255, 198, 26),
                 'aft' => GetColor($im, 255, 0, 0),
                 'user' => GetColor($im, 105, 0, 158)
-                );
+            );
 
             // draw the other page events
             foreach ($page_events as $event_name => &$event) {
@@ -644,25 +651,34 @@ function _GetMapOrImage($rows, $url, $page_events, $options, $is_image_map, $pag
                 }
                 $color = $event_colors[$event_name];
                 $endpoints = array(
-                    array('y1' => $top + 1 + $row_height,
-                          'y2' => $top + $data_height - $row_height - 1));
+                    array(
+                        'y1' => $top + 1 + $row_height,
+                        'y2' => $top + $data_height - $row_height - 1
+                    )
+                );
                 if (isset($perfs) && is_array($perfs)) {
                     foreach ($perfs as $p) {
                         if (isset($p['count']) && isset($p['y1']) && isset($p['y2'])) {
                             // Add event lines in performance chart area.
-                            $endpoints[] = array('y1' => $p['y1'] + 1,
-                                                 'y2' => $p['y2'] - 1);
+                            $endpoints[] = array(
+                                'y1' => $p['y1'] + 1,
+                                'y2' => $p['y2'] - 1
+                            );
                         }
                     }
                 }
                 if (isset($pcap_slices)) {
-                    $endpoints[] = array('y1' => $pcap_slices['y1'] + 1,
-                                       'y2' => $pcap_slices['y2'] - 1);
+                    $endpoints[] = array(
+                        'y1' => $pcap_slices['y1'] + 1,
+                        'y2' => $pcap_slices['y2'] - 1
+                    );
                 }
                 if (isset($cpu_slice_info)) {
                     foreach ($cpu_slice_info as $p) {
-                        $endpoints[] = array('y1' => $p['y1'] + 1,
-                                         'y2' => $p['y2'] - 1);
+                        $endpoints[] = array(
+                            'y1' => $p['y1'] + 1,
+                            'y2' => $p['y2'] - 1
+                        );
                     }
                 }
                 foreach ($endpoints as $y1y2) {
@@ -725,7 +741,7 @@ function _GetMapOrImage($rows, $url, $page_events, $options, $is_image_map, $pag
                 }
             }
 
-          // Draw the performance data.
+            // Draw the performance data.
             if (isset($perfs) && is_array($perfs)) {
                 foreach ($perfs as $p) {
                     $x1 = $x_scaler(0);
@@ -733,20 +749,20 @@ function _GetMapOrImage($rows, $url, $page_events, $options, $is_image_map, $pag
                     foreach ($p['data'] as $ms => $value) {
                         $x2 = $x_scaler($ms);
                         if (array_key_exists('max', $p) && $p['max'] != 0) {
-                            $y2 = ($p['y1'] + $p['height'] - 2 - (int)((double)($p['height'] - 3) * (double)$value / $p['max']));
+                            $y2 = ($p['y1'] + $p['height'] - 2 - (int)((float)($p['height'] - 3) * (float)$value / $p['max']));
                             if ($x2 <= $data_x) {
                                 $x2 = $data_x + 1;
                             }
                             if ($x2 >= $width - 1 && $x1 < $x2) {
-                              // Point goes off the graph.
-                              // Interpolate the ending y-coordinate.
+                                // Point goes off the graph.
+                                // Interpolate the ending y-coordinate.
                                 $r = ($width - 2 - $x1) / ($x2 - $x1);
                                 $y2 = $y1 + (($y2 - $y1) * $r);
                                 $x2 = $width - 2;
                             }
                             if ($x2 > $x1 + 1) {
-                              // If the data spans multiple columns, draw a flat line at the
-                              // current value size it is really an average for the last time bucket.
+                                // If the data spans multiple columns, draw a flat line at the
+                                // current value size it is really an average for the last time bucket.
                                 if (isset($y1)) {
                                     imageline($im, $x1, $y1, $x1, $y2, $p['color']);
                                 }
@@ -767,7 +783,7 @@ function _GetMapOrImage($rows, $url, $page_events, $options, $is_image_map, $pag
 
             // Draw the pcap-based bandwidth data
             if (isset($pcap_slices)) {
-              // go through each pixel and draw bars individually
+                // go through each pixel and draw bars individually
                 $x1 = $data_x + 1;
                 $x2 = $data_x + $data_width;
                 $pcap_top = $pcap_slices['y1'] + 1;
@@ -797,7 +813,7 @@ function _GetMapOrImage($rows, $url, $page_events, $options, $is_image_map, $pag
             // Draw the Timeline information
             if (isset($cpu_slice_info) && $max_ms) {
                 foreach ($cpu_slice_info as $p) {
-                  // go through each pixel and draw bars individually
+                    // go through each pixel and draw bars individually
                     $x1 = $data_x + 1;
                     $x2 = $data_x + $data_width;
                     $cpu_top = $p['y1'] + 1;
@@ -817,10 +833,10 @@ function _GetMapOrImage($rows, $url, $page_events, $options, $is_image_map, $pag
                                     $y = $cpu_bottom;
                                     foreach ($cpu_slice_colors as $type => $color) {
                                         if (isset($cpu_times[$type])) {
-                                              $pixels = $cpu_height * $cpu_times[$type];
-                                              $y2 = max($y - $pixels, $cpu_top);
-                                              imageline($im, $x, $y, $x, $y2, $color);
-                                              $y = $y2;
+                                            $pixels = $cpu_height * $cpu_times[$type];
+                                            $y2 = max($y - $pixels, $cpu_top);
+                                            imageline($im, $x, $y, $x, $y2, $color);
+                                            $y = $y2;
                                         }
                                     }
                                 }
@@ -840,9 +856,9 @@ function _GetMapOrImage($rows, $url, $page_events, $options, $is_image_map, $pag
                 $iScale = $iWidth / $max_ms;
                 $color_interactive = GetColor($im, 178, 234, 148);
                 $color_blocked = GetColor($im, 255, 82, 62);
-              // Default everything to not-interactive
+                // Default everything to not-interactive
                 imagefilledrectangle($im, $iLeft, $iTop, $iRight, $iBottom, $color_blocked);
-              // Draw the explicit windows from the data
+                // Draw the explicit windows from the data
                 $max_interactive = 0;
                 foreach ($interactive as $period) {
                     $l = $iLeft + intval(max(0, $period[0] * $iScale));
@@ -852,12 +868,12 @@ function _GetMapOrImage($rows, $url, $page_events, $options, $is_image_map, $pag
                         imagefilledrectangle($im, $l, $iTop, $r, $iBottom, $color_interactive);
                     }
                 }
-              // everything after the last window is interactive
+                // everything after the last window is interactive
                 if ($max_interactive < $iRight) {
                     imagefilledrectangle($im, $max_interactive, $iTop, $iRight, $iBottom, $color_interactive);
                 }
 
-              // Everything before first contentful paint (or start render if its not available) is N/A (blank)
+                // Everything before first contentful paint (or start render if its not available) is N/A (blank)
                 if (isset($page_events['first_contentful_paint'])) {
                     $start_event = $page_events['first_contentful_paint'];
                 } elseif (isset($page_events['render'])) {
@@ -877,9 +893,11 @@ function _GetMapOrImage($rows, $url, $page_events, $options, $is_image_map, $pag
                     if ($name != 'srt' && $value > 0 && $value <= $max_ms) {
                         $x = $x_scaler($value);
                         if (!$is_thumbnail) {
-                            $triangle_coords = array($x - 3, $top + $row_height + 1,
-                                                     $x,     $top + $row_height + 9,
-                                                     $x + 3, $top + $row_height + 1);
+                            $triangle_coords = array(
+                                $x - 3, $top + $row_height + 1,
+                                $x,     $top + $row_height + 9,
+                                $x + 3, $top + $row_height + 1
+                            );
                             imagefilledpolygon($im, $triangle_coords, 3, $event_colors['user']);
                         }
                         imageline($im, $x, $top + $row_height + 1, $x, $top + $height - 1, $event_colors['user']);
@@ -932,8 +950,8 @@ function _GetMapOrImage($rows, $url, $page_events, $options, $is_image_map, $pag
             $text_color = $black;
             if (
                 isset($document_url) && isset($row['requests']) && count($row['requests']) == 1 &&
-                    isset($row['requests'][0]['documentURL']) &&
-                    _BaseDocumentUrl($row['requests'][0]['documentURL']) != _BaseDocumentUrl($document_url)
+                isset($row['requests'][0]['documentURL']) &&
+                _BaseDocumentUrl($row['requests'][0]['documentURL']) != _BaseDocumentUrl($document_url)
             ) {
                 $text_color = $alt_text_color;
             }
@@ -1159,10 +1177,10 @@ function _GetMapOrImage($rows, $url, $page_events, $options, $is_image_map, $pag
 }
 
 /**
-* Filter the requests we choose to display
-*
-* @param mixed $requests
-*/
+ * Filter the requests we choose to display
+ *
+ * @param mixed $requests
+ */
 function FilterRequests($requests)
 {
     $filtered_requests = array();
@@ -1230,8 +1248,8 @@ class XScaler
 
     public function __invoke($value)
     {
-        return $this->value_max ? $this->x_start + (int)((double)$this->x_width * (double)$value /
-                                      $this->value_max) : 0;
+        return $this->value_max ? $this->x_start + (int)((float)$this->x_width * (float)$value /
+            $this->value_max) : 0;
     }
 
     public function bar_width_ms()
@@ -1275,8 +1293,8 @@ function AverageCpuSlices(&$slices, $first_slice, $last_slice, $slice_usecs, &$k
 }
 
 /**
-*
-*/
+ *
+ */
 function AddRowCoordinates(&$rows, $y1, $row_height)
 {
     if (isset($rows) && is_array($rows) && count($rows)) {
@@ -1313,14 +1331,14 @@ function SetRowColors(&$rows, $is_page_loaded, $bg_colors)
 }
 
 /**
-* Draw performance chart label.
-*
-* @param resource $im is an image resource.
-* @param string $key is which performance chart (e.g. 'cpu' or 'bw').
-* @param mixed $perf is an array of performance parameters.
-* @param int $font is the label's font indentifier.
-* @param int $label_color color identifier
-*/
+ * Draw performance chart label.
+ *
+ * @param resource $im is an image resource.
+ * @param string $key is which performance chart (e.g. 'cpu' or 'bw').
+ * @param mixed $perf is an array of performance parameters.
+ * @param int $font is the label's font indentifier.
+ * @param int $label_color color identifier
+ */
 function DrawPerfLabel($im, $key, $perf, $font, $label_color)
 {
     $x = $perf['x1'];
@@ -1359,14 +1377,14 @@ function DrawPerfLabel($im, $key, $perf, $font, $label_color)
 }
 
 /**
-* Draw performance chart background.
-*
-* @param resource $im is an image resource.
-* @param mixed $perf is an array of performance parameters.
-* @param int $data_x is where the data area of the chart begins.
-* @param int $border_color color identifier
-* @param int $bg_color color identifier
-*/
+ * Draw performance chart background.
+ *
+ * @param resource $im is an image resource.
+ * @param mixed $perf is an array of performance parameters.
+ * @param int $data_x is where the data area of the chart begins.
+ * @param int $border_color color identifier
+ * @param int $bg_color color identifier
+ */
 function DrawPerfBackground($im, $perf, $data_x, $border_color, $bg_color)
 {
     $x1 = $perf['x1'];
@@ -1387,8 +1405,8 @@ function DrawPerfBackground($im, $perf, $data_x, $border_color, $bg_color)
 }
 
 /**
-* Return marker interval best fit.
-*/
+ * Return marker interval best fit.
+ */
 function TimeScaleInterval($max_ms, $target_count)
 {
     $target_interval = (float)$max_ms / (float)$target_count;
@@ -1407,9 +1425,9 @@ function TimeScaleInterval($max_ms, $target_count)
 }
 
 /**
-* Format the label for the time scale.
-* @return string
-*/
+ * Format the label for the time scale.
+ * @return string
+ */
 function TimeScaleLabel($ms, $interval)
 {
     $places = 2;
@@ -1422,18 +1440,18 @@ function TimeScaleLabel($ms, $interval)
 }
 
 /**
-* Draw $label centered at $x.
-*/
+ * Draw $label centered at $x.
+ */
 function DrawCenteredText($im, $x, $y, $label, $font, $font_width, $color)
 {
-    $x -= (int)((double)$font_width * (double)strlen($label) / 2.0);
+    $x -= (int)((float)$font_width * (float)strlen($label) / 2.0);
     imagestring($im, $font, $x, $y, $label, $color);
 }
 
 /**
-* Format the label for a request.
-* @return string
-*/
+ * Format the label for a request.
+ * @return string
+ */
 function GetRequestLabel($request, $show_labels = true)
 {
     $path = parse_url(
@@ -1458,8 +1476,8 @@ function GetRequestLabel($request, $show_labels = true)
 }
 
 /**
-* Append imagemap data for the main document url.
-*/
+ * Append imagemap data for the main document url.
+ */
 function AddMapUrl(&$map, $x1, $y1, $x2, $y2, $url)
 {
     $map[] = array(
@@ -1467,12 +1485,13 @@ function AddMapUrl(&$map, $x1, $y1, $x2, $y2, $url)
         'left' => $x1,
         'right' => $x2,
         'top' => $y1,
-        'bottom' => $y2);
+        'bottom' => $y2
+    );
 }
 
 /**
-* Append imagemap data for a single request.
-*/
+ * Append imagemap data for a single request.
+ */
 function AddMapRequest(&$map, $x1, $y1, $x2, $y2, $request)
 {
     $scheme = $request['is_secure'] ? 'https://' : 'http://';
@@ -1482,12 +1501,13 @@ function AddMapRequest(&$map, $x1, $y1, $x2, $y2, $request)
         'left' => $x1,
         'right' => $x2,
         'top' => $y1,
-        'bottom' => $y2);
+        'bottom' => $y2
+    );
 }
 
 /**
-* Append imagemap data for user timing.
-*/
+ * Append imagemap data for user timing.
+ */
 function AddMapUserTime(&$map, $x1, $y1, $x2, $y2, $label)
 {
     $map[] = array(
@@ -1495,14 +1515,15 @@ function AddMapUserTime(&$map, $x1, $y1, $x2, $y2, $label)
         'left' => $x1,
         'right' => $x2,
         'top' => $y1,
-        'bottom' => $y2);
+        'bottom' => $y2
+    );
 }
 
 
 /**
-* Return an array of 'bars' which define the coordinates and colors
-* for the parts of a request.
-*/
+ * Return an array of 'bars' which define the coordinates and colors
+ * for the parts of a request.
+ */
 function GetBars(
     $request,
     $x_scaler,
@@ -1527,12 +1548,12 @@ function GetBars(
         $state_y2 = $state_y1 + $state_height - 1;
     }
     $state_keys = array('dns', 'connect', 'ssl');
-  // TODO(slamm): tweak minimum width of bars.
-  //  (!$is_mime && !$is_thumbnail) ? each bar at least 1px
-  //   ($is_mime && !$is_thumbnail) ? each bar group at least 1px
+    // TODO(slamm): tweak minimum width of bars.
+    //  (!$is_mime && !$is_thumbnail) ? each bar at least 1px
+    //   ($is_mime && !$is_thumbnail) ? each bar group at least 1px
     if ($show_chunks && isset($request['chunks']) && is_array($request['chunks']) && count($request['chunks'])) {
-      // Show each chunk of the download
-      // First, draw the TTFB color over the whole request time
+        // Show each chunk of the download
+        // First, draw the TTFB color over the whole request time
         if ($pass == 1) {
             $start_key = null;
             if (isset($request['ttfb_start'])) {
@@ -1552,9 +1573,9 @@ function GetBars(
                 AddBarIfValid($bars, 'ttfb', $start_key, $end_key, $request, $x_scaler, $y1, $y2);
             }
         }
-      // Add a bar for the TTFB (headers)
+        // Add a bar for the TTFB (headers)
         AddBarIfValid($bars, 'download', 'download_start', 'download_start', $request, $x_scaler, $y1, $y2);
-      // Make sure the chunks stay withing the download time
+        // Make sure the chunks stay withing the download time
         $min = null;
         if (isset($request['download_start'])) {
             $min = $request['download_start'];
@@ -1563,7 +1584,7 @@ function GetBars(
         } elseif (isset($request['ttfb_start'])) {
             $min = $request['ttfb_start'];
         }
-      // Add the bars for each chunk
+        // Add the bars for each chunk
         foreach ($request['chunks'] as $chunk) {
             if (isset($chunk['ts'])) {
                 $start = $chunk['ts'];
@@ -1576,14 +1597,15 @@ function GetBars(
                 }
                 $start = max($min, $start);
                 $bars[] = array(
-                $x_scaler(round($start)), $x_scaler(round($chunk['ts'])),
-                $y1, $y2,
-                $request['colors']['download'],
-                true);
+                    $x_scaler(round($start)), $x_scaler(round($chunk['ts'])),
+                    $y1, $y2,
+                    $request['colors']['download'],
+                    true
+                );
             }
         }
     } else {
-      // Show the download as a single bar
+        // Show the download as a single bar
         AddBarIfValid($bars, 'download', 'download_start', 'download_end', $request, $x_scaler, $y1, $y2);
         if ($pass == 1) {
             AddBarIfValid($bars, 'ttfb', 'ttfb_start', 'ttfb_end', $request, $x_scaler, $y1, $y2);
@@ -1602,7 +1624,7 @@ function GetBars(
         $js_y2 = $js_y1 + $js_height - 1;
         $bar_width_ms = $x_scaler->bar_width_ms();
         foreach ($request['js_timing'] as $times) {
-          // Calculate the bar hight if the width is less than 1 pixel
+            // Calculate the bar hight if the width is less than 1 pixel
             $duration = $times[1] - $times[0];
             $bar_y1 = $js_y1;
             $bar_y2 = $js_y2;
@@ -1615,18 +1637,19 @@ function GetBars(
             }
 
             $bars[] = array(
-            $x_scaler($times[0]), $x_scaler($times[1]),
-            $bar_y1, $bar_y2,
-            $request['colors']['js'],
-            false);
+                $x_scaler($times[0]), $x_scaler($times[1]),
+                $bar_y1, $bar_y2,
+                $request['colors']['js'],
+                false
+            );
         }
     }
     return $bars;
 }
 
 /**
-* Append a bar for a request part if it exists and is valid
-*/
+ * Append a bar for a request part if it exists and is valid
+ */
 function AddBarIfValid(&$bars, $key, $start_key, $end_key, $request, $x_scaler, $y1, $y2)
 {
     if (
@@ -1644,14 +1667,14 @@ function AddBarIfValid(&$bars, $key, $start_key, $end_key, $request, $x_scaler,
             $y2,
             $request['colors'][$key],
             true
-            );
+        );
     }
 }
 
 /**
-* Insert an interactive waterfall into the current page
-*
-*/
+ * Insert an interactive waterfall into the current page
+ *
+ */
 function InsertWaterfall($url, $requests, $id, $run, $cached, $page_data, $waterfall_options = '')
 {
     echo CreateWaterfallHtml($url, $requests, $id, $run, $cached, $page_data, $waterfall_options);
@@ -1676,7 +1699,7 @@ function CreateWaterfallHtml($url, $requests, $id, $run, $cached, $page_data, $w
         'show_labels' => true,
         'is_mime' => $is_mime,
         'width' => 1012
-        );
+    );
     $rows = GetRequestRows($requests, false);
     $map = GetWaterfallMap($rows, $url, $options, $page_data);
     foreach ($map as $entry) {
@@ -1695,7 +1718,7 @@ function CreateWaterfallHtml($url, $requests, $id, $run, $cached, $page_data, $w
     // main container for the waterfall
     $out .= '<div class="waterfall-container">';
     $out .= "<img class=\"waterfall-image\" alt=\"\" usemap=\"#waterfall_map_step$step\" " .
-            "id=\"waterfall_step$step\" src=\"/waterfall.php?test=$id&run=$run&cached=$cached&step=$step$waterfall_options\">";
+        "id=\"waterfall_step$step\" src=\"/waterfall.php?test=$id&run=$run&cached=$cached&step=$step$waterfall_options\">";
 
     $stepSuffix = "-step" . $step;
     // draw div's over each of the waterfall elements (use the image map as a reference)
@@ -1777,14 +1800,14 @@ function InsertMultiWaterfall(&$waterfalls, $waterfall_options)
     echo '<div>';
     if (count($waterfalls) == 2) {
         echo "<label><span>{$waterfalls[0]['label']}</span> <input type=\"range\" class=\"waterfall-transparency\" name=\"waterfall-1\" min=\"0.0\" max=\"1.0\" step=\"0.01\" value=\"0.5\">{$waterfalls[1]['label']}</label>";
-     // echo "<tr><td>{$waterfalls[0]['label']}</td><td><input type=\"range\" class=\"waterfall-transparency\" name=\"waterfall-1\" min=\"0.0\" max=\"1.0\" step=\"0.01\" value=\"0.5\"></td><td>{$waterfalls[1]['label']}</tr></td>";
+        // echo "<tr><td>{$waterfalls[0]['label']}</td><td><input type=\"range\" class=\"waterfall-transparency\" name=\"waterfall-1\" min=\"0.0\" max=\"1.0\" step=\"0.01\" value=\"0.5\"></td><td>{$waterfalls[1]['label']}</tr></td>";
     } else {
         foreach ($waterfalls as $index => &$waterfall) {
             $o = $index ? $opacity : '1.0';
-       //echo '<tr>';
+            //echo '<tr>';
             echo "<label><span>{$waterfall['label']}</span> <input type=\"range\" class=\"waterfall-transparency\" name=\"waterfall-$index\" min=\"0.0\" max=\"1.0\" step=\"0.01\" value=\"$o\"></label>";
-          //echo "<td>{$waterfall['label']}</td><td><input type=\"range\" class=\"waterfall-transparency\" name=\"waterfall-$index\" min=\"0.0\" max=\"1.0\" step=\"0.01\" value=\"$o\"></td>";
-         // echo '</tr>';
+            //echo "<td>{$waterfall['label']}</td><td><input type=\"range\" class=\"waterfall-transparency\" name=\"waterfall-$index\" min=\"0.0\" max=\"1.0\" step=\"0.01\" value=\"$o\"></td>";
+            // echo '</tr>';
         }
     }
     echo '</div>';
@@ -1809,7 +1832,7 @@ function InsertMultiWaterfall(&$waterfalls, $waterfall_options)
         $data_header_height = intval($row_height * 3 / 2);
         $data_footer_height = $row_height * 3;
         $height = ($data_header_height + ($row_height * $row_count) +
-              $data_footer_height + 2) + 150;
+            $data_footer_height + 2) + 150;
         $height += (2 * $row_height) + 2;
         $height_style = "height:{$height}px;";
     }
@@ -1829,7 +1852,7 @@ function LoadPcapSlices($slices_file, $max_bw)
     if (gz_is_file($slices_file)) {
         $original = json_decode(gz_file_get_contents($slices_file), true);
         if (isset($original) && is_array($original) && isset($original['in'])) {
-          // The data is stored as bytes in 100ms buckets.  Convert that to bps values
+            // The data is stored as bytes in 100ms buckets.  Convert that to bps values
             $data = array();
             foreach ($original as $series => $values) {
                 $data[$series] = array();
@@ -1839,7 +1862,7 @@ function LoadPcapSlices($slices_file, $max_bw)
                 }
             }
 
-          // Smooth out the bandwidth as needed to not exceed the configured bandwidth
+            // Smooth out the bandwidth as needed to not exceed the configured bandwidth
             if ($max_bw) {
                 $max_bw *= 1000;
                 foreach ($data as $series => $values) {
@@ -1862,9 +1885,9 @@ function LoadPcapSlices($slices_file, $max_bw)
                                         $extra -= $available;
                                         $data[$series][$last_time] = $max_bw;
                                     } else {
-                                          $extra = 0;
-                                          $bw = $extra / $elapsed;
-                                          $data[$series][$last_time] += $bw;
+                                        $extra = 0;
+                                        $bw = $extra / $elapsed;
+                                        $data[$series][$last_time] += $bw;
                                     }
                                 }
                             }
@@ -1908,7 +1931,7 @@ function GetAverageSliceValue($slices, $start, $end)
     $slice_width = $end - $start;
 
     if ($slice_width > 0.1) {
-      // Spans multiple buckets
+        // Spans multiple buckets
         $count = 0;
         $total = 0.0;
         for ($bucket = $start_bucket; $bucket <= $end_bucket; $bucket += 0.1) {
@@ -1922,13 +1945,13 @@ function GetAverageSliceValue($slices, $start, $end)
             $avg = $total / floatval($count);
         }
     } elseif ($end == $start) {
-      // One value exactly (unlikely)
+        // One value exactly (unlikely)
         $key = number_format($start_bucket, 1, '.', '');
         if (isset($slices[$key])) {
             $avg = floatval($slices[$key]);
         }
     } else {
-      // Falls between two buckets
+        // Falls between two buckets
         $start_key = number_format($start_bucket, 1, '.', '');
         $end_key = number_format($end_bucket, 1, '.', '');
         if (isset($slices[$start_key]) && isset($slices[$end_key])) {
@@ -1944,30 +1967,32 @@ function GetAverageSliceValue($slices, $start, $end)
 }
 
 /**
-* Add the JS execution times to the requests
-*
-* @param mixed $requests
-* @param mixed $script_timings_file
-*/
+ * Add the JS execution times to the requests
+ *
+ * @param mixed $requests
+ * @param mixed $script_timings_file
+ */
 
 function AddRequestScriptTimings(&$requests, $script_timings_file)
 {
-    $script_events = array('EvaluateScript',
-                         'v8.compile',
-                         'FunctionCall',
-                         'GCEvent',
-                         'TimerFire',
-                         'EventDispatch',
-                         'TimerInstall',
-                         'TimerRemove',
-                         'XHRLoad',
-                         'XHRReadyStateChange',
-                         'MinorGC',
-                         'MajorGC',
-                         'FireAnimationFrame',
-                         'ThreadState::completeSweep',
-                         'Heap::collectGarbage',
-                         'ThreadState::performIdleLazySweep');
+    $script_events = array(
+        'EvaluateScript',
+        'v8.compile',
+        'FunctionCall',
+        'GCEvent',
+        'TimerFire',
+        'EventDispatch',
+        'TimerInstall',
+        'TimerRemove',
+        'XHRLoad',
+        'XHRReadyStateChange',
+        'MinorGC',
+        'MajorGC',
+        'FireAnimationFrame',
+        'ThreadState::completeSweep',
+        'Heap::collectGarbage',
+        'ThreadState::performIdleLazySweep'
+    );
     if (gz_is_file($script_timings_file)) {
         $timings = json_decode(gz_file_get_contents($script_timings_file), true);
         if (
diff --git a/www/waterfall.js b/www/waterfall.js
index a8cfd87544..04cc914f0a 100644
--- a/www/waterfall.js
+++ b/www/waterfall.js
@@ -238,12 +238,12 @@ function SelectRequest(step, request) {
     ) {
       //let's create a nice clean list of hints
       let hints = r["early_hint_headers"][1].substring(6);
-      hints = hints.split(",");
+      hints = hints.split(/(?=\<)/);
 
       details += "<b>Early Hints: </b>";
       details += "<ul class='hints'>";
       hints.forEach((hint) => {
-        details += "<li>" + htmlEncode(hint) + "</li>";
+        details += "<li>" + htmlEncode(hint.replace(/\,/, '')) + "</li>";
       });
       details += "</ul><br>";
     }
diff --git a/www/waterfall.php b/www/waterfall.php
index a8c8c47af8..cc460dcbf0 100644
--- a/www/waterfall.php
+++ b/www/waterfall.php
@@ -51,22 +51,23 @@
 }
 
 $options = array(
-  'id' => $id,
-  'path' => $testPath,
-  'run_id' => $run,
-  'is_cached' => $cached,
-  'step_id' => $step,
-  'use_cpu' =>     (!isset($_REQUEST['cpu'])    || $_REQUEST['cpu'] != 0),
-  'use_bw' =>      (!isset($_REQUEST['bw'])     || $_REQUEST['bw'] != 0),
-  'show_labels' => $show_labels,
-  'show_chunks' => $show_chunks,
-  'max_bw' => $bwIn,
-  'is_mime' => $is_mime,
-  'is_state' => $is_state,
-  'include_js' => $include_js,
-  'include_wait' => $include_wait,
-  'show_user_timing' => (isset($_REQUEST['ut']) ? $_REQUEST['ut'] : GetSetting('waterfall_show_user_timing')),
-  'rowcount' => $rowcount
+    'id' => $id,
+    'path' => $testPath,
+    'run_id' => $run,
+    'is_cached' => $cached,
+    'step_id' => $step,
+    'use_cpu' => (!isset($_REQUEST['cpu']) || $_REQUEST['cpu'] != 0),
+    'use_bw' => (!isset($_REQUEST['bw']) || $_REQUEST['bw'] != 0),
+    'use_lt' => (!isset($_REQUEST['lt']) || $_REQUEST['lt'] != 0),
+    'show_labels' => $show_labels,
+    'show_chunks' => $show_chunks,
+    'max_bw' => $bwIn,
+    'is_mime' => $is_mime,
+    'is_state' => $is_state,
+    'include_js' => $include_js,
+    'include_wait' => $include_wait,
+    'show_user_timing' => (isset($_REQUEST['ut']) ? $_REQUEST['ut'] : GetSetting('waterfall_show_user_timing')),
+    'rowcount' => $rowcount
 );
 
 $url = $testStepResult->readableIdentifier($url);


Test patch:
diff --git a/www/resources/views/pages/testhistory.blade.php b/www/resources/views/pages/testhistory.blade.php
new file mode 100644
index 0000000000..844431c13c
--- /dev/null
+++ b/www/resources/views/pages/testhistory.blade.php
@@ -0,0 +1,85 @@
+@extends('default')
+
+@section('content')
+
+<div class="history_hed">
+    <h1>Test History</h1>
+
+    <form name="filterLog" method="get" action="/testlog.php">
+
+        @if (!$is_logged_in)
+            <div class="logged-out-history">
+                <p>Test history is available for up to 30 days as long as your storage isn't cleared. By registering for a free account, you can keep test history for longer, compare tests, and review changes. Additionally, you will also be able to post on the <a href="https://forums.webpagetest.org">WebPageTest Forum</a> and contribute to the discussions there about features, test results and more.</p>
+                <a href="{{ $protocol }}://{{ $host }}/signup" ?>" class="btn-primary">Get Free Access</a>
+            </div>
+        @endif
+        <div class="history_filter">
+            <label for="filter">Filter test history:</label>
+            <input id="filter" name="filter" type="text" onkeyup="filterHistory()" placeholder="Search">
+            @if ($is_logged_in)
+                <label for="days" class="a11y-hidden">Select how far back you want to see</label>
+                <select name="days" id="days">
+                    <option value="1" @if ($days === 1) selected @endif>1 Day</option>
+                    <option value="7" @if ($days === 7) selected @endif>7 Days</option>
+                    <option value="30" @if ($days === 30) selected @endif>30 Days</option>
+                    <option value="128" @if ($days === 128) selected @endif>128 Days</option>
+                    <option value="365" @if ($days === 1) selected @endif>356 Days</option>
+                </select>
+            @endif
+        </div>
+    </form>
+</div>
+<div class="box">
+    <form name="compare" method="get" action="/video/compare.php">
+        <div class="history-controls">
+            <input id="CompareBtn" type="submit" value="Compare Selected Tests">
+        </div>
+        <div class="scrollableTable">
+            <table id="history" class="history pretty" border="0" cellpadding="5px" cellspacing="0">
+                <thead>
+                    <tr>
+                        <th class="pin"><span>Select to compare</span></th>
+                        <th class="url">URL</th>
+                        <th class="date">Run Date</th>
+                        <th class="location">Run From</th>
+                        <th class="label">Label</th>
+                    </tr>
+                </thead>
+                @if ($is_logged_in)
+                    <tbody id="historyBody">
+                        @foreach ($test_history as $record)
+                            <tr>
+                                <th><input type="checkbox" name="t[]" value="{{ $record->getTestId() }}" aria-label="Select this test" /></th>
+                                <td class="url"><a href="/result/{{ $record->getTestId() }}/">{{ $record->getUrl() }}</a></td>
+                                <td class="date">{{ date_format(date_create($record->getStartTime()), 'M d, Y g:i:s A e') }}</td>
+                                <td class="location">{{ $record->getLocation() }}</td>
+                                <td class="label">{{ $record->getLabel() }}</td>
+                            </tr>
+                        @endforeach
+                    </tbody>
+                @endif
+            </table>
+        </div>
+
+
+        @if ($local)
+            <input type="hidden" name="local" value="1">
+        @endif
+        @isset($priority))
+            <input type="hidden" name="priority" value="{{ $priority }}">
+        @endisset
+    </form>
+</div>
+
+<script>
+    <?php
+    if ($is_logged_in) {
+        include __DIR__ . '/../../js/history-loggedin.js';
+    } else {
+        // if not logged in, build a local searchable test history from the data stored in indexeddb.
+        include __DIR__ . '/../../js/history.js';
+    }
+    ?>
+</script>
+
+@endsection
\ No newline at end of file
diff --git a/www/runtest.php b/www/runtest.php
index 2ecde70de3..5c481930b0 100644
--- a/www/runtest.php
+++ b/www/runtest.php
@@ -2244,7 +2244,7 @@ function WriteJob($location, &$test, &$job, $testId)
         if (AddTestJob($location, $job, $test, $testId)) {
             $ret = true;
         } else {
-            $error = "Sorry, that test location appears to be unavailable.  Pleasy try again later.";
+            $error = "Sorry, that test location appears to be unavailable.  Please try again later.";
         }
     }
 
diff --git a/www/testlog.php b/www/testlog.php
index 62d0782fa2..fda5d1cbbe 100644
--- a/www/testlog.php
+++ b/www/testlog.php
@@ -88,9 +88,7 @@ function check_it($val)
 // single user history
 if (!$csv && ($is_logged_in || (!isset($user) && !isset($_COOKIE['google_email']) && Util::getSetting('localHistory')))) {
     $GLOBALS['tab'] = 'Test History';
-    $tpl = new Template('testhistory');
-    $tpl->setLayout('default');
-    echo $tpl->render('user', array(
+    $vars = [
         'is_logged_in' => $is_logged_in,
         'protocol' => $protocol,
         'host' => $host,
@@ -100,7 +98,17 @@ function check_it($val)
         'local' => isset($_REQUEST['local']) && $_REQUEST['local'],
         'body_class' => 'history',
         'page_title' => 'WebPageTest - Test History',
-    ));
+
+    ];
+    /*
+    // uncomment for Blade
+    require_once __DIR__ . '/resources/view.php';
+    echo view('pages.testhistory', $vars);
+    exit();
+    */
+    $tpl = new Template('testhistory');
+    $tpl->setLayout('default');
+    echo $tpl->render('user', $vars);
     exit();
 }