about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--README.org2
-rw-r--r--uploader/background_jobs.py114
-rw-r--r--uploader/errors.py3
-rw-r--r--uploader/flask_extensions.py40
-rw-r--r--uploader/phenotypes/views.py9
-rw-r--r--uploader/session.py14
-rw-r--r--uploader/static/css/layout-large.css6
-rw-r--r--uploader/static/css/theme.css17
-rw-r--r--uploader/templates/background-jobs/base.html10
-rw-r--r--uploader/templates/background-jobs/delete-job.html61
-rw-r--r--uploader/templates/background-jobs/job-status.html41
-rw-r--r--uploader/templates/background-jobs/job-summary.html69
-rw-r--r--uploader/templates/background-jobs/list-jobs.html79
-rw-r--r--uploader/templates/background-jobs/macro-display-job-details.html29
-rw-r--r--uploader/templates/background-jobs/stop-job.html61
-rw-r--r--uploader/templates/base.html28
-rw-r--r--uploader/ui.py2
17 files changed, 553 insertions, 32 deletions
diff --git a/README.org b/README.org
index ca77653..efa837b 100644
--- a/README.org
+++ b/README.org
@@ -219,7 +219,7 @@ To check for correct type usage in the application, run:
 Run unit tests with:
 #+BEGIN_SRC shell
   $ export UPLOADER_CONF=</path/to/configuration/file.py>
-  $ pytest -m unit_test
+  $ pytest -m unit_test -n auto
 #+END_SRC
 
 To run ALL tests (not just unit tests):
diff --git a/uploader/background_jobs.py b/uploader/background_jobs.py
index 2c55272..61e57f7 100644
--- a/uploader/background_jobs.py
+++ b/uploader/background_jobs.py
@@ -1,29 +1,32 @@
 """Generic views and utilities to handle background jobs."""
 import uuid
+import datetime
 import importlib
 from typing import Callable
 from functools import partial
 
 from werkzeug.wrappers.response import Response
 from flask import (
+    flash,
+    request,
     redirect,
     Blueprint,
-    render_template,
     current_app as app)
 
 from gn_libs import jobs
 from gn_libs import sqlite3
 from gn_libs.jobs.jobs import JobNotFound
 
-from uploader.flask_extensions import url_for
+from uploader import session
 from uploader.authorisation import require_login
+from uploader.flask_extensions import url_for, render_template
 
 background_jobs_bp = Blueprint("background-jobs", __name__)
 HandlerType = Callable[[dict], Response]
 
 
 def __default_error_handler__(job: dict) -> Response:
-    return redirect(url_for("background-jobs.job_error", job_id=job["job_id"]))
+    return redirect(url_for("background-jobs.job_summary", job_id=job["job_id"]))
 
 def register_handlers(
         job_type: str,
@@ -98,13 +101,15 @@ def job_status(job_id: uuid.UUID):
             status = job["metadata"]["status"]
 
             register_job_handlers(job)
-            if status == "error":
+            if status in ("error", "stopped"):
                 return error_handler(job)
 
             if status == "completed":
                 return success_handler(job)
 
-            return render_template("jobs/job-status.html", job=job)
+            return render_template("background-jobs/job-status.html",
+                                   job=job,
+                                   display_datetime=make_datetime_formatter())
         except JobNotFound as _jnf:
             return render_template("jobs/job-not-found.html", job_id=job_id)
 
@@ -119,3 +124,102 @@ def job_error(job_id: uuid.UUID):
             return render_template("jobs/job-error.html", job=job)
         except JobNotFound as _jnf:
             return render_template("jobs/job-not-found.html", job_id=job_id)
+
+
+def make_datetime_formatter(dtformat: str = "") -> Callable[[str], str]:
+    """Make a datetime formatter with the provided `dtformat`"""
+    def __formatter__(val: str) -> str:
+        dt = datetime.datetime.fromisoformat(val)
+        return dt.strftime(dtformat.strip() or "%A, %d %B %Y at %H:%M %Z")
+
+    return __formatter__
+
+
+@background_jobs_bp.route("/list")
+@require_login
+def list_jobs():
+    """List background jobs."""
+    with sqlite3.connection(app.config["ASYNCHRONOUS_JOBS_SQLITE_DB"]) as conn:
+        return render_template(
+            "background-jobs/list-jobs.html",
+            jobs=jobs.jobs_by_external_id(
+                conn, session.user_details()["user_id"]),
+            display_datetime=make_datetime_formatter())
+
+
+@background_jobs_bp.route("/summary/<uuid:job_id>")
+@require_login
+def job_summary(job_id: uuid.UUID):
+    """Provide a summary for completed jobs."""
+    with sqlite3.connection(app.config["ASYNCHRONOUS_JOBS_SQLITE_DB"]) as conn:
+        try:
+            job = jobs.job(conn, job_id, fulldetails=True)
+            status = job["metadata"]["status"]
+
+            if status in ("completed", "error", "stopped"):
+                return render_template("background-jobs/job-summary.html",
+                                       job=job,
+                                       display_datetime=make_datetime_formatter())
+            return redirect(url_for(
+                "background-jobs.job_status", job_id=job["job_id"]))
+        except JobNotFound as _jnf:
+            return render_template("jobs/job-not-found.html", job_id=job_id)
+
+
+@background_jobs_bp.route("/delete/<uuid:job_id>", methods=["GET", "POST"])
+@require_login
+def delete_single(job_id: uuid.UUID):
+    """Delete a single job."""
+    with sqlite3.connection(app.config["ASYNCHRONOUS_JOBS_SQLITE_DB"]) as conn:
+        try:
+            job = jobs.job(conn, job_id, fulldetails=True)
+            status = job["metadata"]["status"]
+            if status not in ("completed", "error", "stopped"):
+                flash("We cannot delete a running job.", "alert alert-danger")
+                return redirect(url_for(
+                    "background-jobs.job_summary", job_id=job_id))
+
+            if request.method == "GET":
+                return render_template("background-jobs/delete-job.html",
+                                       job=job,
+                                       display_datetime=make_datetime_formatter())
+
+            if request.form["btn-confirm-delete"] == "delete":
+                jobs.delete_job(conn, job_id)
+                flash("Job was deleted successfully.", "alert alert-success")
+                return redirect(url_for("background-jobs.list_jobs"))
+            flash("Delete cancelled.", "alert alert-info")
+            return redirect(url_for(
+                "background-jobs.job_summary", job_id=job_id))
+        except JobNotFound as _jnf:
+            return render_template("jobs/job-not-found.html", job_id=job_id)
+
+
+@background_jobs_bp.route("/stop/<uuid:job_id>", methods=["GET", "POST"])
+@require_login
+def stop_job(job_id: uuid.UUID):
+    """Stop a running job."""
+    with sqlite3.connection(app.config["ASYNCHRONOUS_JOBS_SQLITE_DB"]) as conn:
+        try:
+            job = jobs.job(conn, job_id, fulldetails=True)
+            status = job["metadata"]["status"]
+            if status != "running":
+                flash("Cannot stop a job that is not running.", "alert alert-danger")
+                return redirect(url_for(
+                    "background-jobs.job_summary", job_id=job_id))
+
+            if request.method == "GET":
+                return render_template("background-jobs/stop-job.html",
+                                       job=job,
+                                       display_datetime=make_datetime_formatter())
+
+            if request.form["btn-confirm-stop"] == "stop":
+                jobs.kill_job(conn, job_id)
+                flash("Job was stopped successfully.", "alert alert-success")
+                return redirect(url_for(
+                    "background-jobs.job_summary", job_id=job_id))
+            flash("Stop cancelled.", "alert alert-info")
+            return redirect(url_for(
+                "background-jobs.job_summary", job_id=job_id))
+        except JobNotFound as _jnf:
+            return render_template("jobs/job-not-found.html", job_id=job_id)
diff --git a/uploader/errors.py b/uploader/errors.py
index 3e7c893..2ac48b8 100644
--- a/uploader/errors.py
+++ b/uploader/errors.py
@@ -3,7 +3,8 @@ import traceback
 from werkzeug.exceptions import HTTPException
 
 import MySQLdb as mdb
-from flask import Flask, request, render_template, current_app as app
+from flask import Flask, request, current_app as app
+from uploader.flask_extensions import render_template
 
 def handle_general_exception(exc: Exception):
     """Handle generic exceptions."""
diff --git a/uploader/flask_extensions.py b/uploader/flask_extensions.py
index 30fbad7..83d25aa 100644
--- a/uploader/flask_extensions.py
+++ b/uploader/flask_extensions.py
@@ -2,19 +2,16 @@
 import logging
 from typing import Any, Optional
 
-from flask import (request, current_app as app, url_for as flask_url_for)
+from flask import (
+    request,
+    current_app as app,
+    url_for as flask_url_for,
+    render_template as flask_render_template)
 
 logger = logging.getLogger(__name__)
 
 
-def url_for(
-        endpoint: str,
-        _anchor: Optional[str] = None,
-        _method: Optional[str] = None,
-        _scheme: Optional[str] = None,
-        _external: Optional[bool] = None,
-        **values: Any) -> str:
-    """Extension to flask's `url_for` function."""
+def __fetch_flags__():
     flags = {}
     for flag in app.config["FEATURE_FLAGS_HTTP"]:
         flag_value = (request.args.get(flag) or request.form.get(flag) or "").strip()
@@ -22,12 +19,33 @@ def url_for(
             flags[flag] = flag_value
             continue
         continue
+    logger.debug("HTTP FEATURE FLAGS: %s", flags)
+    return flags
 
-    logger.debug("HTTP FEATURE FLAGS: %s, other variables: %s", flags, values)
+
+def url_for(
+        endpoint: str,
+        _anchor: Optional[str] = None,
+        _method: Optional[str] = None,
+        _scheme: Optional[str] = None,
+        _external: Optional[bool] = None,
+        **values: Any) -> str:
+    """Extension to flask's `url_for` function."""
+    logger.debug("other variables: %s", values)
     return flask_url_for(endpoint=endpoint,
                          _anchor=_anchor,
                          _method=_method,
                          _scheme=_scheme,
                          _external=_external,
                          **values,
-                         **flags)
+                         **__fetch_flags__())
+
+
+def render_template(template_name_or_list, **context: Any) -> str:
+    """Extend flask's `render_template` function"""
+    return flask_render_template(
+        template_name_or_list,
+        **{
+            **context,
+            **__fetch_flags__() # override any flag values
+        })
diff --git a/uploader/phenotypes/views.py b/uploader/phenotypes/views.py
index c4c6170..60d5775 100644
--- a/uploader/phenotypes/views.py
+++ b/uploader/phenotypes/views.py
@@ -679,7 +679,8 @@ def load_data_to_database(
                     "success_handler": (
                         "uploader.phenotypes.views"
                         ".load_phenotypes_success_handler")
-                })
+                },
+                external_id=session.logged_in_user_id())
         ).then(
             lambda job: gnlibs_jobs.launch_job(
                 job,
@@ -1059,7 +1060,8 @@ def recompute_means(# pylint: disable=[unused-argument]
                     "success_handler": (
                         "uploader.phenotypes.views."
                         "recompute_phenotype_means_success_handler")
-            }),
+                },
+                external_id=session.logged_in_user_id()),
             _jobs_db,
             Path(f"{app.config['UPLOAD_FOLDER']}/job_errors"),
             worker_manager="gn_libs.jobs.launcher",
@@ -1138,7 +1140,8 @@ def rerun_qtlreaper(# pylint: disable=[unused-argument]
                     "success_handler": (
                         "uploader.phenotypes.views."
                         "rerun_qtlreaper_success_handler")
-            }),
+            },
+            external_id=session.logged_in_user_id()),
             _jobs_db,
             Path(f"{app.config['UPLOAD_FOLDER']}/job_errors"),
             worker_manager="gn_libs.jobs.launcher",
diff --git a/uploader/session.py b/uploader/session.py
index 5af5827..9cb305b 100644
--- a/uploader/session.py
+++ b/uploader/session.py
@@ -1,4 +1,5 @@
 """Deal with user sessions"""
+import logging
 from uuid import UUID, uuid4
 from datetime import datetime
 from typing import Any, Optional, TypedDict
@@ -7,6 +8,8 @@ from authlib.jose import KeySet
 from flask import request, session
 from pymonad.either import Left, Right, Either
 
+logger = logging.getLogger(__name__)
+
 
 class UserDetails(TypedDict):
     """Session information relating specifically to the user."""
@@ -91,6 +94,17 @@ def user_details() -> UserDetails:
     """Retrieve user details."""
     return session_info()["user"]
 
+
+def logged_in_user_id() -> Optional[UUID]:
+    """Get user id for logged in user. If user has not logged in, return None."""
+    return user_token().then(
+        lambda _tok: user_details()
+    ).then(
+        lambda _user: Either(_user["user_id"],
+                             (None, _user["email"] != "anon@ymous.user"))
+    ).either(lambda _err: None, lambda uid: uid)
+
+
 def user_token() -> Either:
     """Retrieve the user token."""
     return session_info()["user"]["token"]
diff --git a/uploader/static/css/layout-large.css b/uploader/static/css/layout-large.css
index 8abd2dd..c40a130 100644
--- a/uploader/static/css/layout-large.css
+++ b/uploader/static/css/layout-large.css
@@ -6,13 +6,15 @@
     }
 
     #header {
+        margin: -0.7em; /* Fill entire length of screen */
+
         /* Place it in the parent element */
         grid-column-start: 1;
         grid-column-end: 3;
 
         /* Define layout for the children elements */
         display: grid;
-        grid-template-columns: 8fr 2fr;
+        grid-template-columns: 1fr 9fr;
     }
 
     #header #header-text {
@@ -45,6 +47,8 @@
         grid-column-start: 1;
         grid-column-end: 3;
         padding: 0 3px;
+
+        margin: -0.3em -0.7em 0 -0.7em;
     }
 
     #main #main-content {
diff --git a/uploader/static/css/theme.css b/uploader/static/css/theme.css
index cd3d103..45e5d3d 100644
--- a/uploader/static/css/theme.css
+++ b/uploader/static/css/theme.css
@@ -8,24 +8,27 @@ body {
 #header {
     background-color: #336699;
     color: #FFFFFF;
-    border-radius: 3px;
     min-height: 30px;
+    border-bottom: solid black 1px;
 }
 
 #header #header-nav .nav li a {
     /* Content styling */
     color: #FFFFFF;
-    background: #4477AA;
-    border: solid 5px #336699;
-    border-radius: 5px;
     font-size: 0.7em;
     text-align: center;
     padding: 1px 7px;
+    text-decoration: none;
 }
 
 #main #breadcrumbs {
-    border-radius:3px;
     text-align: center;
+    background-color: #D5D5D5;
+    padding: 0 1em 0 1em;
+}
+
+#main #breadcrumbs .breadcrumb {
+    padding-top: 0.5em;
 }
 
 #main #main-content {
@@ -88,3 +91,7 @@ table.dataTable tbody tr.selected td {
 .breadcrumb-item {
     text-transform: Capitalize;
 }
+
+.breadcrumb-item a {
+    text-decoration: none;
+}
diff --git a/uploader/templates/background-jobs/base.html b/uploader/templates/background-jobs/base.html
new file mode 100644
index 0000000..7201207
--- /dev/null
+++ b/uploader/templates/background-jobs/base.html
@@ -0,0 +1,10 @@
+{%extends "base.html"%}
+
+{%block breadcrumbs%}
+{{super()}}
+<li class="breadcrumb-item">
+  <a href="{{url_for('background-jobs.list_jobs')}}">
+    background jobs
+  </a>
+</li>
+{%endblock%}
diff --git a/uploader/templates/background-jobs/delete-job.html b/uploader/templates/background-jobs/delete-job.html
new file mode 100644
index 0000000..242c775
--- /dev/null
+++ b/uploader/templates/background-jobs/delete-job.html
@@ -0,0 +1,61 @@
+{%extends "background-jobs/base.html"%}
+{%from "flash_messages.html" import flash_all_messages%}
+{%from "background-jobs/macro-display-job-details.html" import display_job_details%}
+
+{%block title%}Background Jobs{%endblock%}
+
+{%block pagetitle%}Background Jobs{%endblock%}
+
+{%block breadcrumbs%}
+{{super()}}
+<li class="breadcrumb-item">
+  <a href="{{url_for('background-jobs.job_summary', job_id=job.job_id)}}">
+    summary
+  </a>
+</li>
+{%endblock%}
+
+{%block contents%}
+{{flash_all_messages()}}
+
+<div class="row">
+  <h2 class="heading">background jobs: delete?</h2>
+
+  <p class="text-danger">Are you sure you want to delete the job below?</p>
+
+  {{display_job_details(job, display_datetime)}}
+</div>
+
+<div class="row">
+  <form id="frm-delete-job"
+        method="POST"
+        action="{{url_for('background-jobs.delete_single', job_id=job.job_id)}}">
+    <div class="row">
+      <div class="col">
+        <input type="submit"
+               class="btn btn-info"
+               value="cancel"
+               name="btn-confirm-delete" />
+      </div>
+      <div class="col">
+        <input type="submit"
+               class="btn btn-danger"
+               value="delete"
+               name="btn-confirm-delete" />
+      </div>
+    </div>
+  </form>
+</div>
+{%endblock%}
+
+
+{%block sidebarcontents%}
+<div class="row">
+  <h6 class="subheading">What is this?</h6>
+</div>
+<div class="row">
+  <p>Confirm whether or not you want to delete job
+    <strong>{{job.job_id}}</strong>.</p>
+</div>
+{{super()}}
+{%endblock%}
diff --git a/uploader/templates/background-jobs/job-status.html b/uploader/templates/background-jobs/job-status.html
new file mode 100644
index 0000000..50cf6e5
--- /dev/null
+++ b/uploader/templates/background-jobs/job-status.html
@@ -0,0 +1,41 @@
+{%extends "background-jobs/base.html"%}
+{%from "background-jobs/macro-display-job-details.html" import display_job_details%}
+
+{%from "flash_messages.html" import flash_all_messages%}
+
+{%block extrameta%}
+<meta http-equiv="refresh" content="5" />
+{%endblock%}
+
+{%block title%}Background Jobs{%endblock%}
+
+{%block pagetitle%}Background Jobs{%endblock%}
+
+{%block contents%}
+{{flash_all_messages()}}
+
+<div class="row">
+  <h2 class="heading">job status</h2>
+
+  {{display_job_details(job, display_datetime)}}
+</div>
+
+<div class="row">
+  <div class="col">
+    <a href="{{url_for('background-jobs.stop_job', job_id=job.job_id)}}"
+       title="Stop/Kill this job."
+       class="btn btn-danger">stop job</a>
+  </div>
+</div>
+
+<div class="row">
+  <h3 class="subheading">STDOUT</h3>
+  <pre>{{job["stdout"]}}</pre>
+</div>
+
+<div class="row">
+  <h3 class="subheading">STDERR</h3>
+  <pre>{{job["stderr"]}}</pre>
+</div>
+
+{%endblock%}
diff --git a/uploader/templates/background-jobs/job-summary.html b/uploader/templates/background-jobs/job-summary.html
new file mode 100644
index 0000000..fe62d5d
--- /dev/null
+++ b/uploader/templates/background-jobs/job-summary.html
@@ -0,0 +1,69 @@
+{%extends "background-jobs/base.html"%}
+{%from "flash_messages.html" import flash_all_messages%}
+{%from "background-jobs/macro-display-job-details.html" import display_job_details%}
+
+{%block title%}Background Jobs{%endblock%}
+
+{%block pagetitle%}Background Jobs{%endblock%}
+
+{%block breadcrumbs%}
+{{super()}}
+<li class="breadcrumb-item">
+  <a href="{{url_for('background-jobs.job_summary', job_id=job.job_id)}}">
+    summary
+  </a>
+</li>
+{%endblock%}
+
+{%block contents%}
+{{flash_all_messages()}}
+
+<div class="row">
+  <h2 class="heading">background jobs: summary</h2>
+
+  {{display_job_details(job, display_datetime)}}
+</div>
+
+<div class="row">
+  <div class="col">
+    <a href="#"
+       class="btn btn-info not-implemented"
+       title="Update the expiry date and time for this job.">update expiry</a>
+  </div>
+
+  <div class="col">
+    <a href="{{url_for('background-jobs.delete_single', job_id=job.job_id)}}"
+       class="btn btn-danger"
+       title="Delete this job.">delete</a>
+  </div>
+
+  {%if job.metadata.status in ("stopped",)%}
+  <div class="col">
+    <a href="#"
+       class="btn btn-warning not-implemented"
+       title="Create a new copy of this job, and run the copy.">Run Copy</a>
+  </div>
+  {%endif%}
+</div>
+
+<div class="row">
+  <h3 class="subheading">Script Errors and Logging</h3>
+  <pre>{{job["stderr"]}}</pre>
+</div>
+
+<div class="row">
+  <h3 class="subheading">Script Output</h3>
+  <pre>{{job["stdout"]}}</pre>
+</div>
+{%endblock%}
+
+
+{%block sidebarcontents%}
+<div class="row">
+  <h6 class="subheading">What is this?</h6>
+</div>
+<div class="row">
+  <p>This page shows the results of running job '{{job.job_id}}'.</p>
+</div>
+{{super()}}
+{%endblock%}
diff --git a/uploader/templates/background-jobs/list-jobs.html b/uploader/templates/background-jobs/list-jobs.html
new file mode 100644
index 0000000..c16b850
--- /dev/null
+++ b/uploader/templates/background-jobs/list-jobs.html
@@ -0,0 +1,79 @@
+{%extends "background-jobs/base.html"%}
+{%from "flash_messages.html" import flash_all_messages%}
+
+{%block title%}Background Jobs{%endblock%}
+
+{%block pagetitle%}Background Jobs{%endblock%}
+
+{%block contents%}
+{{flash_all_messages()}}
+
+<div class="row"><h2 class="heading">Background Jobs</h2></div>
+
+<div class="row">
+  <div class="table-responsive">
+    <table class="table">
+      <thead>
+        <tr class="table-primary">
+          <th>Type</th>
+          <th>Created</th>
+          <th title="Date and time past which the job's details will be deleted from the system.">
+            Expires</th>
+          <th>Status</th>
+          <th>Actions</th>
+        </tr>
+      </thead>
+
+      <tbody>
+        {%for job in jobs%}
+        <tr>
+          <td>{{job.metadata["job-type"]}}</td>
+          <td>{{display_datetime(job.created)}}</td>
+          <td title="Date and time past which the job's details will be deleted from the system.">
+            {{display_datetime(job.expires)}}
+          </td>
+          <td {%if job.metadata.status == "completed"%}
+              class="fw-bold text-capitalize text-success"
+              {%elif job.metadata.status == "error"%}
+              class="fw-bold text-capitalize text-danger"
+              {%elif job.metadata.status == "stopped"%}
+              class="fw-bold text-capitalize text-warning"
+              {%else%}
+              class="fw-bold text-capitalize text-info"
+              {%endif%}>
+            <div>
+              {{job.metadata.status}}
+            </div>
+          </td>
+          <td>
+            <a href="{{url_for('background-jobs.job_summary', job_id=job.job_id)}}"
+               class="btn btn-info"
+               title="View more detailed information about this job.">
+              view summary</a>
+          </td>
+        </tr>
+        {%else%}
+        <tr>
+          <td colspan="5">
+            You do not have any jobs you have run in the background.</td>
+        </tr>
+        {%endfor%}
+      </tbody>
+    </table>
+  </div>
+</div>
+{%endblock%}
+
+
+{%block sidebarcontents%}
+<div class="row">
+  <h6 class="subheading">What is this?</h6>
+</div>
+<div class="row">
+  <p>The table lists the jobs that are running in the background, that you
+    started.</p>
+  <p>You can use the tools provided on this page to manage the jobs, and to view
+    each job's details.</p>
+</div>
+{{super()}}
+{%endblock%}
diff --git a/uploader/templates/background-jobs/macro-display-job-details.html b/uploader/templates/background-jobs/macro-display-job-details.html
new file mode 100644
index 0000000..82e33c0
--- /dev/null
+++ b/uploader/templates/background-jobs/macro-display-job-details.html
@@ -0,0 +1,29 @@
+{%macro display_job_details(job, display_datetime)%}
+<table class="table">
+  <thead>
+  </thead>
+
+  <tbody>
+    <tr>
+      <th class="table-primary">Job ID</th>
+      <td>{{job.job_id}}</td>
+    </tr>
+    <tr>
+      <th class="table-primary">Type</th>
+      <td>{{job.metadata["job-type"]}}</td>
+    </tr>
+    <tr>
+      <th class="table-primary">Created</th>
+      <td>{{display_datetime(job.created)}}</td>
+    </tr>
+    <tr>
+      <th class="table-primary">Expires</th>
+      <td>{{display_datetime(job.expires)}}</td>
+    </tr>
+    <tr>
+      <th class="table-primary">Status</th>
+      <td>{{job.metadata.status}}</td>
+    </tr>
+  </tbody>
+</table>
+{%endmacro%}
diff --git a/uploader/templates/background-jobs/stop-job.html b/uploader/templates/background-jobs/stop-job.html
new file mode 100644
index 0000000..fc190ac
--- /dev/null
+++ b/uploader/templates/background-jobs/stop-job.html
@@ -0,0 +1,61 @@
+{%extends "background-jobs/base.html"%}
+{%from "flash_messages.html" import flash_all_messages%}
+{%from "background-jobs/macro-display-job-details.html" import display_job_details%}
+
+{%block title%}Background Jobs{%endblock%}
+
+{%block pagetitle%}Background Jobs{%endblock%}
+
+{%block breadcrumbs%}
+{{super()}}
+<li class="breadcrumb-item">
+  <a href="{{url_for('background-jobs.job_summary', job_id=job.job_id)}}">
+    summary
+  </a>
+</li>
+{%endblock%}
+
+{%block contents%}
+{{flash_all_messages()}}
+
+<div class="row">
+  <h2 class="heading">background jobs: stop?</h2>
+
+  <p class="text-danger">Are you sure you want to stop the job below?</p>
+
+  {{display_job_details(job, display_datetime)}}
+</div>
+
+<div class="row">
+  <form id="frm-stop-job"
+        method="POST"
+        action="{{url_for('background-jobs.stop_job', job_id=job.job_id)}}">
+    <div class="row">
+      <div class="col">
+        <input type="submit"
+               class="btn btn-info"
+               value="cancel"
+               name="btn-confirm-stop" />
+      </div>
+      <div class="col">
+        <input type="submit"
+               class="btn btn-danger"
+               value="stop"
+               name="btn-confirm-stop" />
+      </div>
+    </div>
+  </form>
+</div>
+{%endblock%}
+
+
+{%block sidebarcontents%}
+<div class="row">
+  <h6 class="subheading">What is this?</h6>
+</div>
+<div class="row">
+  <p>Confirm whether or not you want to stop job
+    <strong>{{job.job_id}}</strong>.</p>
+</div>
+{{super()}}
+{%endblock%}
diff --git a/uploader/templates/base.html b/uploader/templates/base.html
index 719a646..dd097de 100644
--- a/uploader/templates/base.html
+++ b/uploader/templates/base.html
@@ -30,14 +30,34 @@
     <header id="header">
       <span id="header-text">GeneNetwork</span>
       <nav id="header-nav">
-        <ul class="nav justify-content-end">
+        <ul class="nav">
+          {%if user_logged_in()%}
+          {%if view_under_construction%}
+          <li>
+            <a href="{{url_for('background-jobs.list_jobs')}}"
+               title="User's background jobs.">
+              <!-- https://icons.getbootstrap.com/icons/back/ -->
+              <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-back" viewBox="0 0 16 16">
+                <path d="M0 2a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v2h2a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2v-2H2a2 2 0 0 1-2-2zm2-1a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1V2a1 1 0 0 0-1-1z"/>
+              </svg>
+              Background jobs
+            </a>
+          </li>
+          {%endif%}
+
           <li>
-            {%if user_logged_in()%}
             <a href="{{url_for('oauth2.logout')}}"
                title="Log out of the system">
+              <!-- https://icons.getbootstrap.com/icons/file-person/ -->
+              <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-file-person" viewBox="0 0 16 16">
+                <path d="M12 1a1 1 0 0 1 1 1v10.755S12 11 8 11s-5 1.755-5 1.755V2a1 1 0 0 1 1-1zM4 0a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2z"/>
+                <path d="M8 10a3 3 0 1 0 0-6 3 3 0 0 0 0 6"/>
+              </svg>
               <span class="glyphicon glyphicon-user"></span>
-              {{user_email()}} Sign Out</a>
-            {%else%}
+              Sign Out ({{user_email()}})</a>
+          </li>
+          {%else%}
+          <li>
             <a href="{{authserver_authorise_uri()}}"
                title="Log in to the system">Sign In</a>
             {%endif%}
diff --git a/uploader/ui.py b/uploader/ui.py
index 1994056..41791c7 100644
--- a/uploader/ui.py
+++ b/uploader/ui.py
@@ -1,5 +1,5 @@
 """Utilities to handle the UI"""
-from flask import render_template as flask_render_template
+from uploader.flask_extensions import render_template as flask_render_template
 
 def make_template_renderer(default):
     """Render template for species."""