Bug 1499822 - [tryselect] Implement |mach try chooser| r=sclements
☠☠ backed out by 5ecacfcd6e65 ☠ ☠
authorAndrew Halberstadt <ahalberstadt@mozilla.com>
Tue, 08 Jan 2019 16:46:57 +0000
changeset 452977 beb286aeae336f30ab16a038eb3ce81345985268
parent 452976 9a330195eca662c4b58eb4a82e7125cbb4b11f46
child 452978 c5f89e53d636d57680224b2f29c76919c207cf18
push id111015
push userrmaries@mozilla.com
push dateWed, 09 Jan 2019 03:58:32 +0000
treeherdermozilla-inbound@432b8b97bb6f [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewerssclements
bugs1499822
milestone66.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1499822 - [tryselect] Implement |mach try chooser| r=sclements Usage: $ ./mach try chooser Will start a local flask server and server a "trychooser-like" page that is dynamically generated from the taskgraph. Differential Revision: https://phabricator.services.mozilla.com/D14903
.eslintignore
tools/tryselect/docs/selectors/chooser.rst
tools/tryselect/docs/selectors/index.rst
tools/tryselect/mach_commands.py
tools/tryselect/selectors/chooser/.eslintrc.js
tools/tryselect/selectors/chooser/__init__.py
tools/tryselect/selectors/chooser/app.py
tools/tryselect/selectors/chooser/static/filter.js
tools/tryselect/selectors/chooser/static/select.js
tools/tryselect/selectors/chooser/static/style.css
tools/tryselect/selectors/chooser/templates/chooser.html
tools/tryselect/selectors/chooser/templates/close.html
tools/tryselect/selectors/chooser/templates/layout.html
--- a/.eslintignore
+++ b/.eslintignore
@@ -353,12 +353,13 @@ browser/components/payments/res/vendor/*
 toolkit/components/reader/Readability.js
 toolkit/components/reader/JSDOMParser.js
 
 # Uses preprocessing
 toolkit/components/reader/Readerable.jsm
 toolkit/content/widgets/wizard.xml
 toolkit/modules/AppConstants.jsm
 toolkit/mozapps/update/tests/data/xpcshellConstantsPP.js
+tools/tryselect/selectors/chooser/templates/chooser.html
 
 # Third party
 toolkit/modules/third_party/**
 third_party/**
new file mode 100644
--- /dev/null
+++ b/tools/tryselect/docs/selectors/chooser.rst
@@ -0,0 +1,33 @@
+Chooser Selector
+================
+
+When pushing to try, there are a very large amount of builds and tests to choose from. Often too
+many to remember, making it easy to forget a set of tasks which should otherwise have been run.
+
+This selector allows you to select tasks from a web interface that lists all the possible build and
+test tasks and allows you to select them from a list. It is similar in concept to the old `try
+syntax chooser`_ page, except that the values are dynamically generated using the `taskgraph`_ as an
+input. This ensures that it will never be out of date.
+
+To use:
+
+.. code-block:: shell
+
+    $ mach try chooser
+
+This will spin up a local web server (using Flask) which serves the chooser app. After making your
+selection, simply press ``Push`` and the rest will be handled from there. No need to copy/paste any
+syntax strings or the like.
+
+You can run:
+
+.. code-block:: shell
+
+    $ mach try chooser --full
+
+To generate the interface using the full taskgraph instead. This will include tasks that don't run
+on mozilla-central.
+
+
+.. _try syntax chooser: https://mozilla-releng.net/trychooser
+.. _taskgraph: https://firefox-source-docs.mozilla.org/taskcluster/taskcluster/index.html
--- a/tools/tryselect/docs/selectors/index.rst
+++ b/tools/tryselect/docs/selectors/index.rst
@@ -1,13 +1,14 @@
 Selectors
 =========
 
 These are the currently implemented try selectors:
 
+* :doc:`chooser <chooser>`: Select tasks using a web interface.
 * :doc:`fuzzy <fuzzy>`: Select tasks using a fuzzy finding algorithm and
   a terminal interface.
 * :doc:`again <again>`: Re-run a previous ``try_task_config.json`` based
   push.
 * :doc:`empty <empty>`: Don't select any tasks. Taskcluster will still run
   some tasks automatically (like lint and python unittest tasks). Further tasks
   can be chosen with treeherder's ``Add New Jobs`` feature.
 * :doc:`syntax <syntax>`: Select tasks using classic try syntax.
@@ -25,13 +26,14 @@ See selector specific options by running
 
     $ mach try <selector> --help
 
 .. toctree::
   :caption: Available Selectors
   :maxdepth: 1
   :hidden:
 
+  Chooser <chooser>
   Fuzzy <fuzzy>
   Again <again>
   Empty <empty>
   Syntax <syntax>
   Release <release>
--- a/tools/tryselect/mach_commands.py
+++ b/tools/tryselect/mach_commands.py
@@ -145,16 +145,36 @@ class TrySelect(MachCommandBase):
         For example:
 
           ^start 'exact | !ignore fuzzy end$
         """
         from tryselect.selectors.fuzzy import run_fuzzy_try
         return run_fuzzy_try(**kwargs)
 
     @SubCommand('try',
+                'chooser',
+                description='Schedule tasks by selecting them from a web '
+                            'interface.',
+                parser=get_parser('chooser'))
+    def try_chooser(self, **kwargs):
+        """Push tasks selected from a web interface to try.
+
+        This selector will build the taskgraph and spin up a dynamically
+        created 'trychooser-like' web-page on the localhost. After a selection
+        has been made, pressing the 'Push' button will automatically push the
+        selection to try.
+        """
+        self._activate_virtualenv()
+        self.virtualenv_manager.install_pip_package('flask')
+        self.virtualenv_manager.install_pip_package('flask-wtf')
+
+        from tryselect.selectors.chooser import run_try_chooser
+        return run_try_chooser(**kwargs)
+
+    @SubCommand('try',
                 'again',
                 description='Schedule a previously generated (non try syntax) '
                             'push again.',
                 parser=get_parser('again'))
     def try_again(self, **kwargs):
         from tryselect.selectors.again import run_try_again
         return run_try_again(**kwargs)
 
new file mode 100644
--- /dev/null
+++ b/tools/tryselect/selectors/chooser/.eslintrc.js
@@ -0,0 +1,12 @@
+"use strict";
+
+module.exports = {
+  env: {
+    "jquery": true
+  },
+  globals: {
+    "apply": true,
+    "applyChunks": true,
+    "tasks": true
+  }
+};
new file mode 100644
--- /dev/null
+++ b/tools/tryselect/selectors/chooser/__init__.py
@@ -0,0 +1,50 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+from __future__ import absolute_import, print_function, unicode_literals
+
+import os
+import webbrowser
+from threading import Timer
+
+from tryselect.cli import BaseTryParser
+from tryselect.tasks import generate_tasks
+from tryselect.push import check_working_directory, push_to_try, vcs
+
+here = os.path.abspath(os.path.dirname(__file__))
+
+
+class ChooserParser(BaseTryParser):
+    name = 'chooser'
+    arguments = []
+    common_groups = ['push', 'task']
+    templates = ['artifact', 'env', 'rebuild', 'chemspill-prio', 'gecko-profile']
+
+
+def run_try_chooser(update=False, query=None, templates=None, full=False, parameters=None,
+                    save=False, preset=None, mod_presets=False, push=True, message='{msg}',
+                    **kwargs):
+    from .app import create_application
+    check_working_directory(push)
+
+    tg = generate_tasks(parameters, full, root=vcs.path)
+    app = create_application(tg)
+
+    if os.environ.get('WERKZEUG_RUN_MAIN') == 'true':
+        # we are in the reloader process, don't open the browser or do any try stuff
+        app.run()
+        return
+
+    # give app a second to start before opening the browser
+    Timer(1, lambda: webbrowser.open('http://127.0.0.1:5000')).start()
+    app.run()
+
+    selected = app.tasks
+    if not selected:
+        print("no tasks selected")
+        return
+
+    msg = "Try Chooser Enhanced ({} tasks selected)".format(len(selected))
+    return push_to_try('chooser', message.format(msg=msg), selected, templates, push=push,
+                       closed_tree=kwargs["closed_tree"])
new file mode 100644
--- /dev/null
+++ b/tools/tryselect/selectors/chooser/app.py
@@ -0,0 +1,190 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+from __future__ import absolute_import, print_function
+
+from abc import ABCMeta, abstractproperty
+from collections import defaultdict
+
+from flask import (
+    Flask,
+    render_template,
+    request,
+)
+
+SECTIONS = []
+SUPPORTED_KINDS = set()
+
+
+def register_section(cls):
+    assert issubclass(cls, Section)
+    instance = cls()
+    SECTIONS.append(instance)
+    SUPPORTED_KINDS.update(instance.kind.split(','))
+
+
+class Section(object):
+    __metaclass__ = ABCMeta
+
+    @abstractproperty
+    def name(self):
+        pass
+
+    @abstractproperty
+    def kind(self):
+        pass
+
+    @abstractproperty
+    def title(self):
+        pass
+
+    @abstractproperty
+    def attrs(self):
+        pass
+
+    def contains(self, task):
+        return task.kind in self.kind.split(',')
+
+    def get_context(self, tasks):
+        labels = defaultdict(lambda: {'max_chunk': 0, 'attrs': defaultdict(list)})
+
+        for task in tasks.values():
+            if not self.contains(task):
+                continue
+
+            task = task.attributes
+            label = labels[self.labelfn(task)]
+            for attr in self.attrs:
+                if attr in task and task[attr] not in label['attrs'][attr]:
+                    label['attrs'][attr].append(task[attr])
+
+                if 'test_chunk' in task:
+                    label['max_chunk'] = max(label['max_chunk'], int(task['test_chunk']))
+
+        return {
+            'name': self.name,
+            'kind': self.kind,
+            'title': self.title,
+            'labels': labels,
+        }
+
+
+@register_section
+class Platform(Section):
+    name = 'platform'
+    kind = 'build'
+    title = 'Platforms'
+    attrs = ['build_platform']
+
+    def labelfn(self, task):
+        return task['build_platform']
+
+    def contains(self, task):
+        if not Section.contains(self, task):
+            return False
+
+        # android-stuff tasks aren't actual platforms
+        return not task.task['tags'].get('android-stuff', False)
+
+
+@register_section
+class Test(Section):
+    name = 'test'
+    kind = 'test'
+    title = 'Test Suites'
+    attrs = ['unittest_suite', 'unittest_flavor']
+
+    def labelfn(self, task):
+        suite = task['unittest_suite'].replace(' ', '-')
+        flavor = task['unittest_flavor'].replace(' ', '-')
+
+        if flavor.endswith('chunked'):
+            flavor = flavor[:-len('chunked')]
+
+        if flavor.startswith(suite):
+            flavor = flavor[len(suite):]
+        flavor = flavor.strip('-')
+
+        if flavor in ('crashtest', 'jsreftest'):
+            return flavor
+
+        if flavor:
+            return '{}-{}'.format(suite, flavor)
+        return suite
+
+    def contains(self, task):
+        if not Section.contains(self, task):
+            return False
+        return task.attributes['unittest_suite'] not in ('raptor', 'talos')
+
+
+@register_section
+class Perf(Section):
+    name = 'perf'
+    kind = 'test'
+    title = 'Performance'
+    attrs = ['unittest_suite', 'unittest_flavor', 'raptor_try_name', 'talos_try_name']
+
+    def labelfn(self, task):
+        suite = task['unittest_suite']
+        label = task['{}_try_name'.format(suite)]
+
+        if not label.startswith(suite):
+            label = '{}-{}'.format(suite, label)
+
+        if label.endswith('-e10s'):
+            label = label[:-len('-e10s')]
+
+        return label
+
+    def contains(self, task):
+        if not Section.contains(self, task):
+            return False
+        return task.attributes['unittest_suite'] in ('raptor', 'talos')
+
+
+@register_section
+class Analysis(Section):
+    name = 'analysis'
+    kind = 'build,static-analysis-autotest'
+    title = 'Analysis'
+    attrs = ['build_platform']
+
+    def labelfn(self, task):
+        return task['build_platform']
+
+    def contains(self, task):
+        if not Section.contains(self, task):
+            return False
+        if task.kind == 'build':
+            return task.task['tags'].get('android-stuff', False)
+        return True
+
+
+def create_application(tg):
+    tasks = {l: t for l, t in tg.tasks.items() if t.kind in SUPPORTED_KINDS}
+    sections = [s.get_context(tasks) for s in SECTIONS]
+    context = {
+        'tasks': {l: t.attributes for l, t in tasks.items()},
+        'sections': sections,
+    }
+
+    app = Flask(__name__)
+    app.env = 'development'
+    app.tasks = []
+
+    @app.route('/', methods=['GET', 'POST'])
+    def chooser():
+        if request.method == 'GET':
+            return render_template('chooser.html', **context)
+
+        if request.form['action'] == 'Push':
+            labels = request.form['selected-tasks'].splitlines()
+            app.tasks.extend(labels)
+
+        shutdown = request.environ.get('werkzeug.server.shutdown')
+        shutdown()
+        return render_template('close.html')
+
+    return app
new file mode 100644
--- /dev/null
+++ b/tools/tryselect/selectors/chooser/static/filter.js
@@ -0,0 +1,105 @@
+const selection = $("#selection")[0];
+const count = $("#selection-count")[0];
+const pluralize = (count, noun, suffix = "s") =>
+  `${count} ${noun}${count !== 1 ? suffix : ""}`;
+
+var selected = [];
+
+var updateLabels = () => {
+  $(".tab-pane.active > .filter-label").each(function(index) {
+    let box = $("#" + this.htmlFor)[0];
+    let method = box.checked ? "add" : "remove";
+    $(this)[method + "Class"]("is-checked");
+  });
+};
+
+var apply = () => {
+  let filters = {};
+  let kinds = [];
+
+  $(".filter:checked").each(function(index) {
+    for (let kind of this.name.split(",")) {
+      if (!(kinds.includes(kind)))
+        kinds.push(kind);
+    }
+
+    // Checkbox element values are generated by Section.get_context() in app.py
+    let attrs = JSON.parse(this.value);
+    for (let attr in attrs) {
+      if (!(attr in filters))
+        filters[attr] = [];
+
+      let values = attrs[attr];
+      filters[attr] = filters[attr].concat(values);
+    }
+  });
+  updateLabels();
+
+  if (Object.keys(filters).length == 0 || (Object.keys(filters).length == 1 && "build_type" in filters)) {
+    selection.value = "";
+    count.innerHTML = "0 tasks selected";
+    return;
+  }
+
+  var taskMatches = (label) => {
+    let task = tasks[label];
+
+    // If no box for the given kind has been checked, this task is
+    // automatically not selected.
+    if (!(kinds.includes(task.kind)))
+      return false;
+
+    for (let attr in filters) {
+      let values = filters[attr];
+      if (!(attr in task) || values.includes(task[attr]))
+        continue;
+      return false;
+    }
+    return true;
+  };
+
+  selected = Object.keys(tasks).filter(taskMatches);
+  applyChunks();
+};
+
+var applyChunks = () => {
+  // For tasks that have a chunk filter applied, we handle that here.
+  let filters = {};
+  $(".filter:text").each(function(index) {
+    let value = $(this).val();
+    if (value === "") {
+      return;
+    }
+
+    let attrs = JSON.parse(this.name);
+    let key = `${attrs.unittest_suite}-${attrs.unittest_flavor}`;
+    if (!(key in filters)) {
+      filters[key] = [];
+    }
+
+    // Parse the chunk strings. These are formatted like printer page setups, e.g: "1,4-6,9"
+    for (let item of value.split(",")) {
+      if (!item.includes("-")) {
+        filters[key].push(parseInt(item));
+        continue;
+      }
+
+      let [start, end] = item.split("-");
+      for (let i = parseInt(start); i <= parseInt(end); ++i) {
+        filters[key].push(i);
+      }
+    }
+  });
+
+  let chunked = selected.filter(function(label) {
+    let task = tasks[label];
+    let key = task.unittest_suite + "-" + task.unittest_flavor;
+    if (key in filters && !filters[key].includes(parseInt(task.test_chunk))) {
+      return false;
+    }
+    return true;
+  });
+
+  selection.value = chunked.join("\n");
+  count.innerText = pluralize(chunked.length, "task") + " selected";
+};
new file mode 100644
--- /dev/null
+++ b/tools/tryselect/selectors/chooser/static/select.js
@@ -0,0 +1,38 @@
+const labels = $("label.multiselect");
+const boxes = $("label.multiselect input:checkbox");
+var lastChecked = {};
+
+// implements shift+click
+labels.click(function(e) {
+  if (e.target.tagName === "INPUT")
+    return;
+
+  let box = $("#" + this.htmlFor)[0];
+  let activeSection = $("div.tab-pane.active")[0].id;
+
+  if (activeSection in lastChecked) {
+    // Bug 559506 - In Firefox shift/ctrl/alt+clicking a label doesn't check the box.
+    let isFirefox = navigator.userAgent.toLowerCase().indexOf("firefox") > -1;
+
+    if (e.shiftKey) {
+      if (isFirefox)
+        box.checked = !box.checked;
+
+      let start = boxes.index(box);
+      let end = boxes.index(lastChecked[activeSection]);
+
+      boxes.slice(Math.min(start, end), Math.max(start, end) + 1).prop("checked", box.checked);
+      apply();
+    }
+  }
+
+  lastChecked[activeSection] = box;
+});
+
+function selectAll(btn) {
+  let checked = !!btn.value;
+  $("div.active label.filter-label").each(function(index) {
+    $(this).find("input:checkbox")[0].checked = checked;
+  });
+  apply();
+}
new file mode 100644
--- /dev/null
+++ b/tools/tryselect/selectors/chooser/static/style.css
@@ -0,0 +1,104 @@
+body {
+  padding-top: 70px;
+}
+
+/* Tabs */
+
+#tabbar .nav-link {
+  color: #009570;
+  font-size: 18px;
+  padding-bottom: 15px;
+  padding-top: 15px;
+}
+
+#tabbar .nav-link.active {
+  color: #212529;
+}
+
+#tabbar .nav-link:hover {
+  color: #0f5a3a;
+}
+
+/* Sections */
+
+.tab-content button {
+  font-size: 14px;
+  margin-bottom: 5px;
+  margin-top: 10px;
+}
+
+.filter-label {
+  display: block;
+  font-size: 16px;
+  position: relative;
+  padding-left: 15px;
+  padding-right: 15px;
+  padding-top: 10px;
+  padding-bottom: 10px;
+  margin-bottom: 0;
+  -moz-user-select: none;
+  user-select: none;
+  vertical-align: middle;
+}
+
+.filter-label span {
+  display: flex;
+  min-height: 34px;
+  align-items: center;
+  justify-content: space-between;
+}
+
+.filter-label input[type="checkbox"] {
+  position: absolute;
+  opacity: 0;
+  height: 0;
+  width: 0;
+}
+
+.filter-label input[type="text"] {
+  width: 50px;
+}
+
+.filter-label:hover {
+  background-color: #91a0b0;
+}
+
+.filter-label.is-checked:hover {
+  background-color: #91a0b0;
+}
+
+.filter-label.is-checked {
+  background-color: #404c59;
+  color: white;
+}
+
+/* Preview pane */
+
+#preview {
+  position: fixed;
+  height: 100vh;
+  margin-left: 66%;
+  width: 100%;
+}
+
+#submit-tasks {
+  display: flex;
+  flex-direction: column;
+  height: 80%;
+}
+
+#buttons {
+  display: flex;
+  justify-content: space-between;
+}
+
+#push {
+  background-color: #00e9b7;
+  margin-left: 5px;
+  width: 100%;
+}
+
+#selection {
+  height: 100%;
+  width: 100%;
+}
new file mode 100644
--- /dev/null
+++ b/tools/tryselect/selectors/chooser/templates/chooser.html
@@ -0,0 +1,74 @@
+{% extends 'layout.html' %}
+{% block content %}
+<div class="container-fluid">
+  <div class="row">
+    <div class="col-8">
+      <div class="form-group form-inline">
+        <span class="col-form-label col-md-2 pt-1">Build Type</span>
+        <div class="form-check form-check-inline">
+          <input id="both" class="filter form-check-input" type="radio" name="buildtype" value='{}' onchange="apply();" checked>
+          <label for="both" class="form-check-label">both</label>
+        </div>
+        {% for type in ["opt", "debug"] %}
+        <div class="form-check form-check-inline">
+          <input id="{{ type }}" class="filter form-check-input" type="radio" name="buildtype" value='{"build_type": "{{ type }}"}' onchange="apply();">
+          <label for={{ type }} class="form-check-label">{{ type }}</label>
+        </div>
+        {% endfor %}
+      </div>
+      <ul class="nav nav-tabs" id="tabbar" role="tablist">
+        {% for section in sections %}
+        <li class="nav-item">
+          {% if loop.first %}
+          <a class="nav-link active" id="{{ section.name }}-tab" data-toggle="tab" href="#{{section.name }}" role="tab" aria-controls="{{ section.name }}" aria-selected="true">{{ section.title }}</a>
+          {% else %}
+          <a class="nav-link" id="{{ section.name }}-tab" data-toggle="tab" href="#{{section.name }}" role="tab" aria-controls="{{ section.name }}" aria-selected="false">{{ section.title }}</a>
+          {% endif %}
+        </li>
+        {% endfor %}
+      </ul>
+      <div class="tab-content">
+        <button type="button" class="btn btn-secondary" value="true" onclick="selectAll(this);">Select All</button>
+        <button type="button" class="btn btn-secondary" onclick="selectAll(this);">Deselect All</button>
+        {% for section in sections %}
+        {% if loop.first %}
+        <div class="tab-pane show active" id="{{ section.name }}" role="tabpanel" aria-labelledby="{{ section.name }}-tab">
+        {% else %}
+        <div class="tab-pane" id="{{ section.name }}" role="tabpanel" aria-labelledby="{{ section.name }}-tab">
+        {% endif %}
+          {% for label, meta in section.labels|dictsort %}
+          <label class="multiselect filter-label" for={{ label }}>
+            <span>
+              {{ label }}
+              <input class="filter" type="checkbox" id={{ label }} name="{{ section.kind }}" value='{{ meta.attrs|tojson|safe }}' onchange="console.log('checkbox onchange triggered');apply();">
+              {% if meta.max_chunk > 1 %}
+              <input class="filter" type="text" pattern="^[0-9][0-9,-]*$" placeholder="1-{{ meta.max_chunk }}" name='{{ meta.attrs|tojson|safe }}' oninput="applyChunks();">
+              {% endif %}
+            </span>
+          </label>
+          {% endfor %}
+        </div>
+        {% endfor %}
+      </div>
+    </div>
+    <div class="col-4" id="preview">
+      <form id="submit-tasks" action="" method="POST">
+        <textarea id="selection" name="selected-tasks" wrap="off"></textarea>
+        <span id="selection-count">0 tasks selected</span><br>
+        <span id="buttons">
+          <input id="cancel" class="btn btn-default" type="submit" name="action" value="Cancel">
+          <input id="push" class="btn btn-default" type="submit" name="action" value="Push">
+        </span>
+      </form>
+    </div>
+  </div>
+</div>
+{% endblock %}
+
+{% block scripts %}
+<script>
+  const tasks = {{ tasks|tojson|safe }};
+</script>
+<script src="{{ url_for('static', filename='filter.js') }}"></script>
+<script src="{{ url_for('static', filename='select.js') }}"></script>
+{% endblock %}
new file mode 100644
--- /dev/null
+++ b/tools/tryselect/selectors/chooser/templates/close.html
@@ -0,0 +1,8 @@
+{% extends 'layout.html' %}
+{% block content %}
+<div class="container-fluid">
+  <div class="alert alert-primary" role="alert">
+    You may now close this page.
+  </div>
+</div>
+{% endblock %}
new file mode 100644
--- /dev/null
+++ b/tools/tryselect/selectors/chooser/templates/layout.html
@@ -0,0 +1,33 @@
+<html>
+    <head>
+        <meta charset="utf-8">
+        <title>Try Chooser Enhanced</title>
+        <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.1.3/css/bootstrap.min.css">
+        <link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
+    </head>
+    <body>
+        <nav class="navbar navbar-default fixed-top navbar-dark bg-dark">
+          <div class="container-fluid">
+            <span class="navbar-brand mb-0 h1">Try Chooser Enhanced</span>
+            <button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
+              <span class="navbar-toggler-icon"></span>
+            </button>
+            <div class="collapse navbar-collapse" id="navbarSupportedContent">
+              <ul class="navbar-nav mr-auto">
+                <li class="nav-item">
+                  <a class="nav-link" href="https://firefox-source-docs.mozilla.org/tools/try/index.html">Documentation</a>
+                </li>
+                <li class="nav-item">
+                  <a class="nav-link" href="https://treeherder.mozilla.org/#/jobs?repo=try">Treeherder</a>
+                </li>
+              </ul>
+            </div>
+          </div>
+        </nav>
+        {% block content %}{% endblock %}
+        <script src="https://code.jquery.com/jquery-3.3.1.slim.min.js" integrity="sha384-q8i/X+965DzO0rT7abK41JStQIAqVgRVzpbzo5smXKp4YfRvH+8abtTE1Pi6jizo" crossorigin="anonymous"></script>
+        <script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.14.3/umd/popper.min.js" integrity="sha384-ZMP7rVo3mIykV+2+9J3UJ46jBk0WLaUAdn689aCwoqbBJiSnjAK/l8WvCWPIPm49" crossorigin="anonymous"></script>
+        <script src="https://stackpath.bootstrapcdn.com/bootstrap/4.1.3/js/bootstrap.min.js" integrity="sha384-ChfqqxuZUCnJSK3+MXmPNIyE6ZbWh2IMqE241rYiqJxyMiZ6OW/JmZQ5stwEULTy" crossorigin="anonymous"></script>
+        {% block scripts %}{% endblock %}
+    </body>
+</html>