UI overhaul; job cancellation

This commit is contained in:
Reid 'arrdem' McKenzie 2023-05-28 17:21:05 -06:00
parent 2fde40d1ab
commit 5546934fae
35 changed files with 1755 additions and 708 deletions

View file

@ -2,15 +2,21 @@
"""The core app entrypoint.""" """The core app entrypoint."""
from datetime import datetime
from pathlib import Path from pathlib import Path
import tomllib
import click import click
from flask import Flask, request from flask import Flask, request
import tomllib from tentacles.blueprints import (
from datetime import datetime api,
file_ui,
from tentacles.blueprints import user_ui, printer_ui, job_ui, file_ui, api job_ui,
from tentacles.store import Store printer_ui,
user_ui,
)
from tentacles.globals import _ctx, Ctx, ctx from tentacles.globals import _ctx, Ctx, ctx
from tentacles.store import Store
from tentacles.workers import create_workers from tentacles.workers import create_workers

View file

@ -2,14 +2,17 @@
"""API endpoints supporting the 'ui'.""" """API endpoints supporting the 'ui'."""
from hashlib import sha3_256
import os import os
from typing import Optional 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.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") BLUEPRINT = Blueprint("api", __name__, url_prefix="/api")
@ -51,11 +54,6 @@ def delete_printer():
def create_file(location: Optional[str] = None): 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. # 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: if "file" not in request.files:
return {"status": "error", "error": "No file to upload"}, 400 return {"status": "error", "error": "No file to upload"}, 400

View file

@ -1,31 +1,58 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import logging import logging
from datetime import timedelta
import re
from tentacles.globals import ctx from tentacles.globals import ctx
from .util import requires_auth from .util import requires_auth
from .api import create_file
from flask import ( from flask import (
Blueprint, Blueprint,
current_app, flash,
render_template,
request, request,
redirect, redirect,
render_template,
flash,
) )
import os
from .util import salt, is_logged_in
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
BLUEPRINT = Blueprint("files", __name__) BLUEPRINT = Blueprint("files", __name__)
@requires_auth @requires_auth
@BLUEPRINT.route("/files", methods=["GET", "POST"]) @BLUEPRINT.route("/files", methods=["GET"])
def files(): def list_files():
if request.method == "POST": if request.method == "POST":
flash("Not supported yet", category="warning") flash("Not supported yet", category="warning")
return render_template("files.html.j2") 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")

View file

@ -1,32 +1,55 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import logging import logging
from datetime import timedelta
import re
from tentacles.globals import ctx
from .util import requires_auth from .util import requires_auth
from flask import ( from flask import (
Blueprint, Blueprint,
current_app,
request,
redirect,
render_template,
flash, flash,
redirect,
request,
render_template,
) )
from tentacles.globals import ctx
from .util import salt, is_logged_in
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
BLUEPRINT = Blueprint("jobs", __name__) BLUEPRINT = Blueprint("jobs", __name__)
@requires_auth @requires_auth
@BLUEPRINT.route("/jobs", methods=["GET", "POST"]) @BLUEPRINT.route("/jobs", methods=["GET"])
def jobs(): def list_jobs():
if request.method == "POST": return render_template("jobs.html.j2")
@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"))) ctx.db.create_job(ctx.uid, int(request.form.get("file_id")))
flash("Job created!", category="info") flash("Job created!", category="info")
return redirect("/") 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")

View file

@ -2,16 +2,17 @@
import logging import logging
from .util import is_logged_in, requires_admin
from flask import ( from flask import (
Blueprint, Blueprint,
request, flash,
redirect, redirect,
render_template, render_template,
flash, request,
) )
from tentacles.globals import ctx from tentacles.globals import ctx
from .util import is_logged_in, requires_admin
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
BLUEPRINT = Blueprint("printer", __name__) BLUEPRINT = Blueprint("printer", __name__)

View file

@ -1,21 +1,21 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import logging
from datetime import timedelta from datetime import timedelta
import logging
import re import re
from tentacles.globals import ctx from .util import is_logged_in, salt
from flask import ( from flask import (
Blueprint, Blueprint,
current_app, current_app,
request, flash,
redirect, redirect,
render_template, render_template,
flash, request,
) )
from tentacles.globals import ctx
from .util import salt, is_logged_in
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
BLUEPRINT = Blueprint("user", __name__) BLUEPRINT = Blueprint("user", __name__)

View file

@ -2,15 +2,10 @@
import logging import logging
from flask import current_app, flash, redirect
from flask import (
current_app,
redirect,
flash,
)
from tentacles.globals import ctx from tentacles.globals import ctx
log = logging.getLogger(__name__) log = logging.getLogger(__name__)

View file

@ -85,8 +85,10 @@ CREATE TABLE IF NOT EXISTS jobs (
, file_id INTEGER NOT NULL , file_id INTEGER NOT NULL
, priority INTEGER CHECK(priority IS NOT NULL AND 0 <= priority) , priority INTEGER CHECK(priority IS NOT NULL AND 0 <= priority)
, started_at TEXT , started_at TEXT
, cancelled_at TEXT
, finished_at TEXT , finished_at TEXT
, state TEXT , state TEXT
, message TEXT
, printer_id INTEGER , printer_id INTEGER
, FOREIGN KEY(user_id) REFERENCES users(id) , FOREIGN KEY(user_id) REFERENCES users(id)
, FOREIGN KEY(file_id) REFERENCES files(id) , FOREIGN KEY(file_id) REFERENCES files(id)

View file

@ -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;
}

View file

@ -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) {}

View file

@ -1,188 +1,5 @@
$black: #171426; @import "tirefire/colors";
$beige: #F4F8EE; @import "tirefire/fonts";
$red: #BB2D2E;
$orange: #CA4F1F;
$yellow: #EDB822;
$clear: rgba(255, 255, 255, 255);
$secondary_red: red; @import "tirefire/alerts";
$secondary_blue: #288BC2; @import "tirefire/timers";
$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;
}

View file

@ -1,328 +1,26 @@
@import "tirefire"; @import "normalize";
@import "skeleton";
.color-yellow { // And the TireFire® bits
color: $yellow; @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 { .file,
font-family: 'Aaux Next', sans-serif; .printer,
background-color: $beige; .key,
color: $black; .job {
margin-top: 4px;
} }
html, body { .details {
margin: 0; overflow: hidden;
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;
} }

View file

@ -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;
}
}

View file

@ -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;
}

View file

@ -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;

View file

@ -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;
}
}

View file

@ -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);

View file

@ -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;
}
//}

View file

@ -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;
}

View file

@ -4,7 +4,6 @@ from collections import namedtuple
from datetime import datetime, timedelta from datetime import datetime, timedelta
from hashlib import sha3_256 from hashlib import sha3_256
from importlib.resources import files from importlib.resources import files
from pathlib import Path
import sqlite3 import sqlite3
from textwrap import indent from textwrap import indent
from typing import Optional from typing import Optional
@ -187,10 +186,10 @@ class Store(object):
@requires_conn @requires_conn
def list_keys(self, uid: int): def list_keys(self, uid: int):
return [(id, name, datetime.fromisoformat(exp) if exp else None)
for id, name, exp in self._conn.execute( for id, name, exp in self._conn.execute(
"SELECT id, name, expiration FROM user_keys WHERE user_id = ?1", [uid] "SELECT id, name, expiration FROM user_keys WHERE user_id = ?1 AND name != 'web session'", [uid]
).fetchall(): ).fetchall()]
yield id, name, datetime.fromisoformat(exp) if exp else None
@requires_conn @requires_conn
def fetch_key(self, kid) -> tuple: 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. # A record of local files on disk, and the users who own then.
@fmap(one) @fmap(one)
@requires_conn @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( return self._conn.execute(
"INSERT INTO files (user_id, filename, path, upload_date) VALUES (?, ?, ?, datetime('now')) RETURNING (id)", "INSERT INTO files (user_id, filename, path, upload_date) VALUES (?, ?, ?, datetime('now')) RETURNING (id)",
[uid, name, path], [uid, name, path],
@ -313,8 +312,8 @@ class Store(object):
self._conn.execute("DELETE FROM files WHERE user_id = ? AND id = ?", [uid, fid]) self._conn.execute("DELETE FROM files WHERE user_id = ? AND id = ?", [uid, fid])
@requires_conn @requires_conn
def fetch_file(self, fid: int): def fetch_file(self, uid: int, fid: int):
return self._conn.execute("SELECT * FROM files WHERE id = ?", [fid]).fetchone() return self._conn.execute("SELECT * FROM files WHERE user_id = ?1 AND id = ?2", [uid, fid]).fetchone()
################################################################################ ################################################################################
# Job # Job
@ -346,6 +345,28 @@ class Store(object):
f""" f"""
SELECT * FROM jobs SELECT * FROM jobs
WHERE {cond} 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 ORDER BY priority DESC
""", """,
[], [],
@ -365,7 +386,15 @@ class Store(object):
@requires_conn @requires_conn
def list_running_jobs(self): 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( return self._conn.execute(
""" """
@ -377,6 +406,21 @@ class Store(object):
[], [],
).fetchall() ).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) @fmap(one)
@requires_conn @requires_conn
def poll_job_queue(self): def poll_job_queue(self):
@ -398,12 +442,26 @@ class Store(object):
"SELECT * FROM jobs WHERE user_id = ? AND id = ?", [uid, jid] "SELECT * FROM jobs WHERE user_id = ? AND id = ?", [uid, jid]
).fetchone() ).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 @requires_conn
def assign_job(self, job_id: int, printer_id: int): def assign_job(self, job_id: int, printer_id: int):
return self._conn.execute( return self._conn.execute(
"UPDATE jobs SET printer_id = ?2 WHERE id = ?1", [job_id, printer_id] "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 @requires_conn
def start_job(self, job_id: int): def start_job(self, job_id: int):
return self._conn.execute( return self._conn.execute(
@ -411,10 +469,10 @@ class Store(object):
) )
@requires_conn @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( return self._conn.execute(
"UPDATE jobs SET finished_at = datetime('now'), state = ?2 WHERE id = ?1", "UPDATE jobs SET finished_at = datetime('now'), state = ?2, message = ?3 WHERE id = ?1",
[job_id, state], [job_id, state, message],
) )
@requires_conn @requires_conn

View file

@ -1,16 +1,30 @@
{% extends "base.html.j2" %} {% extends "base.html.j2" %}
{% block content %} {% block content %}
<div class="container">
<h1>Add printer</h1> <h1>Add printer</h1>
<form class="row" method="post"> <div class="row">
<span class="form-input row"><span class="form-label">Printer name</span><input type="text" name="name" /></span> <form method="post">
<span class="form-input row"><span class="form-label">Printer API URL</span><input type="text" name="url" /></span> <div class="row">
<span class="form-input row"><span class="form-label">API key</span><input type="text" name="api_key" /></span> <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 type="hidden" name="tested" value="false" />
<span class="row"> <input id="test" type="button" value="Test" enabled="false" />
<span><input id="test" type="button" value="Test" enabled="false" /></span> <input id="submit" type="submit" value="Add" onclick="maybeSubmit();" />
<span><input id="submit" type="submit" value="Add" onclick="maybeSubmit();" /></span> </div>
</span>
</form> </form>
</div> </div>

View file

@ -10,8 +10,8 @@
{% endblock %} {% endblock %}
</head> </head>
<body> <body>
<nav class="navbar"> <nav class="container navbar">
<span class="logo"> <span class="logo six columns">
<a class="row" href="/"> <a class="row" href="/">
<img src="/static/tentacles.svg" alt="Tentacles"> <img src="/static/tentacles.svg" alt="Tentacles">
<span class="color-yellow">Tentacles</span> <span class="color-yellow">Tentacles</span>
@ -24,21 +24,23 @@
</div> </div>
</label> </label>
<ul class="menu"> <ul class="menu container">
{% if not ctx.uid %} {% if not ctx.uid %}
<li><a href="/user/login"><span class="button slide">Log in</span></a></li> <li><a class="twelve columns slide" href="/user/login">Log in</a></li>
<li><a href="/user/register"><span class="button slide">Register<span></a></li> <li><a class="twelve columns slide" href="/user/register">Register</a></li>
{% else %} {% 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 %} {% 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 %} {% endif %}
<li><a href="/user"><span class="button slide">Settings</span></a></li> <li><a class="twelve columns button slide" href="/user">Settings</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/logout">Log out</a></li>
{% endif %} {% endif %}
</ul> </ul>
</nav> </nav>
</div> </div>
<div class="content"> <div class="container content">
{% with messages = get_flashed_messages(with_categories=True) %} {% with messages = get_flashed_messages(with_categories=True) %}
{% if messages %} {% if messages %}
<div class="flashes"> <div class="flashes">
@ -60,7 +62,7 @@
{% block content %}Oops, an empty page :/{% endblock %} {% block content %}Oops, an empty page :/{% endblock %}
</div> </div>
<div class="footer"> <div class="container footer">
{% block footer %} {% block footer %}
&copy; Copyright 2023 by <a href="https://arrdem.com/">@arrdem</a>. &copy; Copyright 2023 by <a href="https://arrdem.com/">@arrdem</a>.
{% endblock %} {% endblock %}

View file

@ -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 %}

View file

@ -1,31 +1,21 @@
{% import "macros.html.j2" as macros %} {% import "macros.html.j2" as macros %}
<div class="panel files"> <div class="files">
<h2>Files</h2> <h2>Files</h2>
{% with files = ctx.db.list_files(uid=ctx.uid) %} {% with files = ctx.db.list_files(uid=ctx.uid) %}
{% if files %} {% if files %}
<ul>
{% for file in files %} {% for file in files %}
<li class="file"> <div class="file row u-flex">
<div class="details six columns">
<span class="file-name">{{ file.filename }}</span> <span class="file-name">{{ file.filename }}</span>
<span class="file-controls"> </div>
<div class="controls u-flex u-ml-auto">
{{ macros.start_job(file.id) }} {{ macros.start_job(file.id) }}
{{ macros.delete_file(file.id) }} {{ macros.delete_file(file.id) }}
</span> </div>
</li> </div>
{% endfor %} {% endfor %}
</ul>
{% else %} {% else %}
You don't have any files. Upload something! You don't have any files. Upload something!
{% endif %} {% endif %}
{% endwith %} {% endwith %}
</div> </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>

View file

@ -1,8 +1,12 @@
{% extends "base.html.j2" %} {% extends "base.html.j2" %}
{% block content %} {% block content %}
<div class="row twelve columns">
{% include "jobs_list.html.j2" %} {% include "jobs_list.html.j2" %}
</div>
{% if ctx.uid %} {% if ctx.uid %}
<div class="row twelve columns">
{% include "files_list.html.j2" %} {% include "files_list.html.j2" %}
</div>
{% endif %} {% endif %}
{% endblock %} {% endblock %}

View file

@ -1,4 +1,5 @@
{% extends "base.html.j2" %} {% extends "base.html.j2" %}
{% block content %} {% block content %}
{% include "jobs_list.html.j2" %} {% include "jobs_list.html.j2" %}
{% include "jobs_history.html.j2" %}
{% endblock %} {% endblock %}

View file

@ -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>

View file

@ -1,16 +1,25 @@
<div class="panel queue"> {% import "macros.html.j2" as macros %}
<div class="queue">
<h2>Job queue</h2> <h2>Job queue</h2>
{% with jobs = ctx.db.list_jobs(uid=ctx.uid) %} {% with jobs = ctx.db.list_job_queue(uid=ctx.uid) %}
{% if jobs %} {% if jobs %}
<ul>
{% for job in jobs %} {% for job in jobs %}
<li class="job"> <div class="job row u-flex">
<span class="job-id">{{job.id}}</span> <div class="details seven colums">
<span class="job-filename">{{ctx.db.fetch_file(job.file_id).filename}}</span> <div class="job-status one column">
<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 }} <div class="dot {{ macros.job_state(job) }} {{ 'dot--basic' if not job.state else '' }}" style="--dot-size: 1em;">
</li> </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 %} {% endfor %}
</ul>
{% else %} {% else %}
No pending tasks. {% if ctx.uid %}Start something!{% endif %} No pending tasks. {% if ctx.uid %}Start something!{% endif %}
{% endif %} {% endif %}

View file

@ -1,15 +1,47 @@
{# #################################################################################################### #}
{# Job CRUD #}
{% macro start_job(id) %} {% macro start_job(id) %}
<form method="post" action="/jobs"> <form class="inline" method="post" action="/jobs">
<input type="hidden" name="action" value="queue" /> <input type="hidden" name="action" value="enqueue" />
<input type="hidden" name="file_id" value="{{ id }}" /> <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> </form>
{% endmacro %} {% endmacro %}
{% macro delete_file(id) %} {% macro duplicate_job(id) %}
<form method="post" action="/files"> <form class="inline" method="post" action="/jobs">
<input type="hidden" name="action" value="delete" /> <input type="hidden" name="action" value="duplicate" />
<input type="hidden" name="id" value="{{ id }}" /> <input type="hidden" name="job_id" value="{{ id }}" />
<span><input id="submit" type="submit" value="Delete"/></span> <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> </form>
{% endmacro %} {% endmacro %}

View file

@ -1,4 +1,6 @@
{% extends "base.html.j2" %} {% extends "base.html.j2" %}
{% block content %} {% block content %}
<div class="twelve columns">
{% include "printers_list.html.j2" %} {% include "printers_list.html.j2" %}
</div>
{% endblock %} {% endblock %}

View file

@ -2,25 +2,27 @@
<h2>Printers</h2> <h2>Printers</h2>
{% with printers = ctx.db.list_printers() %} {% with printers = ctx.db.list_printers() %}
{% if printers %} {% if printers %}
<ul>
{% for printer in printers %} {% for printer in printers %}
{% with id, name, url, _api_key, last_poll, status = printer %} {% with id, name, url, _api_key, last_poll, status = printer %}
<li class="printer row"> <div class="printer row u-flex">
<div class="details six columns">
<span class="printer-name">{{name}}</span> <span class="printer-name">{{name}}</span>
<span class="printer-url"><code>{{url}}</code></span> <span class="printer-url"><code>{{url}}</code></span>
<span class="printer-status">{{status}}</span> <span class="printer-status">{{status}}</span>
<span class="printer-date">{{last_poll}}</span> <span class="printer-date">{{last_poll}}</span>
</div>
{# FIXME: How should these action buttons work? #} {# FIXME: How should these action buttons work? #}
<span class="printer-controls ml-auto"> <div class="controls u-flex u-ml-auto">
<a class="button" href="/printers/test?id={{id}}">Test</a> <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/edit?id={{id}}">Edit</a>
<a class="button" href="/printers/delete?id={{id}}">Remove</a> <a class="button" href="/printers/delete?id={{id}}">Remove</a>
</span> </div>
</li> </div>
{% endwith %} {% endwith %}
{% endfor %} {% endfor %}
</ul> {% if ctx.is_admin %}
{% if ctx.is_admin %}<a class="button" href="/printers/add">Add a printer</a>{% endif %} <a class="button" href="/printers/add">Add a printer</a>
{% endif %}
{% else %} {% else %}
No printers available. {% if ctx.is_admin %}<a href="/printers/add">Configure one!</a>{% else %}Ask the admin to configure one!{% endif %} No printers available. {% if ctx.is_admin %}<a href="/printers/add">Configure one!</a>{% else %}Ask the admin to configure one!{% endif %}
{% endif %} {% endif %}

View file

@ -1,45 +1,53 @@
{% extends "base.html.j2" %} {% extends "base.html.j2" %}
{% block content %} {% block content %}
<h1>User settings</h1> <h1>User settings</h1>
<div class="keys container"> <div class="row twelve columns keys">
<h2>API keys</h2> <h2>API keys</h2>
{% with keys = ctx.db.list_keys(ctx.uid) %} {% with keys = ctx.db.list_keys(ctx.uid) %}
<ul> {% if keys %}
{% for id, name, exp in keys if name != 'web session' %} {% for id, name, exp in keys %}
<li class="row"> <div class="row key u-flex">
<div class="row key"> <div class="details six columns">
<div class="row">
<span class="key-name">{{ name or 'anonymous' }}</span> <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> <span class="key-key">{{ id[:10] }}...</span>
<form method="post" class="ml-auto"> <span class="key-expiration u-ml-auto">{{ 'Expires in ' if exp else ''}}{{ exp - datetime.now() if exp else 'Never expires' }}</span>
</div>
<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="action" value="revoke">
<input type="hidden" name="id" value="{{ id }}"> <input type="hidden" name="id" value="{{ id }}">
<span><input id="submit" type="submit" value="Revoke"/></span> <input id="submit" type="submit" value="Revoke"/>
</form> </form>
</div> </div>
</div> </div>
</li>
{% endfor %} {% endfor %}
</ul> {% else %}
<p>No API keys configured.</p>
{% endif %}
{% endwith %} {% endwith %}
</div> </div>
<div class="keys-form container"> <div class="row twelve columns keys">
<h2>Add a key</h2> <h2>Add a key</h2>
<form method="post"> <form method="post">
<input type="hidden" name="action" value="add"> <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> <div class="row">
<span class="form-input mr-auto row"><span class="form-label">Key lifetime</span> <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"> <select name="ttl">
<option value="30d">30 days</option> <option value="30d">30 days</option>
<option value="90d">90 days</option> <option value="90d">90 days</option>
<option value="365d">1y</option> <option value="365d">1y</option>
<option value="forever">Forever (not recommended)</option> <option value="forever">Forever (not recommended)</option>
</select> </select>
</span> </div>
<span class="form-input mr-auto row"><input id="submit" type="submit" value="Add" onclick="maybeSubmit();" /></span> </div>
<div class="row">
<input id="submit" type="submit" value="Add" />
</div>
</form> </form>
</div> </div>
{% endblock %} {% endblock %}

View file

@ -7,19 +7,23 @@ Supporting the core app with asynchronous maintenance tasks.
Mostly related to monitoring and managing Printer state. 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 contextlib import closing
from urllib import parse as urlparse from datetime import datetime, timedelta
from tentacles.store import Store import logging
from pathlib import Path 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 octorest import OctoRest as _OR
from requests import Response 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): class OctoRest(_OR):
@ -67,25 +71,41 @@ def poll_printers(db_factory: Callable[[], Store]) -> None:
with closing(db_factory()) as db: with closing(db_factory()) as db:
for printer in db.list_printers(): for printer in db.list_printers():
id, name, url, api_key, last_poll, status = printer id, name, url, api_key, last_poll, status = printer
mapped_job = db.fetch_job_by_printer(id)
try: try:
client = OctoRest(url=url, apikey=api_key) client = OctoRest(url=url, apikey=api_key)
job = client.job_info() printer_job = client.job_info()
state = client.printer().get("state").get("flags", {}) print(printer_job)
if state.get("error"): 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") db.update_printer_status(id, "error")
elif state.get("ready"):
elif printer_state.get("ready"):
db.update_printer_status(id, "idle") db.update_printer_status(id, "idle")
elif state.get("printing"):
elif printer_state.get("printing"):
db.update_printer_status(id, "running") db.update_printer_status(id, "running")
else: else:
raise Exception(f"Indeterminate state {state!r}") raise Exception(f"Indeterminate state {printer_state!r}")
except (ConnectionError, Timeout): except (ConnectionError, Timeout):
db.update_printer_status(id, "error") db.update_printer_status(id, "error")
except HTTPError as e: except HTTPError as e:
assert isinstance(e.response, Response) 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") db.update_printer_status(id, "error")
elif e.response.json().get("error") == "Printer is not operational": elif e.response.json().get("error") == "Printer is not operational":
db.update_printer_status(id, "disconnected") db.update_printer_status(id, "disconnected")
@ -113,7 +133,11 @@ def push_jobs(db_factory: Callable[[], Store]) -> None:
with closing(db_factory()) as db: with closing(db_factory()) as db:
for job in db.list_mapped_jobs(): for job in db.list_mapped_jobs():
printer = db.fetch_printer(job.printer_id) 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: try:
client = OctoRest(url=printer.url, apikey=printer.api_key) client = OctoRest(url=printer.url, apikey=printer.api_key)
try: try:
@ -134,6 +158,23 @@ def push_jobs(db_factory: Callable[[], Store]) -> None:
log.exception("Oop") 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)) @corn_job(timedelta(seconds=5))
def pull_jobs(db_factory: Callable[[], Store]) -> None: def pull_jobs(db_factory: Callable[[], Store]) -> None:
"""Poll the state of mapped printers to control jobs.""" """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=poll_printers, args=[db_factory]).start()
Thread(target=assign_jobs, args=[db_factory]).start() Thread(target=assign_jobs, args=[db_factory]).start()
Thread(target=push_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() Thread(target=pull_jobs, args=[db_factory]).start()
return SHUTDOWN return SHUTDOWN

View file

@ -1,9 +1,9 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
from datetime import timedelta from datetime import timedelta
import tentacles.store as s
import pytest import pytest
import tentacles.store as s
@pytest.yield_fixture @pytest.yield_fixture

View file

@ -2,8 +2,6 @@
from tentacles.store import Store from tentacles.store import Store
import pytest
def test_store_initializes(store: Store): def test_store_initializes(store: Store):
assert isinstance(store, Store) assert isinstance(store, Store)