diff --git a/projects/tentacles/src/python/tentacles/__main__.py b/projects/tentacles/src/python/tentacles/__main__.py
index 4001c7c..80d66d7 100644
--- a/projects/tentacles/src/python/tentacles/__main__.py
+++ b/projects/tentacles/src/python/tentacles/__main__.py
@@ -2,15 +2,21 @@
 
 """The core app entrypoint."""
 
+from datetime import datetime
 from pathlib import Path
+import tomllib
+
 import click
 from flask import Flask, request
-import tomllib
-from datetime import datetime
-
-from tentacles.blueprints import user_ui, printer_ui, job_ui, file_ui, api
-from tentacles.store import Store
+from tentacles.blueprints import (
+    api,
+    file_ui,
+    job_ui,
+    printer_ui,
+    user_ui,
+)
 from tentacles.globals import _ctx, Ctx, ctx
+from tentacles.store import Store
 from tentacles.workers import create_workers
 
 
diff --git a/projects/tentacles/src/python/tentacles/blueprints/api.py b/projects/tentacles/src/python/tentacles/blueprints/api.py
index 680ede1..53f3007 100644
--- a/projects/tentacles/src/python/tentacles/blueprints/api.py
+++ b/projects/tentacles/src/python/tentacles/blueprints/api.py
@@ -2,14 +2,17 @@
 
 """API endpoints supporting the 'ui'."""
 
+from hashlib import sha3_256
 import os
 from typing import Optional
 
+from flask import Blueprint, current_app, request
+from tentacles.blueprints.util import (
+    requires_admin,
+    requires_auth,
+)
 from tentacles.globals import ctx
-from tentacles.blueprints.util import requires_admin, requires_auth
 
-from flask import Blueprint, request, current_app
-from hashlib import sha3_256
 
 BLUEPRINT = Blueprint("api", __name__, url_prefix="/api")
 
@@ -51,11 +54,6 @@ def delete_printer():
 def create_file(location: Optional[str] = None):
     # This is the important one, because it's the one that PrusaSlicer et all use to upload jobs.
 
-    print(request)
-    print(request.headers)
-    print(request.files)
-    print(request.form)
-
     if "file" not in request.files:
         return {"status": "error", "error": "No file to upload"}, 400
 
diff --git a/projects/tentacles/src/python/tentacles/blueprints/file_ui.py b/projects/tentacles/src/python/tentacles/blueprints/file_ui.py
index c189715..37352dd 100644
--- a/projects/tentacles/src/python/tentacles/blueprints/file_ui.py
+++ b/projects/tentacles/src/python/tentacles/blueprints/file_ui.py
@@ -1,31 +1,58 @@
 #!/usr/bin/env python3
 
 import logging
-from datetime import timedelta
-import re
 
 from tentacles.globals import ctx
 from .util import requires_auth
+from .api import create_file
 
 from flask import (
     Blueprint,
-    current_app,
+    flash,
+    render_template,
     request,
     redirect,
-    render_template,
-    flash,
 )
-
-from .util import salt, is_logged_in
+import os
 
 log = logging.getLogger(__name__)
 BLUEPRINT = Blueprint("files", __name__)
 
 
 @requires_auth
-@BLUEPRINT.route("/files", methods=["GET", "POST"])
-def files():
+@BLUEPRINT.route("/files", methods=["GET"])
+def list_files():
     if request.method == "POST":
         flash("Not supported yet", category="warning")
 
     return render_template("files.html.j2")
+
+
+@requires_auth
+@BLUEPRINT.route("/files", methods=["POST"])
+def manipulate_files():
+    match request.form.get("action"):
+        case "upload":
+            resp, code = create_file()
+            if 200 <= code <= 300:
+                flash("File created", category="info")
+            else:
+                flash(resp.get("error"), category="error")
+                return render_template("files.html.j2"), code
+
+        case "delete":
+            file = ctx.db.fetch_file(ctx.uid, int(request.form.get("file_id")))
+            if any(job.finished_at is None for job in ctx.db.list_jobs_by_file(file.id)):
+                flash("File is in use", category="error")
+                return render_template("files.html.j2"), 400
+
+            os.unlink(file.path)
+            ctx.db.delete_file(ctx.uid, file.id)
+            flash("File deleted", category="info")
+
+        case _:
+            print(request.form)
+            flash("Not supported yet", category="warning")
+            return render_template("files.html.j2"), 400
+
+    return redirect("/files")
diff --git a/projects/tentacles/src/python/tentacles/blueprints/job_ui.py b/projects/tentacles/src/python/tentacles/blueprints/job_ui.py
index 0c875fe..512515c 100644
--- a/projects/tentacles/src/python/tentacles/blueprints/job_ui.py
+++ b/projects/tentacles/src/python/tentacles/blueprints/job_ui.py
@@ -1,32 +1,55 @@
 #!/usr/bin/env python3
 
 import logging
-from datetime import timedelta
-import re
 
-from tentacles.globals import ctx
 from .util import requires_auth
 
 from flask import (
     Blueprint,
-    current_app,
-    request,
-    redirect,
-    render_template,
     flash,
+    redirect,
+    request,
+    render_template,
 )
+from tentacles.globals import ctx
 
-from .util import salt, is_logged_in
 
 log = logging.getLogger(__name__)
 BLUEPRINT = Blueprint("jobs", __name__)
 
 
 @requires_auth
-@BLUEPRINT.route("/jobs", methods=["GET", "POST"])
-def jobs():
-    if request.method == "POST":
-        ctx.db.create_job(ctx.uid, int(request.form.get("file_id")))
-        flash("Job created!", category="info")
+@BLUEPRINT.route("/jobs", methods=["GET"])
+def list_jobs():
+    return render_template("jobs.html.j2")
 
-    return redirect("/")
+
+@requires_auth
+@BLUEPRINT.route("/jobs", methods=["POST"])
+def manipulate_jobs():
+    match request.form.get("action"):
+        case "enqueue":
+            ctx.db.create_job(ctx.uid, int(request.form.get("file_id")))
+            flash("Job created!", category="info")
+
+        case  "duplicate":
+            if job := ctx.db.fetch_job(ctx.uid, int(request.form.get("job_id"))):
+                ctx.db.create_job(ctx.uid, job.file_id)
+                flash("Job created!", category="info")
+            else:
+                flash("Could not duplicate", category="error")
+
+        case "cancel":
+            ctx.db.cancel_job(ctx.uid, int(request.form.get("job_id")))
+            flash("Cancellation reqiested", category="info")
+
+        case "delete":
+            ctx.db.delete_job(ctx.uid, int(request.form.get("job_id")))
+            flash("Job deleted", category="info")
+
+        case _:
+            print(request.form)
+            flash("Unsupported operation", category="error")
+            return render_template("jobs.html.j2"), 400
+
+    return redirect("/jobs")
diff --git a/projects/tentacles/src/python/tentacles/blueprints/printer_ui.py b/projects/tentacles/src/python/tentacles/blueprints/printer_ui.py
index 1ac0b54..c9f9d29 100644
--- a/projects/tentacles/src/python/tentacles/blueprints/printer_ui.py
+++ b/projects/tentacles/src/python/tentacles/blueprints/printer_ui.py
@@ -2,16 +2,17 @@
 
 import logging
 
+from .util import is_logged_in, requires_admin
+
 from flask import (
     Blueprint,
-    request,
+    flash,
     redirect,
     render_template,
-    flash,
+    request,
 )
-
 from tentacles.globals import ctx
-from .util import is_logged_in, requires_admin
+
 
 log = logging.getLogger(__name__)
 BLUEPRINT = Blueprint("printer", __name__)
diff --git a/projects/tentacles/src/python/tentacles/blueprints/user_ui.py b/projects/tentacles/src/python/tentacles/blueprints/user_ui.py
index 77d1230..2d06204 100644
--- a/projects/tentacles/src/python/tentacles/blueprints/user_ui.py
+++ b/projects/tentacles/src/python/tentacles/blueprints/user_ui.py
@@ -1,21 +1,21 @@
 #!/usr/bin/env python3
 
-import logging
 from datetime import timedelta
+import logging
 import re
 
-from tentacles.globals import ctx
+from .util import is_logged_in, salt
 
 from flask import (
     Blueprint,
     current_app,
-    request,
+    flash,
     redirect,
     render_template,
-    flash,
+    request,
 )
+from tentacles.globals import ctx
 
-from .util import salt, is_logged_in
 
 log = logging.getLogger(__name__)
 BLUEPRINT = Blueprint("user", __name__)
diff --git a/projects/tentacles/src/python/tentacles/blueprints/util.py b/projects/tentacles/src/python/tentacles/blueprints/util.py
index a50d740..be30237 100644
--- a/projects/tentacles/src/python/tentacles/blueprints/util.py
+++ b/projects/tentacles/src/python/tentacles/blueprints/util.py
@@ -2,15 +2,10 @@
 
 import logging
 
-
-from flask import (
-    current_app,
-    redirect,
-    flash,
-)
-
+from flask import current_app, flash, redirect
 from tentacles.globals import ctx
 
+
 log = logging.getLogger(__name__)
 
 
diff --git a/projects/tentacles/src/python/tentacles/schema.sql b/projects/tentacles/src/python/tentacles/schema.sql
index aebda76..29f6da1 100644
--- a/projects/tentacles/src/python/tentacles/schema.sql
+++ b/projects/tentacles/src/python/tentacles/schema.sql
@@ -85,8 +85,10 @@ CREATE TABLE IF NOT EXISTS jobs (
  , file_id INTEGER NOT NULL
  , priority INTEGER CHECK(priority IS NOT NULL AND 0 <= priority)
  , started_at TEXT
+ , cancelled_at TEXT
  , finished_at TEXT
  , state TEXT
+ , message TEXT
  , printer_id INTEGER
  , FOREIGN KEY(user_id) REFERENCES users(id)
  , FOREIGN KEY(file_id) REFERENCES files(id)
diff --git a/projects/tentacles/src/python/tentacles/static/css/_normalize.scss b/projects/tentacles/src/python/tentacles/static/css/_normalize.scss
new file mode 100644
index 0000000..81c6f31
--- /dev/null
+++ b/projects/tentacles/src/python/tentacles/static/css/_normalize.scss
@@ -0,0 +1,427 @@
+/*! normalize.css v3.0.2 | MIT License | git.io/normalize */
+
+/**
+ * 1. Set default font family to sans-serif.
+ * 2. Prevent iOS text size adjust after orientation change, without disabling
+ *    user zoom.
+ */
+
+html {
+  font-family: sans-serif; /* 1 */
+  -ms-text-size-adjust: 100%; /* 2 */
+  -webkit-text-size-adjust: 100%; /* 2 */
+}
+
+/**
+ * Remove default margin.
+ */
+
+body {
+  margin: 0;
+}
+
+/* HTML5 display definitions
+   ========================================================================== */
+
+/**
+ * Correct `block` display not defined for any HTML5 element in IE 8/9.
+ * Correct `block` display not defined for `details` or `summary` in IE 10/11
+ * and Firefox.
+ * Correct `block` display not defined for `main` in IE 11.
+ */
+
+article,
+aside,
+details,
+figcaption,
+figure,
+footer,
+header,
+hgroup,
+main,
+menu,
+nav,
+section,
+summary {
+  display: block;
+}
+
+/**
+ * 1. Correct `inline-block` display not defined in IE 8/9.
+ * 2. Normalize vertical alignment of `progress` in Chrome, Firefox, and Opera.
+ */
+
+audio,
+canvas,
+progress,
+video {
+  display: inline-block; /* 1 */
+  vertical-align: baseline; /* 2 */
+}
+
+/**
+ * Prevent modern browsers from displaying `audio` without controls.
+ * Remove excess height in iOS 5 devices.
+ */
+
+audio:not([controls]) {
+  display: none;
+  height: 0;
+}
+
+/**
+ * Address `[hidden]` styling not present in IE 8/9/10.
+ * Hide the `template` element in IE 8/9/11, Safari, and Firefox < 22.
+ */
+
+[hidden],
+template {
+  display: none;
+}
+
+/* Links
+   ========================================================================== */
+
+/**
+ * Remove the gray background color from active links in IE 10.
+ */
+
+a {
+  background-color: transparent;
+}
+
+/**
+ * Improve readability when focused and also mouse hovered in all browsers.
+ */
+
+a:active,
+a:hover {
+  outline: 0;
+}
+
+/* Text-level semantics
+   ========================================================================== */
+
+/**
+ * Address styling not present in IE 8/9/10/11, Safari, and Chrome.
+ */
+
+abbr[title] {
+  border-bottom: 1px dotted;
+}
+
+/**
+ * Address style set to `bolder` in Firefox 4+, Safari, and Chrome.
+ */
+
+b,
+strong {
+  font-weight: bold;
+}
+
+/**
+ * Address styling not present in Safari and Chrome.
+ */
+
+dfn {
+  font-style: italic;
+}
+
+/**
+ * Address variable `h1` font-size and margin within `section` and `article`
+ * contexts in Firefox 4+, Safari, and Chrome.
+ */
+
+h1 {
+  font-size: 2em;
+  margin: 0.67em 0;
+}
+
+/**
+ * Address styling not present in IE 8/9.
+ */
+
+mark {
+  background: #ff0;
+  color: #000;
+}
+
+/**
+ * Address inconsistent and variable font size in all browsers.
+ */
+
+small {
+  font-size: 80%;
+}
+
+/**
+ * Prevent `sub` and `sup` affecting `line-height` in all browsers.
+ */
+
+sub,
+sup {
+  font-size: 75%;
+  line-height: 0;
+  position: relative;
+  vertical-align: baseline;
+}
+
+sup {
+  top: -0.5em;
+}
+
+sub {
+  bottom: -0.25em;
+}
+
+/* Embedded content
+   ========================================================================== */
+
+/**
+ * Remove border when inside `a` element in IE 8/9/10.
+ */
+
+img {
+  border: 0;
+}
+
+/**
+ * Correct overflow not hidden in IE 9/10/11.
+ */
+
+svg:not(:root) {
+  overflow: hidden;
+}
+
+/* Grouping content
+   ========================================================================== */
+
+/**
+ * Address margin not present in IE 8/9 and Safari.
+ */
+
+figure {
+  margin: 1em 40px;
+}
+
+/**
+ * Address differences between Firefox and other browsers.
+ */
+
+hr {
+  -moz-box-sizing: content-box;
+  box-sizing: content-box;
+  height: 0;
+}
+
+/**
+ * Contain overflow in all browsers.
+ */
+
+pre {
+  overflow: auto;
+}
+
+/**
+ * Address odd `em`-unit font size rendering in all browsers.
+ */
+
+code,
+kbd,
+pre,
+samp {
+  font-family: monospace, monospace;
+  font-size: 1em;
+}
+
+/* Forms
+   ========================================================================== */
+
+/**
+ * Known limitation: by default, Chrome and Safari on OS X allow very limited
+ * styling of `select`, unless a `border` property is set.
+ */
+
+/**
+ * 1. Correct color not being inherited.
+ *    Known issue: affects color of disabled elements.
+ * 2. Correct font properties not being inherited.
+ * 3. Address margins set differently in Firefox 4+, Safari, and Chrome.
+ */
+
+button,
+input,
+optgroup,
+select,
+textarea {
+  color: inherit; /* 1 */
+  font: inherit; /* 2 */
+  margin: 0; /* 3 */
+}
+
+/**
+ * Address `overflow` set to `hidden` in IE 8/9/10/11.
+ */
+
+button {
+  overflow: visible;
+}
+
+/**
+ * Address inconsistent `text-transform` inheritance for `button` and `select`.
+ * All other form control elements do not inherit `text-transform` values.
+ * Correct `button` style inheritance in Firefox, IE 8/9/10/11, and Opera.
+ * Correct `select` style inheritance in Firefox.
+ */
+
+button,
+select {
+  text-transform: none;
+}
+
+/**
+ * 1. Avoid the WebKit bug in Android 4.0.* where (2) destroys native `audio`
+ *    and `video` controls.
+ * 2. Correct inability to style clickable `input` types in iOS.
+ * 3. Improve usability and consistency of cursor style between image-type
+ *    `input` and others.
+ */
+
+button,
+html input[type="button"], /* 1 */
+input[type="reset"],
+input[type="submit"] {
+  -webkit-appearance: button; /* 2 */
+  cursor: pointer; /* 3 */
+}
+
+/**
+ * Re-set default cursor for disabled elements.
+ */
+
+button[disabled],
+html input[disabled] {
+  cursor: default;
+}
+
+/**
+ * Remove inner padding and border in Firefox 4+.
+ */
+
+button::-moz-focus-inner,
+input::-moz-focus-inner {
+  border: 0;
+  padding: 0;
+}
+
+/**
+ * Address Firefox 4+ setting `line-height` on `input` using `!important` in
+ * the UA stylesheet.
+ */
+
+input {
+  line-height: normal;
+}
+
+/**
+ * It's recommended that you don't attempt to style these elements.
+ * Firefox's implementation doesn't respect box-sizing, padding, or width.
+ *
+ * 1. Address box sizing set to `content-box` in IE 8/9/10.
+ * 2. Remove excess padding in IE 8/9/10.
+ */
+
+input[type="checkbox"],
+input[type="radio"] {
+  box-sizing: border-box; /* 1 */
+  padding: 0; /* 2 */
+}
+
+/**
+ * Fix the cursor style for Chrome's increment/decrement buttons. For certain
+ * `font-size` values of the `input`, it causes the cursor style of the
+ * decrement button to change from `default` to `text`.
+ */
+
+input[type="number"]::-webkit-inner-spin-button,
+input[type="number"]::-webkit-outer-spin-button {
+  height: auto;
+}
+
+/**
+ * 1. Address `appearance` set to `searchfield` in Safari and Chrome.
+ * 2. Address `box-sizing` set to `border-box` in Safari and Chrome
+ *    (include `-moz` to future-proof).
+ */
+
+input[type="search"] {
+  -webkit-appearance: textfield; /* 1 */
+  -moz-box-sizing: content-box;
+  -webkit-box-sizing: content-box; /* 2 */
+  box-sizing: content-box;
+}
+
+/**
+ * Remove inner padding and search cancel button in Safari and Chrome on OS X.
+ * Safari (but not Chrome) clips the cancel button when the search input has
+ * padding (and `textfield` appearance).
+ */
+
+input[type="search"]::-webkit-search-cancel-button,
+input[type="search"]::-webkit-search-decoration {
+  -webkit-appearance: none;
+}
+
+/**
+ * Define consistent border, margin, and padding.
+ */
+
+fieldset {
+  border: 1px solid #c0c0c0;
+  margin: 0 2px;
+  padding: 0.35em 0.625em 0.75em;
+}
+
+/**
+ * 1. Correct `color` not being inherited in IE 8/9/10/11.
+ * 2. Remove padding so people aren't caught out if they zero out fieldsets.
+ */
+
+legend {
+  border: 0; /* 1 */
+  padding: 0; /* 2 */
+}
+
+/**
+ * Remove default vertical scrollbar in IE 8/9/10/11.
+ */
+
+textarea {
+  overflow: auto;
+}
+
+/**
+ * Don't inherit the `font-weight` (applied by a rule above).
+ * NOTE: the default cannot safely be changed in Chrome and Safari on OS X.
+ */
+
+optgroup {
+  font-weight: bold;
+}
+
+/* Tables
+   ========================================================================== */
+
+/**
+ * Remove most spacing between table cells.
+ */
+
+table {
+  border-collapse: collapse;
+  border-spacing: 0;
+}
+
+td,
+th {
+  padding: 0;
+}
\ No newline at end of file
diff --git a/projects/tentacles/src/python/tentacles/static/css/_skeleton.scss b/projects/tentacles/src/python/tentacles/static/css/_skeleton.scss
new file mode 100644
index 0000000..f28bf6c
--- /dev/null
+++ b/projects/tentacles/src/python/tentacles/static/css/_skeleton.scss
@@ -0,0 +1,418 @@
+/*
+* Skeleton V2.0.4
+* Copyright 2014, Dave Gamache
+* www.getskeleton.com
+* Free to use under the MIT license.
+* http://www.opensource.org/licenses/mit-license.php
+* 12/29/2014
+*/
+
+
+/* Table of contents
+––––––––––––––––––––––––––––––––––––––––––––––––––
+- Grid
+- Base Styles
+- Typography
+- Links
+- Buttons
+- Forms
+- Lists
+- Code
+- Tables
+- Spacing
+- Utilities
+- Clearing
+- Media Queries
+*/
+
+
+/* Grid
+–––––––––––––––––––––––––––––––––––––––––––––––––– */
+.container {
+  position: relative;
+  width: 100%;
+  max-width: 960px;
+  margin: 0 auto;
+  padding: 0 20px;
+  box-sizing: border-box; }
+.column,
+.columns {
+  width: 100%;
+  float: left;
+  box-sizing: border-box; }
+
+/* For devices larger than 400px */
+@media (min-width: 400px) {
+  .container {
+    width: 85%;
+    padding: 0; }
+}
+
+/* For devices larger than 550px */
+@media (min-width: 550px) {
+  .container {
+    width: 80%; }
+  .column,
+  .columns {
+    margin-left: 4%; }
+  .column:first-child,
+  .columns:first-child {
+    margin-left: 0; }
+
+  .one.column,
+  .one.columns                    { width: 4.66666666667%; }
+  .two.columns                    { width: 13.3333333333%; }
+  .three.columns                  { width: 22%;            }
+  .four.columns                   { width: 30.6666666667%; }
+  .five.columns                   { width: 39.3333333333%; }
+  .six.columns                    { width: 48%;            }
+  .seven.columns                  { width: 56.6666666667%; }
+  .eight.columns                  { width: 65.3333333333%; }
+  .nine.columns                   { width: 74.0%;          }
+  .ten.columns                    { width: 82.6666666667%; }
+  .eleven.columns                 { width: 91.3333333333%; }
+  .twelve.columns                 { width: 100%; margin-left: 0; }
+
+  .one-third.column               { width: 30.6666666667%; }
+  .two-thirds.column              { width: 65.3333333333%; }
+
+  .one-half.column                { width: 48%; }
+
+  /* Offsets */
+  .offset-by-one.column,
+  .offset-by-one.columns          { margin-left: 8.66666666667%; }
+  .offset-by-two.column,
+  .offset-by-two.columns          { margin-left: 17.3333333333%; }
+  .offset-by-three.column,
+  .offset-by-three.columns        { margin-left: 26%;            }
+  .offset-by-four.column,
+  .offset-by-four.columns         { margin-left: 34.6666666667%; }
+  .offset-by-five.column,
+  .offset-by-five.columns         { margin-left: 43.3333333333%; }
+  .offset-by-six.column,
+  .offset-by-six.columns          { margin-left: 52%;            }
+  .offset-by-seven.column,
+  .offset-by-seven.columns        { margin-left: 60.6666666667%; }
+  .offset-by-eight.column,
+  .offset-by-eight.columns        { margin-left: 69.3333333333%; }
+  .offset-by-nine.column,
+  .offset-by-nine.columns         { margin-left: 78.0%;          }
+  .offset-by-ten.column,
+  .offset-by-ten.columns          { margin-left: 86.6666666667%; }
+  .offset-by-eleven.column,
+  .offset-by-eleven.columns       { margin-left: 95.3333333333%; }
+
+  .offset-by-one-third.column,
+  .offset-by-one-third.columns    { margin-left: 34.6666666667%; }
+  .offset-by-two-thirds.column,
+  .offset-by-two-thirds.columns   { margin-left: 69.3333333333%; }
+
+  .offset-by-one-half.column,
+  .offset-by-one-half.columns     { margin-left: 52%; }
+
+}
+
+
+/* Base Styles
+–––––––––––––––––––––––––––––––––––––––––––––––––– */
+/* NOTE
+html is set to 62.5% so that all the REM measurements throughout Skeleton
+are based on 10px sizing. So basically 1.5rem = 15px :) */
+html {
+  font-size: 62.5%; }
+body {
+  font-size: 1.5em; /* currently ems cause chrome bug misinterpreting rems on body element */
+  line-height: 1.6;
+  font-weight: 400;
+  font-family: "Raleway", "HelveticaNeue", "Helvetica Neue", Helvetica, Arial, sans-serif;
+  color: #222; }
+
+
+/* Typography
+–––––––––––––––––––––––––––––––––––––––––––––––––– */
+h1, h2, h3, h4, h5, h6 {
+  margin-top: 0;
+  margin-bottom: 2rem;
+  font-weight: 300; }
+h1 { font-size: 4.0rem; line-height: 1.2;  letter-spacing: -.1rem;}
+h2 { font-size: 3.6rem; line-height: 1.25; letter-spacing: -.1rem; }
+h3 { font-size: 3.0rem; line-height: 1.3;  letter-spacing: -.1rem; }
+h4 { font-size: 2.4rem; line-height: 1.35; letter-spacing: -.08rem; }
+h5 { font-size: 1.8rem; line-height: 1.5;  letter-spacing: -.05rem; }
+h6 { font-size: 1.5rem; line-height: 1.6;  letter-spacing: 0; }
+
+/* Larger than phablet */
+@media (min-width: 550px) {
+  h1 { font-size: 5.0rem; }
+  h2 { font-size: 4.2rem; }
+  h3 { font-size: 3.6rem; }
+  h4 { font-size: 3.0rem; }
+  h5 { font-size: 2.4rem; }
+  h6 { font-size: 1.5rem; }
+}
+
+p {
+  margin-top: 0; }
+
+
+/* Links
+–––––––––––––––––––––––––––––––––––––––––––––––––– */
+a {
+  color: #1EAEDB; }
+a:hover {
+  color: #0FA0CE; }
+
+
+/* Buttons
+–––––––––––––––––––––––––––––––––––––––––––––––––– */
+.button,
+button,
+input[type="submit"],
+input[type="reset"],
+input[type="button"] {
+  display: inline-block;
+  height: 38px;
+  padding: 0 30px;
+  color: #555;
+  text-align: center;
+  font-size: 11px;
+  font-weight: 600;
+  line-height: 38px;
+  letter-spacing: .1rem;
+  text-transform: uppercase;
+  text-decoration: none;
+  white-space: nowrap;
+  background-color: transparent;
+  border-radius: 4px;
+  border: 1px solid #bbb;
+  cursor: pointer;
+  box-sizing: border-box; }
+.button:hover,
+button:hover,
+input[type="submit"]:hover,
+input[type="reset"]:hover,
+input[type="button"]:hover,
+.button:focus,
+button:focus,
+input[type="submit"]:focus,
+input[type="reset"]:focus,
+input[type="button"]:focus {
+  color: #333;
+  border-color: #888;
+  outline: 0; }
+.button.button-primary,
+button.button-primary,
+input[type="submit"].button-primary,
+input[type="reset"].button-primary,
+input[type="button"].button-primary {
+  color: #FFF;
+  background-color: #33C3F0;
+  border-color: #33C3F0; }
+.button.button-primary:hover,
+button.button-primary:hover,
+input[type="submit"].button-primary:hover,
+input[type="reset"].button-primary:hover,
+input[type="button"].button-primary:hover,
+.button.button-primary:focus,
+button.button-primary:focus,
+input[type="submit"].button-primary:focus,
+input[type="reset"].button-primary:focus,
+input[type="button"].button-primary:focus {
+  color: #FFF;
+  background-color: #1EAEDB;
+  border-color: #1EAEDB; }
+
+
+/* Forms
+–––––––––––––––––––––––––––––––––––––––––––––––––– */
+input[type="email"],
+input[type="number"],
+input[type="search"],
+input[type="text"],
+input[type="tel"],
+input[type="url"],
+input[type="password"],
+textarea,
+select {
+  height: 38px;
+  padding: 6px 10px; /* The 6px vertically centers text on FF, ignored by Webkit */
+  background-color: #fff;
+  border: 1px solid #D1D1D1;
+  border-radius: 4px;
+  box-shadow: none;
+  box-sizing: border-box; }
+/* Removes awkward default styles on some inputs for iOS */
+input[type="email"],
+input[type="number"],
+input[type="search"],
+input[type="text"],
+input[type="tel"],
+input[type="url"],
+input[type="password"],
+textarea {
+  -webkit-appearance: none;
+     -moz-appearance: none;
+          appearance: none; }
+textarea {
+  min-height: 65px;
+  padding-top: 6px;
+  padding-bottom: 6px; }
+input[type="email"]:focus,
+input[type="number"]:focus,
+input[type="search"]:focus,
+input[type="text"]:focus,
+input[type="tel"]:focus,
+input[type="url"]:focus,
+input[type="password"]:focus,
+textarea:focus,
+select:focus {
+  border: 1px solid #33C3F0;
+  outline: 0; }
+label,
+legend {
+  display: block;
+  margin-bottom: .5rem;
+  font-weight: 600; }
+fieldset {
+  padding: 0;
+  border-width: 0; }
+input[type="checkbox"],
+input[type="radio"] {
+  display: inline; }
+label > .label-body {
+  display: inline-block;
+  margin-left: .5rem;
+  font-weight: normal; }
+
+
+/* Lists
+–––––––––––––––––––––––––––––––––––––––––––––––––– */
+ul {
+  list-style: circle inside; }
+ol {
+  list-style: decimal inside; }
+ol, ul {
+  padding-left: 0;
+  margin-top: 0; }
+ul ul,
+ul ol,
+ol ol,
+ol ul {
+  margin: 1.5rem 0 1.5rem 3rem;
+  font-size: 90%; }
+li {
+  margin-bottom: 1rem; }
+
+
+/* Code
+–––––––––––––––––––––––––––––––––––––––––––––––––– */
+code {
+  padding: .2rem .5rem;
+  margin: 0 .2rem;
+  font-size: 90%;
+  white-space: nowrap;
+  background: #F1F1F1;
+  border: 1px solid #E1E1E1;
+  border-radius: 4px; }
+pre > code {
+  display: block;
+  padding: 1rem 1.5rem;
+  white-space: pre; }
+
+
+/* Tables
+–––––––––––––––––––––––––––––––––––––––––––––––––– */
+th,
+td {
+  padding: 12px 15px;
+  text-align: left;
+  border-bottom: 1px solid #E1E1E1; }
+th:first-child,
+td:first-child {
+  padding-left: 0; }
+th:last-child,
+td:last-child {
+  padding-right: 0; }
+
+
+/* Spacing
+–––––––––––––––––––––––––––––––––––––––––––––––––– */
+button,
+.button {
+  margin-bottom: 1rem; }
+input,
+textarea,
+select,
+fieldset {
+  margin-bottom: 1.5rem; }
+pre,
+blockquote,
+dl,
+figure,
+table,
+p,
+ul,
+ol,
+form {
+  margin-bottom: 2.5rem; }
+
+
+/* Utilities
+–––––––––––––––––––––––––––––––––––––––––––––––––– */
+.u-full-width {
+  width: 100%;
+  box-sizing: border-box; }
+.u-max-full-width {
+  max-width: 100%;
+  box-sizing: border-box; }
+.u-pull-right {
+  float: right; }
+.u-pull-left {
+  float: left; }
+
+
+/* Misc
+–––––––––––––––––––––––––––––––––––––––––––––––––– */
+hr {
+  margin-top: 3rem;
+  margin-bottom: 3.5rem;
+  border-width: 0;
+  border-top: 1px solid #E1E1E1; }
+
+
+/* Clearing
+–––––––––––––––––––––––––––––––––––––––––––––––––– */
+
+/* Self Clearing Goodness */
+.container:after,
+.row:after,
+.u-cf {
+  content: "";
+  display: table;
+  clear: both; }
+
+
+/* Media Queries
+–––––––––––––––––––––––––––––––––––––––––––––––––– */
+/*
+Note: The best way to structure the use of media queries is to create the queries
+near the relevant code. For example, if you wanted to change the styles for buttons
+on small devices, paste the mobile query code up in the buttons section and style it
+there.
+*/
+
+
+/* Larger than mobile */
+@media (min-width: 400px) {}
+
+/* Larger than phablet (also point when grid becomes active) */
+@media (min-width: 550px) {}
+
+/* Larger than tablet */
+@media (min-width: 750px) {}
+
+/* Larger than desktop */
+@media (min-width: 1000px) {}
+
+/* Larger than Desktop HD */
+@media (min-width: 1200px) {}
diff --git a/projects/tentacles/src/python/tentacles/static/css/_tirefire.scss b/projects/tentacles/src/python/tentacles/static/css/_tirefire.scss
index b596b4e..4522d77 100644
--- a/projects/tentacles/src/python/tentacles/static/css/_tirefire.scss
+++ b/projects/tentacles/src/python/tentacles/static/css/_tirefire.scss
@@ -1,188 +1,5 @@
-$black: #171426;
-$beige: #F4F8EE;
-$red: #BB2D2E;
-$orange: #CA4F1F;
-$yellow: #EDB822;
-$clear: rgba(255, 255, 255, 255);
+@import "tirefire/colors";
+@import "tirefire/fonts";
 
-$secondary_red: red;
-$secondary_blue: #288BC2;
-$secondary_green: #A5C426;
-$secondary_light_grey: #CACBCA;
-$secondary_dark_grey: #9A9A9A;
-
-@font-face {
-  font-family: 'Aaux Next';
-  font-style: normal;
-  font-weight: 400;
-  src: local('Aaux Next'), url('/static/font/AauxNextBlk.otf') format('otf');
-}
-
-@font-face {
-  font-family: 'Aaux Next';
-  font-style: normal;
-  font-weight: 400;
-  src: local('Aaux Next'), url('/static/font/aauxnextbdwebfont.otf') format('otf');
-}
-
-@font-face {
-  font-family: 'Aaux Next';
-  font-style: normal;
-  font-weight: 400;
-  src: local('Aaux Next'), url('/static/font/aauxnextltwebfont.otf') format('otf');
-}
-
-@font-face {
-  font-family: 'Aaux Next';
-  font-style: normal;
-  font-weight: 400;
-  src: local('Aaux Next'), url('/static/font/aauxnextmdwebfont.otf') format('otf');
-}
-
-@import url(https://fonts.googleapis.com/css?family=Raleway);
-
-////////////////////////////////////////////////////////////////////////////////////////////////////
-// Hidable alerts
-.alert .inner {
-  display: block;
-  padding: 6px;
-  margin: 6px;
-  border-radius: 3px;
-  border: 1px solid rgb(180,180,180);
-  background-color: rgb(212,212,212);
-}
-
-.alert .close {
-  float: right;
-  margin: 3px 12px 0px 0px;
-  cursor: pointer;
-}
-
-.alert .inner,.alert .close {
-  color: rgb(88,88,88);
-}
-
-.alert input {
-  display: none;
-}
-
-.alert input:checked ~ * {
-  animation-name: dismiss,hide;
-  animation-duration: 300ms;
-  animation-iteration-count: 1;
-  animation-timing-function: ease;
-  animation-fill-mode: forwards;
-  animation-delay: 0s,100ms;
-}
-
-.alert.error .inner {
-  border: 1px solid rgb(238,211,215);
-  background-color: rgb(242,222,222);
-}
-
-.alert.error .inner,.alert.error .close {
-  color: rgb(185,74,72);
-}
-
-.alert.success .inner {
-  border: 1px solid rgb(214,233,198);
-  background-color: rgb(223,240,216);
-}
-
-.alert.success .inner,.alert.success .close {
-  color: rgb(70,136,71);
-}
-
-.alert.info .inner {
-  border: 1px solid rgb(188,232,241);
-  background-color: rgb(217,237,247);
-}
-
-.alert.info .inner,.alert.info .close {
-  color: rgb(58,135,173);
-}
-
-.alert.warning .inner {
-  border: 1px solid rgb(251,238,213);
-  background-color: rgb(252,248,227);
-}
-
-.alert.warning .inner,.alert.warning .close {
-  color: rgb(192,152,83);
-}
-
-@keyframes dismiss {
-  0% {
-    opacity: 1;
-  }
-  90%, 100% {
-    opacity: 0;
-    font-size: 0.1px;
-    transform: scale(0);
-  }
-}
-
-@keyframes hide {
-  100% {
-    height: 0px;
-    width: 0px;
-    overflow: hidden;
-    margin: 0px;
-    padding: 0px;
-    border: 0px;
-  }
-}
-
-////////////////////////////////////////////////////////////////////////////////////////////////////
-// A timer animation
-
-.timer {
-    background: -webkit-linear-gradient(left, skyBlue 50%, #eee 50%);
-    border-radius: 100%;
-    height: calc(var(--size) * 1px);
-    width: calc(var(--size) * 1px);
-    position: relative;
-    -webkit-animation: time calc(var(--duration) * 1s) steps(1000, start);
-      -webkit-mask: radial-gradient(transparent 50%,#000 50%);
-      mask: radial-gradient(transparent 50%,#000 50%);
-}
-.mask {
-    border-radius: 100% 0 0 100% / 50% 0 0 50%;
-    height: 100%;
-    left: 0;
-    position: absolute;
-    top: 0;
-    width: 50%;
-    -webkit-animation: mask calc(var(--duration) * 1s) steps(500, start);
-    -webkit-transform-origin: 100% 50%;
-}
-@-webkit-keyframes time {
-    100% {
-        -webkit-transform: rotate(360deg);
-    }
-}
-@-webkit-keyframes mask {
-    0% {
-        background: #eee;
-        -webkit-transform: rotate(0deg);
-    }
-    50% {
-        background: #eee;
-        -webkit-transform: rotate(-180deg);
-    }
-    50.01% {
-        background: skyBlue;
-        -webkit-transform: rotate(0deg);
-    }
-    100% {
-        background: skyBlue;
-        -webkit-transform: rotate(-180deg);
-    }
-}
-
-.alert .timer {
-    --size: 10;
-    --duration: 5;
-    padding: 6px;
-    margin: 6px;
-}
+@import "tirefire/alerts";
+@import "tirefire/timers";
diff --git a/projects/tentacles/src/python/tentacles/static/css/style.scss b/projects/tentacles/src/python/tentacles/static/css/style.scss
index 7d128da..cfec423 100644
--- a/projects/tentacles/src/python/tentacles/static/css/style.scss
+++ b/projects/tentacles/src/python/tentacles/static/css/style.scss
@@ -1,328 +1,26 @@
-@import "tirefire";
+@import "normalize";
+@import "skeleton";
 
-.color-yellow {
-  color: $yellow;
+// And the TireFire® bits
+@import "tirefire/fonts";
+@import "tirefire/basics";
+@import "tirefire/alerts";
+@import "tirefire/timers";
+@import "tirefire/nav";
+@import "tirefire/dots";
+
+.controls a,
+.controls form {
+    margin-right: 2px;
 }
 
-html {
-  font-family: 'Aaux Next', sans-serif;
-  background-color: $beige;
-  color: $black;
+.file,
+.printer,
+.key,
+.job {
+    margin-top: 4px;
 }
 
-html, body {
-  margin: 0;
-  height: 100%;
-  width: 100%;
-  min-width: 400px;
-
-  display: flex;
-  flex-grow: 1;
-  flex-direction: column;
-  justify-content: center;
-}
-
-.content, .footer {
-  padding-left: 10%;
-  padding-right: 10%;
-}
-
-.content {
-  .flash, .panel {
-    margin-bottom: 40px;
-  }
-}
-
-a {
-  color: $secondary_blue;
-  text-decoration: none;
-}
-
-*, *::before, *::after {
-  margin: 0;
-  padding: 0;
-  align-content: center;
-}
-
-h1, h2, h3, h4, h5, h6 {
-  width: 100%;
-}
-
-span {
-  display: flex;
-  align-self: center;
-}
-
-ul {
-  list-style: none;
-
-  .decorated {
-    list-style: auto;
-    padding: 1em;
-  }
-
-  li {
-    padding-top: 0.1em;
-  }
-}
-
-nav {
-  background-color: $beige;
-  box-shadow: 0px 10px 0px $red,
-              0px 12px 0px $clear,
-              0px 22px 0px $orange,
-              0px 24px 0px $clear,
-              0px 34px 0px $yellow;
-  margin-bottom: 34px;
-
-  .logo {
-    text-decoration: none;
-    font-weight: bold;
-    font-size: 60px;
-
-    img {
-      height: 56px;
-    }
-  }
-}
-
-.nav-links {
-  list-style: none;
-}
-
-.nav-item a {
-  display: inline-block;
-  padding: 10px 15px;
-}
-
-.nav-item:hover {
-  background-color: white;
-}
-
-.nav-item:hover a {
-  color: $secondary_blue;
-}
-
-$navbar_height: 50px;
-$navbar_padding: 10px;
-.navbar {
-  display: flex;
-  flex-direction: row;
-  align-items: center;
-  justify-content: space-between;
-  color: #FFF;
-  padding-left: $navbar_padding;
-  padding-right: $navbar_padding;
-  margin-top: $navbar_padding;
-}
-
-.menu {
-  display: flex;
-  flex-direction: row;
-  list-style-type: none;
-  margin: 0;
-  padding: 0;
-}
-
-.menu > li {
-  margin: 0 1rem;
-  // overflow: hidden;
-}
-
-.menu-button-container {
-  display: none;
-  height: 100%;
-  width: 30px;
-  cursor: pointer;
-  flex-direction: column;
-  justify-content: center;
-  align-items: center;
-}
-
-#menu-toggle {
-  display: none;
-}
-
-.menu-button,
-.menu-button::before,
-.menu-button::after {
-  display: block;
-  background-color: $red;
-  position: absolute;
-  height: 4px;
-  width: 30px;
-  transition: transform 400ms cubic-bezier(0.23, 1, 0.32, 1);
-  border-radius: 2px;
-}
-
-.menu-button::before {
-  content: '';
-  margin-top: -8px;
-}
-
-.menu-button::after {
-  content: '';
-  margin-top: 8px;
-}
-
-#menu-toggle:checked + .menu-button-container .menu-button::before {
-  margin-top: 0px;
-  transform: rotate(405deg);
-}
-
-#menu-toggle:checked + .menu-button-container .menu-button {
-  background: rgba(255, 255, 255, 0);
-}
-
-#menu-toggle:checked + .menu-button-container .menu-button::after {
-  margin-top: 0px;
-  transform: rotate(-405deg);
-}
-
-@media (max-width: 700px) {
-  .menu-button-container {
-    display: flex;
-  }
-  .menu {
-    position: absolute;
-    top: 0;
-    margin-top: $navbar_height + ($navbar_padding * 3);
-    left: 0;
-    flex-direction: column;
-    width: 100%;
-    justify-content: left;
-    align-items: center;
-  }
-  #menu-toggle ~ .menu li {
-    height: 0;
-    margin: 0;
-    padding: 0;
-    border: 0;
-    transition: height 400ms cubic-bezier(0.23, 1, 0.32, 1);
-  }
-  #menu-toggle:checked ~ .menu li {
-    height: 2.5em;
-    padding: 0.5em;
-    transition: height 400ms cubic-bezier(0.23, 1, 0.32, 1);
-  }
-  .menu > li {
-    display: flex;
-    justify-content: center;
-    margin: 0;
-    padding: 0.5em 0;
-    width: 100%;
-    color: $secondary_blue;
-    background-color: $beige;
-  }
-  #menu-toggle:checked ~ .menu > li {
-    border-top: 1px solid #444;
-  }
-  #menu-toggle:checked ~ .menu > li:last-child {
-    border-bottom: 1px solid #444;
-  }
-}
-
-.content {
-  padding-top: 1em;
-  padding-left: 10%;
-  padding-right: 10%;
-}
-
-.footer {
-  margin-top: auto;
-  width: 100%;
-}
-
-.row {
-  display: flex;
-  flex-direction: row;
-  flex-wrap: wrap;
-  width: 100%;
-}
-
-.container {
-  display: flex;
-  flex-direction: column;
-  flex-wrap: wrap;
-  width: 100%;
-}
-
-.mr-auto {
-  margin-right: auto;
-}
-
-.ml-auto {
-  margin-left: auto;
-}
-
-.flashes {
-  .alert {
-    p {
-      font-size: 20px;
-      margin-top: 10px;
-      margin-left: 10px;
-    }
-  }
-}
-
-form {
-  display: flex;
-  flex-direction: column;
-
-  .form-input {
-    display: flex;
-    flex-direction: row;
-
-    .form-label {
-      width: 200px;
-      margin-right: 10px;
-    }
-
-    input[type=text] {
-      margin-right: auto;
-      width: 400px;
-    }
-  }
-}
-
-.button,
-input[type=submit],
-input[type=button],
-button[type=submit] {
-  border: 1px solid $secondary_blue;
-  border-radius: 0.25em;
-  padding: 0.5em;
-  background-color: $secondary_blue;
-  color: $beige;
-  cursor: pointer;
-  text-transform: uppercase;
-  font-weight: bold;
-}
-
-.keys ul li {
-  display: flex;
-  flex-direction: row;
-
-  span {
-    margin-right: 1em;
-  }
-
-  .key-key {
-    max-width: 20em;
-    overflow: clip;
-  }
-}
-
-.printer-name,
-.printer-status,
-.printer-url,
-.printer-date,
-.printer-controls {
-  margin-top: 0.1em;
-  margin-right: 1em;
-  min-width: 10em;
-}
-
-.printer-controls * {
-  margin-right: 0.1em;
+.details {
+    overflow: hidden;
 }
diff --git a/projects/tentacles/src/python/tentacles/static/css/tirefire/_alerts.scss b/projects/tentacles/src/python/tentacles/static/css/tirefire/_alerts.scss
new file mode 100644
index 0000000..b8f07a7
--- /dev/null
+++ b/projects/tentacles/src/python/tentacles/static/css/tirefire/_alerts.scss
@@ -0,0 +1,89 @@
+.alert .inner {
+  display: block;
+  padding: 6px;
+  margin: 6px;
+  border-radius: 3px;
+  border: 1px solid rgb(180,180,180);
+  background-color: rgb(212,212,212);
+}
+
+.alert .close {
+  float: right;
+  margin: 3px 12px 0px 0px;
+  cursor: pointer;
+}
+
+.alert .inner,.alert .close {
+  color: rgb(88,88,88);
+}
+
+.alert input {
+  display: none;
+}
+
+.alert input:checked ~ * {
+  animation-name: dismiss,hide;
+  animation-duration: 300ms;
+  animation-iteration-count: 1;
+  animation-timing-function: ease;
+  animation-fill-mode: forwards;
+  animation-delay: 0s,100ms;
+}
+
+.alert.error .inner {
+  border: 1px solid rgb(238,211,215);
+  background-color: rgb(242,222,222);
+}
+
+.alert.error .inner,.alert.error .close {
+  color: rgb(185,74,72);
+}
+
+.alert.success .inner {
+  border: 1px solid rgb(214,233,198);
+  background-color: rgb(223,240,216);
+}
+
+.alert.success .inner,.alert.success .close {
+  color: rgb(70,136,71);
+}
+
+.alert.info .inner {
+  border: 1px solid rgb(188,232,241);
+  background-color: rgb(217,237,247);
+}
+
+.alert.info .inner,.alert.info .close {
+  color: rgb(58,135,173);
+}
+
+.alert.warning .inner {
+  border: 1px solid rgb(251,238,213);
+  background-color: rgb(252,248,227);
+}
+
+.alert.warning .inner,.alert.warning .close {
+  color: rgb(192,152,83);
+}
+
+@keyframes dismiss {
+  0% {
+    opacity: 1;
+  }
+  90%, 100% {
+    opacity: 0;
+    font-size: 0.1px;
+    transform: scale(0);
+  }
+}
+
+@keyframes hide {
+  100% {
+    height: 0px;
+    width: 0px;
+    overflow: hidden;
+    margin: 0px;
+    padding: 0px;
+    border: 0px;
+  }
+}
diff --git a/projects/tentacles/src/python/tentacles/static/css/tirefire/_basics.scss b/projects/tentacles/src/python/tentacles/static/css/tirefire/_basics.scss
new file mode 100644
index 0000000..fa8672a
--- /dev/null
+++ b/projects/tentacles/src/python/tentacles/static/css/tirefire/_basics.scss
@@ -0,0 +1,36 @@
+@import "tirefire/colors";
+@import "tirefire/fonts";
+
+html,
+body {
+  font-family: 'Aaux Next', sans-serif;
+  background-color: $beige;
+  color: $black;
+}
+
+body {
+  display: flex;
+  flex-direction: column;
+  min-height: 99vh;
+}
+
+.footer {
+  margin-top: auto
+}
+
+.u-flex {
+  display: flex;
+}
+
+.inline,
+.inline > * {
+  margin: 0;
+}
+
+.u-mr-auto {
+  margin-right: auto;
+}
+
+.u-ml-auto {
+  margin-left: auto;
+}
diff --git a/projects/tentacles/src/python/tentacles/static/css/tirefire/_colors.scss b/projects/tentacles/src/python/tentacles/static/css/tirefire/_colors.scss
new file mode 100644
index 0000000..aff57f0
--- /dev/null
+++ b/projects/tentacles/src/python/tentacles/static/css/tirefire/_colors.scss
@@ -0,0 +1,12 @@
+$black: #171426;
+$beige: #F4F8EE;
+$red: #BB2D2E;
+$orange: #CA4F1F;
+$yellow: #EDB822;
+$clear: rgba(255, 255, 255, 255);
+
+$secondary_red: red;
+$secondary_blue: #288BC2;
+$secondary_green: #A5C426;
+$secondary_light_grey: #CACBCA;
+$secondary_dark_grey: #9A9A9A;
diff --git a/projects/tentacles/src/python/tentacles/static/css/tirefire/_dots.scss b/projects/tentacles/src/python/tentacles/static/css/tirefire/_dots.scss
new file mode 100644
index 0000000..b855021
--- /dev/null
+++ b/projects/tentacles/src/python/tentacles/static/css/tirefire/_dots.scss
@@ -0,0 +1,49 @@
+@import "tirefire/colors";
+
+@keyframes blink {
+  0% { opacity: 0; }
+  50% { opacity: 1; }
+  100% { opacity: 0; }
+}
+
+.dot {
+  width: var(--dot-size);
+  height: var(--dot-size);
+  background-color: var(--dot-color);
+  border-radius: 50%;
+}
+
+.dot.success {
+  background-color: $secondary_green;
+}
+
+.dot.queued {
+  background-color: $secondary_blue;
+}
+
+.dot.running {
+  background-color: $secondary_green;
+}
+
+.dot.error,
+.dot.cancelled {
+  background-color: $red;
+}
+
+.dot--basic {
+  animation: blink 2s infinite;
+}
+
+.dot--once {
+  animation: blink 2s 1;
+}
+
+.dot--delayed {
+  animation: blink 2s infinite 4s;
+}
+
+@media (prefers-reduced-motion: reduce) {
+  .dot {
+    animation: none;
+  }
+}
diff --git a/projects/tentacles/src/python/tentacles/static/css/tirefire/_fonts.scss b/projects/tentacles/src/python/tentacles/static/css/tirefire/_fonts.scss
new file mode 100644
index 0000000..91e42ff
--- /dev/null
+++ b/projects/tentacles/src/python/tentacles/static/css/tirefire/_fonts.scss
@@ -0,0 +1,29 @@
+@font-face {
+  font-family: 'Aaux Next';
+  font-style: normal;
+  font-weight: 400;
+  src: local('Aaux Next'), url('/static/font/AauxNextBlk.otf') format('otf');
+}
+
+@font-face {
+  font-family: 'Aaux Next';
+  font-style: normal;
+  font-weight: 400;
+  src: local('Aaux Next'), url('/static/font/aauxnextbdwebfont.otf') format('otf');
+}
+
+@font-face {
+  font-family: 'Aaux Next';
+  font-style: normal;
+  font-weight: 400;
+  src: local('Aaux Next'), url('/static/font/aauxnextltwebfont.otf') format('otf');
+}
+
+@font-face {
+  font-family: 'Aaux Next';
+  font-style: normal;
+  font-weight: 400;
+  src: local('Aaux Next'), url('/static/font/aauxnextmdwebfont.otf') format('otf');
+}
+
+@import url(https://fonts.googleapis.com/css?family=Raleway);
diff --git a/projects/tentacles/src/python/tentacles/static/css/tirefire/_nav.scss b/projects/tentacles/src/python/tentacles/static/css/tirefire/_nav.scss
new file mode 100644
index 0000000..8a573bc
--- /dev/null
+++ b/projects/tentacles/src/python/tentacles/static/css/tirefire/_nav.scss
@@ -0,0 +1,161 @@
+@import "tirefire/colors";
+
+.nav-links {
+  list-style: none;
+}
+
+.nav-item a {
+  display: inline-block;
+  padding: 10px 15px;
+}
+
+.nav-item:hover {
+}
+
+.nav-item:hover a {
+  color: $secondary_blue;
+}
+
+$navbar_height: 50px;
+$navbar_padding: 10px;
+$stripe_thickness: 8px;
+$stripe_padding: ($stripe_thickness / 4);
+
+.navbar {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  justify-content: space-between;
+  padding-left: $navbar_padding;
+  padding-right: $navbar_padding;
+  margin-top: $navbar_padding;
+  background-color: $beige;
+  box-shadow: 0px (1 * $stripe_thickness + 0 * $stripe_padding) 0px $red,
+              0px (1 * $stripe_thickness + 1 * $stripe_padding) 0px $clear,
+              0px (2 * $stripe_thickness + 1 * $stripe_padding) 0px $orange,
+              0px (2 * $stripe_thickness + 2 * $stripe_padding) 0px $clear,
+              0px (3 * $stripe_thickness + 2 * $stripe_padding) 0px $yellow;
+  margin-bottom: (3 * ($stripe_thickness + $stripe_padding));
+
+  .logo {
+    text-decoration: none;
+    font-size: 60px;
+
+    a { text-decoration: none; color: $black; }
+
+    img {
+      height: 56px;
+    }
+  }
+}
+
+.menu {
+  display: flex;
+  flex-direction: row;
+  list-style-type: none;
+  margin: 0;
+  padding: 0;
+  z-index: 10000;
+}
+
+.menu > li {
+  margin: 0 1rem;
+  overflow: hidden;
+}
+
+.menu-button-container {
+  display: none;
+  height: 100%;
+  width: 30px;
+  cursor: pointer;
+  flex-direction: column;
+  justify-content: center;
+  align-items: center;
+
+  margin-left: auto;  // Float hard right
+}
+
+#menu-toggle {
+  display: none;
+}
+
+.menu-button,
+.menu-button::before,
+.menu-button::after {
+  display: block;
+  background-color: $red;
+  position: absolute;
+  height: 4px;
+  width: 30px;
+  transition: transform 400ms cubic-bezier(0.23, 1, 0.32, 1);
+  border-radius: 2px;
+}
+
+.menu-button::before {
+  content: '';
+  margin-top: -8px;
+}
+
+.menu-button::after {
+  content: '';
+  margin-top: 8px;
+}
+
+#menu-toggle:checked + .menu-button-container .menu-button::before {
+  margin-top: 0px;
+  transform: rotate(405deg);
+}
+
+#menu-toggle:checked + .menu-button-container .menu-button {
+  background: rgba(255, 255, 255, 0);
+}
+
+#menu-toggle:checked + .menu-button-container .menu-button::after {
+  margin-top: 0px;
+  transform: rotate(-405deg);
+}
+
+//@media (max-width: 700px) {
+  .menu-button-container {
+    display: flex;
+  }
+  .menu {
+    position: absolute;
+    top: 0;
+    margin-top: $navbar_height + ($navbar_padding * 3);
+    left: 0;
+    flex-direction: column;
+    width: 100%;
+    justify-content: left;
+    align-items: center;
+  }
+  #menu-toggle ~ .menu li {
+    height: 0;
+    margin: 0;
+    padding: 0;
+    border: 0;
+    transition: height 400ms cubic-bezier(0.23, 1, 0.32, 1);
+  }
+  #menu-toggle:checked ~ .menu li {
+    height: 2.5em;
+    padding: 0.5em;
+    transition: height 400ms cubic-bezier(0.23, 1, 0.32, 1);
+  }
+  .menu > li {
+    display: flex;
+    justify-content: center;
+    margin: 0;
+    padding: 0.5em 0;
+    width: 100%;
+    color: $secondary_blue;
+    background-color: $beige;
+  }
+  /*
+  #menu-toggle:checked ~ .menu > li {
+    border-top: 1px solid #444;
+  }
+  */
+  #menu-toggle:checked ~ .menu > li:last-child {
+    border-bottom: 1px solid #444;
+  }
+//}
diff --git a/projects/tentacles/src/python/tentacles/static/css/tirefire/_timers.scss b/projects/tentacles/src/python/tentacles/static/css/tirefire/_timers.scss
new file mode 100644
index 0000000..4cf1a64
--- /dev/null
+++ b/projects/tentacles/src/python/tentacles/static/css/tirefire/_timers.scss
@@ -0,0 +1,52 @@
+@import "tirefire/colors";
+
+.timer {
+    background: -webkit-linear-gradient(left, $secondary_light_grey 50%, $clear 50%);
+    border-radius: 100%;
+    height: calc(var(--size) * 1px);
+    width: calc(var(--size) * 1px);
+    position: relative;
+    -webkit-animation: time calc(var(--duration) * 1s) steps(1000, start);
+      -webkit-mask: radial-gradient(transparent 50%, #000 50%);
+      mask: radial-gradient(transparent 50%, #000 50%);
+}
+.mask {
+    border-radius: 100% 0 0 100% / 50% 0 0 50%;
+    height: 100%;
+    left: 0;
+    position: absolute;
+    top: 0;
+    width: 50%;
+    -webkit-animation: mask calc(var(--duration) * 1s) steps(500, start);
+    -webkit-transform-origin: 100% 50%;
+}
+@-webkit-keyframes time {
+    100% {
+        -webkit-transform: rotate(360deg);
+    }
+}
+@-webkit-keyframes mask {
+    0% {
+        background: $clear;
+        -webkit-transform: rotate(0deg);
+    }
+    50% {
+        background: $clear;
+        -webkit-transform: rotate(-180deg);
+    }
+    50.01% {
+        background: $secondary_light_grey;
+        -webkit-transform: rotate(0deg);
+    }
+    100% {
+        background: $secondary_light_grey;
+        -webkit-transform: rotate(-180deg);
+    }
+}
+
+.alert .timer {
+    --size: 10;
+    --duration: 5;
+    padding: 6px;
+    margin: 6px;
+}
diff --git a/projects/tentacles/src/python/tentacles/store.py b/projects/tentacles/src/python/tentacles/store.py
index 7baac77..54dd9f4 100644
--- a/projects/tentacles/src/python/tentacles/store.py
+++ b/projects/tentacles/src/python/tentacles/store.py
@@ -4,7 +4,6 @@ from collections import namedtuple
 from datetime import datetime, timedelta
 from hashlib import sha3_256
 from importlib.resources import files
-from pathlib import Path
 import sqlite3
 from textwrap import indent
 from typing import Optional
@@ -187,10 +186,10 @@ class Store(object):
 
     @requires_conn
     def list_keys(self, uid: int):
-        for id, name, exp in self._conn.execute(
-            "SELECT id, name, expiration FROM user_keys WHERE user_id = ?1", [uid]
-        ).fetchall():
-            yield id, name, datetime.fromisoformat(exp) if exp else None
+        return [(id, name, datetime.fromisoformat(exp) if exp else None)
+         for id, name, exp in self._conn.execute(
+            "SELECT id, name, expiration FROM user_keys WHERE user_id = ?1 AND name != 'web session'", [uid]
+        ).fetchall()]
 
     @requires_conn
     def fetch_key(self, kid) -> tuple:
@@ -296,7 +295,7 @@ class Store(object):
     # A record of local files on disk, and the users who own then.
     @fmap(one)
     @requires_conn
-    def create_file(self, uid: int, name: str, path: Path) -> int:
+    def create_file(self, uid: int, name: str, path: str) -> int:
         return self._conn.execute(
             "INSERT INTO files (user_id, filename, path, upload_date) VALUES (?, ?, ?, datetime('now')) RETURNING (id)",
             [uid, name, path],
@@ -313,8 +312,8 @@ class Store(object):
         self._conn.execute("DELETE FROM files WHERE user_id = ? AND id = ?", [uid, fid])
 
     @requires_conn
-    def fetch_file(self, fid: int):
-        return self._conn.execute("SELECT * FROM files WHERE id = ?", [fid]).fetchone()
+    def fetch_file(self, uid: int, fid: int):
+        return self._conn.execute("SELECT * FROM files WHERE user_id = ?1 AND id = ?2", [uid, fid]).fetchone()
 
     ################################################################################
     # Job
@@ -346,6 +345,28 @@ class Store(object):
             f"""
             SELECT * FROM jobs
             WHERE {cond}
+            """,
+            [],
+        ).fetchall()
+
+    @requires_conn
+    def list_jobs_by_file(self, fid: int):
+        return self._conn.execute(
+            f"""
+            SELECT * FROM jobs
+            WHERE file_id = ?1
+            """,
+            [fid],
+        ).fetchall()
+
+    @requires_conn
+    def list_job_queue(self, uid: Optional[int] = None):
+        """Enumerate jobs in priority order. Note: ignores completed jobs."""
+        cond = f"user_id = {uid}" if uid else "TRUE"
+        return self._conn.execute(
+            f"""
+            SELECT * FROM jobs
+            WHERE finished_at IS NULL AND {cond}
             ORDER BY priority DESC
             """,
             [],
@@ -365,7 +386,15 @@ class Store(object):
 
     @requires_conn
     def list_running_jobs(self):
-        """Scheduler detail. List running jobs."""
+        """Scheduler detail. List running jobs.
+
+        Note that jobs for which cancellation has been requested but which HAVE
+        NOT YET BEEN ACKNOWLEDGED AS CANCELLED BY OctoPrint must still be
+        "running". This prevents the cancelling printer from being rescheduled
+        prematurely and from allows the job to be moved into the cancelled state
+        by normal printer status inspection.
+
+        """
 
         return self._conn.execute(
             """
@@ -377,6 +406,21 @@ class Store(object):
             [],
         ).fetchall()
 
+    @requires_conn
+    def list_cancelled_jobs(self):
+        """Scheduler detail. List jobs which have been cancelled but are still 'live'."""
+
+        return self._conn.execute(
+            """
+            SELECT * FROM jobs
+            WHERE started_at IS NOT NULL
+                  AND printer_id IS NOT NULL
+                  AND finished_at IS NULL
+                  AND cancelled_at IS NOT NULL
+            """,
+            [],
+        ).fetchall()
+
     @fmap(one)
     @requires_conn
     def poll_job_queue(self):
@@ -398,12 +442,26 @@ class Store(object):
             "SELECT * FROM jobs WHERE user_id = ? AND id = ?", [uid, jid]
         ).fetchone()
 
+    @requires_conn
+    def fetch_job_by_printer(self, pid: int) -> Optional[tuple]:
+        """Find 'the' mapped incomplete job for a given printer."""
+
+        return self._conn.execute(
+            "SELECT * FROM jobs WHERE printer_id = ? AND finished_at IS NULL", [pid]
+        ).fetchone()
+
     @requires_conn
     def assign_job(self, job_id: int, printer_id: int):
         return self._conn.execute(
             "UPDATE jobs SET printer_id = ?2 WHERE id = ?1", [job_id, printer_id]
         )
 
+    @requires_conn
+    def cancel_job(self, uid: int, job_id: int):
+        return self._conn.execute(
+            "UPDATE jobs SET cancelled_at = datetime('now') WHERE user_id = ?1 AND id = ?2", [uid, job_id]
+        )
+
     @requires_conn
     def start_job(self, job_id: int):
         return self._conn.execute(
@@ -411,10 +469,10 @@ class Store(object):
         )
 
     @requires_conn
-    def finish_job(self, job_id: int, state: str):
+    def finish_job(self, job_id: int, state: str, message: str = None):
         return self._conn.execute(
-            "UPDATE jobs SET finished_at = datetime('now'), state = ?2 WHERE id = ?1",
-            [job_id, state],
+            "UPDATE jobs SET finished_at = datetime('now'), state = ?2, message = ?3 WHERE id = ?1",
+            [job_id, state, message],
         )
 
     @requires_conn
diff --git a/projects/tentacles/src/python/tentacles/templates/add_printer.html.j2 b/projects/tentacles/src/python/tentacles/templates/add_printer.html.j2
index 49545b4..5074a97 100644
--- a/projects/tentacles/src/python/tentacles/templates/add_printer.html.j2
+++ b/projects/tentacles/src/python/tentacles/templates/add_printer.html.j2
@@ -1,16 +1,30 @@
 {% extends "base.html.j2" %}
 {% block content %}
-<div class="container">
-  <h1>Add printer</h1>
-  <form class="row" method="post">
-    <span class="form-input row"><span class="form-label">Printer name</span><input type="text" name="name" /></span>
-    <span class="form-input row"><span class="form-label">Printer API URL</span><input type="text" name="url" /></span>
-    <span class="form-input row"><span class="form-label">API key</span><input type="text" name="api_key" /></span>
-    <input type="hidden" name="tested" value="false" />
-    <span class="row">
-      <span><input id="test" type="button" value="Test" enabled="false" /></span>
-      <span><input id="submit" type="submit" value="Add" onclick="maybeSubmit();" /></span>
-    </span>
+<h1>Add printer</h1>
+<div class="row">
+  <form method="post">
+    <div class="row">
+      <div class="twelve columns">
+        <label for="name">Printer name</label>
+        <input type="text" name="name" />
+      </div>
+    </div>
+
+    <div class="row">
+      <div class="six columns">
+        <label for="url">Printer base URL</label>
+        <input type="text" name="url" />
+      </div>
+      <div class="six columns">
+        <label for="api_key">API key</label>
+        <input type="text" name="api_key" />
+      </div>
+    </div>
+    <div class="row">
+      <input type="hidden" name="tested" value="false" />
+      <input id="test" type="button" value="Test" enabled="false" />
+      <input id="submit" type="submit" value="Add" onclick="maybeSubmit();" />
+    </div>
   </form>
 </div>
 
diff --git a/projects/tentacles/src/python/tentacles/templates/base.html.j2 b/projects/tentacles/src/python/tentacles/templates/base.html.j2
index 197d8ee..e30d294 100644
--- a/projects/tentacles/src/python/tentacles/templates/base.html.j2
+++ b/projects/tentacles/src/python/tentacles/templates/base.html.j2
@@ -10,9 +10,9 @@
     {% endblock %}
 </head>
 <body>
-  <nav class="navbar">
-    <span class="logo">
-      <a class="row"href="/">
+  <nav class="container navbar">
+    <span class="logo six columns">
+      <a class="row" href="/">
         <img src="/static/tentacles.svg" alt="Tentacles">
         <span class="color-yellow">Tentacles</span>
       </a>
@@ -24,21 +24,23 @@
       </div>
     </label>
 
-    <ul class="menu">
+    <ul class="menu container">
       {% if not ctx.uid %}
-      <li><a href="/user/login"><span class="button slide">Log in</span></a></li>
-      <li><a href="/user/register"><span class="button slide">Register<span></a></li>
+      <li><a class="twelve columns slide" href="/user/login">Log in</a></li>
+      <li><a class="twelve columns slide" href="/user/register">Register</a></li>
       {% else %}
+      <li><a class="twelve columns button slide" href="/jobs">Jobs</a></li>
+      <li><a class="twelve columns button slide" href="/files">Files</a></li>
       {% if ctx.is_admin %}
-      <li><a href="/printers"><span class="button slide">Printers</span></a></li>
+      <li><a class="twelve columns button slide" href="/printers">Printers</a></li>
       {% endif %}
-      <li><a href="/user"><span class="button slide">Settings</span></a></li>
-      <li><a href="/user/logout"><span class="button slide">Log out</span></a></li>
+      <li><a class="twelve columns button slide" href="/user">Settings</a></li>
+      <li><a class="twelve columns button slide" href="/user/logout">Log out</a></li>
       {% endif %}
     </ul>
   </nav>
   </div>
-  <div class="content">
+  <div class="container content">
     {% with messages = get_flashed_messages(with_categories=True) %}
     {% if messages %}
     <div class="flashes">
@@ -60,7 +62,7 @@
 
     {% block content %}Oops, an empty page :/{% endblock %}
   </div>
-  <div class="footer">
+  <div class="container footer">
     {% block footer %}
       &copy; Copyright 2023 by <a href="https://arrdem.com/">@arrdem</a>.
     {% endblock %}
diff --git a/projects/tentacles/src/python/tentacles/templates/files.html.j2 b/projects/tentacles/src/python/tentacles/templates/files.html.j2
new file mode 100644
index 0000000..4d3a2ac
--- /dev/null
+++ b/projects/tentacles/src/python/tentacles/templates/files.html.j2
@@ -0,0 +1,21 @@
+{% extends "base.html.j2" %}
+{% block content %}
+<div class="row twelve columns">
+  {% include "files_list.html.j2" %}
+</div>
+
+<div class="row twelve columns">
+  <h2>Upload a file</h2>
+  <form method="post" action="/files" enctype="multipart/form-data">
+    <input type="hidden" name="action" value="upload" />
+    <input type="hidden" name="select" value="false" />
+    <input type="hidden" name="start" value="false" />
+    <div class="row">
+      <input type="file" name="file" accept=".gcode,text/plain" />
+    </div>
+    <div class="row">
+      <input id="submit" type="submit" value="Upload"/>
+    </div>
+  </form>
+</div>
+{% endblock %}
diff --git a/projects/tentacles/src/python/tentacles/templates/files_list.html.j2 b/projects/tentacles/src/python/tentacles/templates/files_list.html.j2
index 138a58f..9d66cff 100644
--- a/projects/tentacles/src/python/tentacles/templates/files_list.html.j2
+++ b/projects/tentacles/src/python/tentacles/templates/files_list.html.j2
@@ -1,31 +1,21 @@
 {% import "macros.html.j2" as macros %}
-<div class="panel files">
+<div class="files">
   <h2>Files</h2>
   {% with files = ctx.db.list_files(uid=ctx.uid) %}
     {% if files %}
-      <ul>
-        {% for file in files %}
-          <li class="file">
+      {% for file in files %}
+        <div class="file row u-flex">
+          <div class="details six columns">
             <span class="file-name">{{ file.filename }}</span>
-            <span class="file-controls">
-              {{ macros.start_job(file.id) }}
-              {{ macros.delete_file(file.id) }}
-            </span>
-          </li>
-        {% endfor %}
-      </ul>
+          </div>
+          <div class="controls u-flex u-ml-auto">
+            {{ macros.start_job(file.id) }}
+            {{ macros.delete_file(file.id) }}
+          </div>
+        </div>
+      {% endfor %}
     {% else %}
       You don't have any files. Upload something!
     {% endif %}
   {% endwith %}
 </div>
-
-<div class="panel file-upload">
-  <h2>Upload a file</h2>
-  <form method="post" action="/api/files/local" enctype="multipart/form-data">
-    <input type="hidden" name="select" value="false" />
-    <input type="hidden" name="start" value="false" />
-    <input type="file" name="file" accept=".gcode,text/plain" />
-    <span><input id="submit" type="submit" value="Upload"/></span>
-  </form>
-</div>
diff --git a/projects/tentacles/src/python/tentacles/templates/index.html.j2 b/projects/tentacles/src/python/tentacles/templates/index.html.j2
index 7bf7914..5f3263d 100644
--- a/projects/tentacles/src/python/tentacles/templates/index.html.j2
+++ b/projects/tentacles/src/python/tentacles/templates/index.html.j2
@@ -1,8 +1,12 @@
 {% extends "base.html.j2" %}
 {% block content %}
+<div class="row twelve columns">
   {% include "jobs_list.html.j2" %}
+</div>
 
-  {% if ctx.uid %}
+{% if ctx.uid %}
+<div class="row twelve columns">
   {% include "files_list.html.j2" %}
-  {% endif %}
+</div>
+{% endif %}
 {% endblock %}
diff --git a/projects/tentacles/src/python/tentacles/templates/jobs.html.j2 b/projects/tentacles/src/python/tentacles/templates/jobs.html.j2
index b45ef01..ad0330e 100644
--- a/projects/tentacles/src/python/tentacles/templates/jobs.html.j2
+++ b/projects/tentacles/src/python/tentacles/templates/jobs.html.j2
@@ -1,4 +1,5 @@
 {% extends "base.html.j2" %}
 {% block content %}
 {% include "jobs_list.html.j2" %}
+{% include "jobs_history.html.j2" %}
 {% endblock %}
diff --git a/projects/tentacles/src/python/tentacles/templates/jobs_history.html.j2 b/projects/tentacles/src/python/tentacles/templates/jobs_history.html.j2
new file mode 100644
index 0000000..b745a7b
--- /dev/null
+++ b/projects/tentacles/src/python/tentacles/templates/jobs_history.html.j2
@@ -0,0 +1,24 @@
+{% import "macros.html.j2" as macros %}
+<div class="history">
+  <h2>Job history</h2>
+  {% with jobs = ctx.db.list_jobs(uid=ctx.uid) %}
+  {% if jobs %}
+  {% for job in jobs if job.finished_at %}
+    <div class="job row u-flex">
+      <div class="details">
+        <span class="job-status one column">
+          <div class="dot {{ macros.job_state(job) }} {{ 'dot--basic' if not job.state else '' }}" style="--dot-size: 1em;">
+          </div>
+        </span>
+        <span class="job-filename eleven columns">{{ctx.db.fetch_file(ctx.uid, job.file_id).filename}}</span>
+      </div>
+      <div class="controls u-flex u-ml-auto">
+        {{ macros.delete_job(job.id) }}
+      </div>
+    </div>
+  {% endfor %}
+  {% else %}
+    <p>No job history to display.</p>
+  {% endif %}
+  {% endwith %}
+</div>
diff --git a/projects/tentacles/src/python/tentacles/templates/jobs_list.html.j2 b/projects/tentacles/src/python/tentacles/templates/jobs_list.html.j2
index 5c932a8..ccef9d4 100644
--- a/projects/tentacles/src/python/tentacles/templates/jobs_list.html.j2
+++ b/projects/tentacles/src/python/tentacles/templates/jobs_list.html.j2
@@ -1,18 +1,27 @@
-<div class="panel queue">
+{% import "macros.html.j2" as macros %}
+<div class="queue">
   <h2>Job queue</h2>
-  {% with jobs = ctx.db.list_jobs(uid=ctx.uid) %}
-    {% if jobs %}
-      <ul>
-        {% for job in jobs %}
-          <li class="job">
-            <span class="job-id">{{job.id}}</span>
-            <span class="job-filename">{{ctx.db.fetch_file(job.file_id).filename}}</span>
-            <span class="job-status">{{ 'pending' if not job.printer_id else 'uploading' if not job.started_at else 'running' if not job.finished_at else job.state }}
-          </li>
-        {% endfor %}
-      </ul>
-    {% else %}
-      No pending tasks. {% if ctx.uid %}Start something!{% endif %}
-    {% endif %}
-  {% endwith %}
+  {% with jobs = ctx.db.list_job_queue(uid=ctx.uid) %}
+  {% if jobs %}
+    {% for job in jobs %}
+      <div class="job row u-flex">
+        <div class="details seven colums">
+          <div class="job-status one column">
+            <div class="dot {{ macros.job_state(job) }} {{ 'dot--basic' if not job.state else '' }}" style="--dot-size: 1em;">
+            </div>
+          </div>
+          <div class="job-filename six columns">
+            {{ctx.db.fetch_file(ctx.uid, job.file_id).filename}}
+          </div>
+        </div>
+        <div class="controls u-flex u-ml-auto">
+          {{ macros.duplicate_job(job.id) }}
+          {{ macros.cancel_job(job.id) }}
+        </div>
+      </div>
+    {% endfor %}
+  {% else %}
+    No pending tasks. {% if ctx.uid %}Start something!{% endif %}
+  {% endif %}
+{% endwith %}
 </div>
diff --git a/projects/tentacles/src/python/tentacles/templates/macros.html.j2 b/projects/tentacles/src/python/tentacles/templates/macros.html.j2
index 11d8182..dd6d8d1 100644
--- a/projects/tentacles/src/python/tentacles/templates/macros.html.j2
+++ b/projects/tentacles/src/python/tentacles/templates/macros.html.j2
@@ -1,15 +1,47 @@
+{# #################################################################################################### #}
+{# Job CRUD #}
 {% macro start_job(id) %}
-<form method="post" action="/jobs">
-  <input type="hidden" name="action" value="queue" />
+<form class="inline" method="post" action="/jobs">
+  <input type="hidden" name="action" value="enqueue" />
   <input type="hidden" name="file_id" value="{{ id }}" />
-  <span><input id="submit" type="submit" value="Queue"/></span>
+  <input id="submit" type="submit" value="Enqueue"/>
 </form>
 {% endmacro %}
 
-{% macro delete_file(id) %}
-<form method="post" action="/files">
-  <input type="hidden" name="action" value="delete" />
-  <input type="hidden" name="id" value="{{ id }}" />
-  <span><input id="submit" type="submit" value="Delete"/></span>
+{% macro duplicate_job(id) %}
+<form class="inline" method="post" action="/jobs">
+  <input type="hidden" name="action" value="duplicate" />
+  <input type="hidden" name="job_id" value="{{ id }}" />
+  <input id="submit" type="submit" value="Duplicate"/>
+</form>
+{% endmacro %}
+
+{% macro cancel_job(id) %}
+<form class="inline" method="post" action="/jobs">
+  <input type="hidden" name="action" value="cancel" />
+  <input type="hidden" name="job_id" value="{{ id }}" />
+  <input id="submit" type="submit" value="Cancel"/>
+</form>
+{% endmacro %}
+
+{% macro delete_job(id) %}
+<form class="inline" method="post" action="/jobs">
+  <input type="hidden" name="action" value="delete" />
+  <input type="hidden" name="job_id" value="{{ id }}" />
+  <input id="submit" type="submit" value="Delete"/>
+</form>
+{% endmacro %}
+
+{% macro job_state(job) %}
+{{ 'queued' if (not job.finished_at and not job.printer_id) else 'running' if not job.finished_at else job.state }}
+{% endmacro %}
+
+{# #################################################################################################### #}
+{# File CRUD #}
+{% macro delete_file(id) %}
+<form class="inline" method="post" action="/files">
+  <input type="hidden" name="action" value="delete" />
+  <input type="hidden" name="file_id" value="{{ id }}" />
+  <input id="submit" type="submit" value="Delete"/>
 </form>
 {% endmacro %}
diff --git a/projects/tentacles/src/python/tentacles/templates/printers.html.j2 b/projects/tentacles/src/python/tentacles/templates/printers.html.j2
index 1081f64..048332d 100644
--- a/projects/tentacles/src/python/tentacles/templates/printers.html.j2
+++ b/projects/tentacles/src/python/tentacles/templates/printers.html.j2
@@ -1,4 +1,6 @@
 {% extends "base.html.j2" %}
 {% block content %}
-{% include "printers_list.html.j2" %}
+<div class="twelve columns">
+  {% include "printers_list.html.j2" %}
+</div>
 {% endblock %}
diff --git a/projects/tentacles/src/python/tentacles/templates/printers_list.html.j2 b/projects/tentacles/src/python/tentacles/templates/printers_list.html.j2
index 334d08c..ca6dfba 100644
--- a/projects/tentacles/src/python/tentacles/templates/printers_list.html.j2
+++ b/projects/tentacles/src/python/tentacles/templates/printers_list.html.j2
@@ -2,25 +2,27 @@
   <h2>Printers</h2>
   {% with printers = ctx.db.list_printers() %}
     {% if printers %}
-      <ul>
-        {% for printer in printers %}
-          {% with id, name, url, _api_key, last_poll, status = printer %}
-            <li class="printer row">
+      {% for printer in printers %}
+        {% with id, name, url, _api_key, last_poll, status = printer %}
+          <div class="printer row u-flex">
+            <div class="details six columns">
               <span class="printer-name">{{name}}</span>
               <span class="printer-url"><code>{{url}}</code></span>
               <span class="printer-status">{{status}}</span>
               <span class="printer-date">{{last_poll}}</span>
-              {# FIXME: How should these action buttons work? #}
-              <span class="printer-controls ml-auto">
-                <a class="button" href="/printers/test?id={{id}}">Test</a>
-                <a class="button" href="/printers/edit?id={{id}}">Edit</a>
-                <a class="button" href="/printers/delete?id={{id}}">Remove</a>
-              </span>
-            </li>
-          {% endwith %}
-        {% endfor %}
-      </ul>
-      {% if ctx.is_admin %}<a class="button" href="/printers/add">Add a printer</a>{% endif %}
+            </div>
+            {# FIXME: How should these action buttons work? #}
+            <div class="controls u-flex u-ml-auto">
+              <a class="button" href="/printers/test?id={{id}}">Test</a>
+              <a class="button" href="/printers/edit?id={{id}}">Edit</a>
+              <a class="button" href="/printers/delete?id={{id}}">Remove</a>
+            </div>
+          </div>
+        {% endwith %}
+      {% endfor %}
+      {% if ctx.is_admin %}
+        <a class="button" href="/printers/add">Add a printer</a>
+      {% endif %}
     {% else %}
       No printers available. {% if ctx.is_admin %}<a href="/printers/add">Configure one!</a>{% else %}Ask the admin to configure one!{% endif %}
     {% endif %}
diff --git a/projects/tentacles/src/python/tentacles/templates/user.html.j2 b/projects/tentacles/src/python/tentacles/templates/user.html.j2
index c209456..28c8998 100644
--- a/projects/tentacles/src/python/tentacles/templates/user.html.j2
+++ b/projects/tentacles/src/python/tentacles/templates/user.html.j2
@@ -1,45 +1,53 @@
 {% extends "base.html.j2" %}
 {% block content %}
 <h1>User settings</h1>
-<div class="keys container">
+<div class="row twelve columns keys">
   <h2>API keys</h2>
   {% with keys = ctx.db.list_keys(ctx.uid) %}
-  <ul>
-    {% for id, name, exp in keys if name != 'web session' %}
-    <li class="row">
-      <div class="row key">
-        <div class="row">
-          <span class="key-name">{{ name or 'anonymous' }}</span>
-          <span class="key-expiration ml-auto">{{ 'Expires in ' if exp else ''}}{{ exp - datetime.now() if exp else 'Never expires' }}</span>
-        </div>
-        <div class="row">
-          <span class="key-key">{{ id[:10] }}...</span>
-          <form method="post" class="ml-auto">
-            <input type="hidden" name="action" value="revoke">
-            <input type="hidden" name="id" value="{{ id }}">
-            <span><input id="submit" type="submit" value="Revoke"/></span>
-          </form>
-        </div>
+  {% if keys %}
+  {% for id, name, exp in keys %}
+    <div class="row key u-flex">
+      <div class="details six columns">
+        <span class="key-name">{{ name or 'anonymous' }}</span>
+        <span class="key-key">{{ id[:10] }}...</span>
+        <span class="key-expiration u-ml-auto">{{ 'Expires in ' if exp else ''}}{{ exp - datetime.now() if exp else 'Never expires' }}</span>
       </div>
-    </li>
-    {% endfor %}
-  </ul>
+      <div class="controls u-flex u-ml-auto">
+        <form class="inline" method="post" class="ml-auto">
+          <input type="hidden" name="action" value="revoke">
+          <input type="hidden" name="id" value="{{ id }}">
+          <input id="submit" type="submit" value="Revoke"/>
+        </form>
+      </div>
+    </div>
+  {% endfor %}
+  {% else %}
+    <p>No API keys configured.</p>
+  {% endif %}
   {% endwith %}
 </div>
-<div class="keys-form container">
+<div class="row twelve columns keys">
   <h2>Add a key</h2>
   <form method="post">
     <input type="hidden" name="action" value="add">
-    <span class="form-input mr-auto row"><span class="form-label">API key name</span><input type="text" name="name" /></span>
-    <span class="form-input mr-auto row"><span class="form-label">Key lifetime</span>
-      <select name="ttl">
-        <option value="30d">30 days</option>
-        <option value="90d">90 days</option>
-        <option value="365d">1y</option>
-        <option value="forever">Forever (not recommended)</option>
-      </select>
-    </span>
-    <span class="form-input mr-auto row"><input id="submit" type="submit" value="Add" onclick="maybeSubmit();" /></span>
+    <div class="row">
+      <div class="row six columns">
+        <label for="name">API key name</label>
+        <input class="u-full-width" type="text" name="name" value="anonymous" />
+      </div>
+      <div class="row six columns">
+        <label for="ttl">Key lifetime</label>
+        <select name="ttl">
+          <option value="30d">30 days</option>
+          <option value="90d">90 days</option>
+          <option value="365d">1y</option>
+          <option value="forever">Forever (not recommended)</option>
+        </select>
+      </div>
+    </div>
+    <div class="row">
+      <input id="submit" type="submit" value="Add" />
+    </div>
   </form>
 </div>
 {% endblock %}
diff --git a/projects/tentacles/src/python/tentacles/workers.py b/projects/tentacles/src/python/tentacles/workers.py
index 3dc233f..edb72a6 100644
--- a/projects/tentacles/src/python/tentacles/workers.py
+++ b/projects/tentacles/src/python/tentacles/workers.py
@@ -7,19 +7,23 @@ Supporting the core app with asynchronous maintenance tasks.
 Mostly related to monitoring and managing Printer state.
 """
 
-from time import sleep
-from threading import Thread, Event
-from typing import Callable
-from datetime import timedelta, datetime
-import logging
 from contextlib import closing
-from urllib import parse as urlparse
-from tentacles.store import Store
+from datetime import datetime, timedelta
+import logging
 from pathlib import Path
+from threading import Event, Thread
+from time import sleep
+from typing import Callable
+from urllib import parse as urlparse
 
 from octorest import OctoRest as _OR
 from requests import Response
-from requests.exceptions import HTTPError, ConnectionError, Timeout
+from requests.exceptions import (
+    ConnectionError,
+    HTTPError,
+    Timeout,
+)
+from tentacles.store import Store
 
 
 class OctoRest(_OR):
@@ -67,25 +71,41 @@ def poll_printers(db_factory: Callable[[], Store]) -> None:
     with closing(db_factory()) as db:
         for printer in db.list_printers():
             id, name, url, api_key, last_poll, status = printer
+            mapped_job = db.fetch_job_by_printer(id)
             try:
                 client = OctoRest(url=url, apikey=api_key)
-                job = client.job_info()
-                state = client.printer().get("state").get("flags", {})
-                if state.get("error"):
+                printer_job = client.job_info()
+                print(printer_job)
+                try:
+                    printer_state = client.printer().get("state").get("flags", {})
+                except:
+                    printer_state = {"error": printer_job.get("error")}
+
+                if printer_state.get("error"):
+                    # If there's a mapped job, we manually fail it so that
+                    # there's no possibility of a sync problem between the
+                    # polling tasks. This violates separation of concerns a bit,
+                    # but appears required for correctness.
+                    if mapped_job:
+                        db.finish_job(mapped_job.id, "error")
+
                     db.update_printer_status(id, "error")
-                elif state.get("ready"):
+
+                elif printer_state.get("ready"):
                     db.update_printer_status(id, "idle")
-                elif state.get("printing"):
+
+                elif printer_state.get("printing"):
                     db.update_printer_status(id, "running")
+
                 else:
-                    raise Exception(f"Indeterminate state {state!r}")
+                    raise Exception(f"Indeterminate state {printer_state!r}")
 
             except (ConnectionError, Timeout):
                 db.update_printer_status(id, "error")
 
             except HTTPError as e:
                 assert isinstance(e.response, Response)
-                if e.response.status_code in [403, 401] or "error" in job:
+                if e.response.status_code in [403, 401] or "error" in printer_job:
                     db.update_printer_status(id, "error")
                 elif e.response.json().get("error") == "Printer is not operational":
                     db.update_printer_status(id, "disconnected")
@@ -113,7 +133,11 @@ def push_jobs(db_factory: Callable[[], Store]) -> None:
     with closing(db_factory()) as db:
         for job in db.list_mapped_jobs():
             printer = db.fetch_printer(job.printer_id)
-            file = db.fetch_file(job.file_id)
+            file = db.fetch_file(job.user_id, job.file_id)
+            if not file:
+                log.error(f"Job {job.id} no longer maps to a file")
+                db.delete_job(job.user_id, job.id)
+
             try:
                 client = OctoRest(url=printer.url, apikey=printer.api_key)
                 try:
@@ -134,6 +158,23 @@ def push_jobs(db_factory: Callable[[], Store]) -> None:
                 log.exception("Oop")
 
 
+@corn_job(timedelta(seconds=5))
+def revoke_jobs(db_factory: Callable[[], Store]) -> None:
+    """Ensure that job files are uploaded and started to the assigned printer."""
+
+    with closing(db_factory()) as db:
+        for job in db.list_cancelled_jobs():
+            printer = db.fetch_printer(job.printer_id)
+            try:
+                client = OctoRest(url=printer.url, apikey=printer.api_key)
+                client.cancel()
+            except TimeoutError:
+                pass
+
+            except Exception:
+                log.exception("Oop")
+
+
 @corn_job(timedelta(seconds=5))
 def pull_jobs(db_factory: Callable[[], Store]) -> None:
     """Poll the state of mapped printers to control jobs."""
@@ -150,6 +191,7 @@ def create_workers(db_factory: Callable[[], Store]) -> Event:
     Thread(target=poll_printers, args=[db_factory]).start()
     Thread(target=assign_jobs, args=[db_factory]).start()
     Thread(target=push_jobs, args=[db_factory]).start()
+    Thread(target=revoke_jobs, args=[db_factory]).start()
     Thread(target=pull_jobs, args=[db_factory]).start()
 
     return SHUTDOWN
diff --git a/projects/tentacles/test/python/conftest.py b/projects/tentacles/test/python/conftest.py
index 19ed4c6..dac9fa3 100644
--- a/projects/tentacles/test/python/conftest.py
+++ b/projects/tentacles/test/python/conftest.py
@@ -1,9 +1,9 @@
 #!/usr/bin/env python3
 
 from datetime import timedelta
-import tentacles.store as s
 
 import pytest
+import tentacles.store as s
 
 
 @pytest.yield_fixture
diff --git a/projects/tentacles/test/python/test_store.py b/projects/tentacles/test/python/test_store.py
index d79d390..639958a 100644
--- a/projects/tentacles/test/python/test_store.py
+++ b/projects/tentacles/test/python/test_store.py
@@ -2,8 +2,6 @@
 
 from tentacles.store import Store
 
-import pytest
-
 
 def test_store_initializes(store: Store):
     assert isinstance(store, Store)