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 %} © 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)