Grotti 3.2.0: Jetzt-Liste, Filament-Pause, Strom-Torte, UI-Polish.
Jetzt/Topbar und Material-Routing, Pause bei Filament-Ende, Druckbericht mit Stromanteilen, Design- und Kamera-Stream-Polish; Bedarf-Alert-Spam und Platte-belegt-Bugs behoben.
This commit is contained in:
1 parent
6e59df22e5
commit
4ddffbbeb1
68 files changed
+4348
-1215
No files matched your search
+154
-8
@@ -11,7 +11,7 @@ from urllib.parse import quote
|
||||
|
||||
import markdown
|
||||
import psycopg
|
||||
from flask import Flask, abort, g, jsonify, redirect, render_template, request, send_file, session, url_for
|
||||
from flask import Flask, Response, abort, g, jsonify, redirect, render_template, request, send_file, session, stream_with_context, url_for
|
||||
from markupsafe import Markup
|
||||
from psycopg.rows import dict_row
|
||||
from werkzeug.middleware.proxy_fix import ProxyFix
|
||||
@@ -44,6 +44,7 @@ UPLOAD_SUFFIXES = (".gcode", ".3mf", ".gcode.3mf")
|
||||
HOST_RE = re.compile(r"^[A-Za-z0-9.\-]+(?::\d{1,5})?$")
|
||||
TOKEN_QUERY_PATHS = (
|
||||
"/api/v1/kamera",
|
||||
"/api/v1/kamera/stream",
|
||||
"/api/v1/vorschau",
|
||||
)
|
||||
STEER_ACTIONS = {
|
||||
@@ -243,10 +244,10 @@ def create_app(cfg):
|
||||
if path == "/api/v1/einstellungen" and request.method == "GET":
|
||||
need_admin = False
|
||||
elif path == "/api/v1/einstellungen" and request.method == "PATCH":
|
||||
# Operator darf nur auto_queue schalten
|
||||
# Operator darf nur Automatik-Schalter setzen
|
||||
payload = request.get_json(silent=True) or {}
|
||||
keys = {k for k, v in payload.items() if v is not None}
|
||||
need_admin = keys - {"auto_queue"} != set()
|
||||
need_admin = keys - {"auto_queue", "auto_retry"} != set()
|
||||
elif path.startswith("/api/v1/users"):
|
||||
need_admin = True
|
||||
elif request.method != "GET":
|
||||
@@ -340,6 +341,24 @@ def create_app(cfg):
|
||||
require_host(host)
|
||||
return host
|
||||
|
||||
_icons_cache = {"html": "", "mtime": None}
|
||||
|
||||
def icons_sprite_html():
|
||||
path = ROOT / "static" / "icons.svg"
|
||||
try:
|
||||
mtime = path.stat().st_mtime
|
||||
except OSError:
|
||||
return ""
|
||||
if _icons_cache["html"] and _icons_cache["mtime"] == mtime:
|
||||
return _icons_cache["html"]
|
||||
text = path.read_text(encoding="utf-8")
|
||||
text = re.sub(r"<\?xml[^?]*\?>\s*", "", text)
|
||||
text = re.sub(r"<!--.*?-->\s*", "", text, count=1, flags=re.DOTALL)
|
||||
text = text.replace("<svg ", '<svg width="0" height="0" style="position:absolute" aria-hidden="true" ', 1)
|
||||
_icons_cache["html"] = text
|
||||
_icons_cache["mtime"] = mtime
|
||||
return text
|
||||
|
||||
@app.context_processor
|
||||
def inject_globals():
|
||||
user = auth_user()
|
||||
@@ -347,6 +366,7 @@ def create_app(cfg):
|
||||
"version": __version__,
|
||||
"csrf": session.get("csrf") or "",
|
||||
"current_user": user,
|
||||
"icons_sprite": Markup(icons_sprite_html()),
|
||||
}
|
||||
|
||||
def page(name, title):
|
||||
@@ -732,6 +752,10 @@ def create_app(cfg):
|
||||
def ideen():
|
||||
return redirect("/auftraege", code=301)
|
||||
|
||||
@app.get("/jetzt")
|
||||
def jetzt_page():
|
||||
return page("jetzt.html", "Jetzt")
|
||||
|
||||
@app.get("/aktivitaet")
|
||||
def aktivitaet():
|
||||
return page("aktivitaet.html", "Aktivität")
|
||||
@@ -791,6 +815,7 @@ def create_app(cfg):
|
||||
ntfy = data.get("ntfy") if isinstance(data.get("ntfy"), dict) else None
|
||||
alerts = data.get("alerts") if isinstance(data.get("alerts"), dict) else None
|
||||
auto_queue = data.get("auto_queue") if "auto_queue" in data else None
|
||||
auto_retry = data.get("auto_retry") if "auto_retry" in data else None
|
||||
fqdn = data.get("fqdn") if "fqdn" in data else None
|
||||
watch_dir = data.get("watch_dir") if "watch_dir" in data else None
|
||||
return jsonify(grotti_config.save_settings(
|
||||
@@ -800,6 +825,7 @@ def create_app(cfg):
|
||||
ntfy=ntfy,
|
||||
alerts=alerts,
|
||||
auto_queue=auto_queue,
|
||||
auto_retry=auto_retry,
|
||||
fqdn=fqdn,
|
||||
watch_dir=watch_dir,
|
||||
))
|
||||
@@ -956,6 +982,40 @@ def create_app(cfg):
|
||||
abort(404)
|
||||
return image[1], 200, {"Content-Type": image[0], "Cache-Control": "no-store"}
|
||||
|
||||
@app.get("/api/v1/kamera/stream")
|
||||
def camera_stream():
|
||||
"""MJPEG vom Drucker durchreichen (kein Frame-Cache)."""
|
||||
host = require_host(request.args.get("host", ""))
|
||||
try:
|
||||
upstream = moonraker.open_camera_stream(host)
|
||||
except Exception:
|
||||
abort(404)
|
||||
ctype = upstream.headers.get("Content-Type") or "multipart/x-mixed-replace; boundary=--"
|
||||
|
||||
@stream_with_context
|
||||
def generate():
|
||||
try:
|
||||
while True:
|
||||
chunk = upstream.read(16384)
|
||||
if not chunk:
|
||||
break
|
||||
yield chunk
|
||||
finally:
|
||||
try:
|
||||
upstream.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return Response(
|
||||
generate(),
|
||||
status=200,
|
||||
headers={
|
||||
"Content-Type": ctype,
|
||||
"Cache-Control": "no-store",
|
||||
"X-Accel-Buffering": "no",
|
||||
},
|
||||
)
|
||||
|
||||
@app.get("/api/v1/vorschau")
|
||||
def live_preview():
|
||||
host = require_host(request.args.get("host", ""))
|
||||
@@ -1371,6 +1431,62 @@ def create_app(cfg):
|
||||
})
|
||||
return jsonify(items)
|
||||
|
||||
@app.get("/api/v1/jetzt")
|
||||
def jetzt_api():
|
||||
"""Priorisierte Handlungsliste, laufende Drucke und Farm-ETA."""
|
||||
from grotti import jetzt as jetzt_mod
|
||||
|
||||
send_jobs = []
|
||||
try:
|
||||
send_jobs = app.extensions["send_queue"].list_jobs() or []
|
||||
except Exception:
|
||||
send_jobs = []
|
||||
return jsonify(jetzt_mod.build(
|
||||
conn(),
|
||||
app.extensions["watcher"].latest or [],
|
||||
printers=store.list_printers(conn()),
|
||||
send_jobs=send_jobs,
|
||||
))
|
||||
|
||||
@app.get("/api/v1/prognose")
|
||||
def prognose_api():
|
||||
"""Wann welcher Drucker frei ist und wann alles fertig ist."""
|
||||
from grotti import jetzt as jetzt_mod
|
||||
|
||||
running = []
|
||||
for live in app.extensions["watcher"].latest or []:
|
||||
state = (live.get("state") or "").lower()
|
||||
if state not in ("printing", "paused", "heating"):
|
||||
continue
|
||||
running.append({
|
||||
"host": live.get("host") or "",
|
||||
"name": live.get("name") or "",
|
||||
"remain": live.get("remain") or 0,
|
||||
"progress": live.get("progress") or 0,
|
||||
"filename": live.get("filename") or "",
|
||||
"state": state,
|
||||
"free_at": None,
|
||||
})
|
||||
eta = jetzt_mod.farm_eta(running)
|
||||
for item in running:
|
||||
rem = float(item.get("remain") or 0)
|
||||
item["free_at"] = jetzt_mod._iso_remain(rem) if rem > 0 else None
|
||||
waiting = [j for j in store.list_print_queue(conn(), limit=80) if j.get("status") == "waiting"]
|
||||
return jsonify({
|
||||
"running": running,
|
||||
"waiting_count": len(waiting),
|
||||
"eta": eta,
|
||||
})
|
||||
|
||||
@app.get("/api/v1/suche")
|
||||
def suche_api():
|
||||
"""Globale Suche für Ctrl+K."""
|
||||
q = (request.args.get("q") or "").strip()
|
||||
if len(q) < 1:
|
||||
return jsonify({"items": []})
|
||||
items = store.global_search(conn(), q, limit=20)
|
||||
return jsonify({"items": items})
|
||||
|
||||
@app.post("/api/v1/druck-queue")
|
||||
def print_queue_create():
|
||||
data = body()
|
||||
@@ -1445,8 +1561,8 @@ def create_app(cfg):
|
||||
c = conn()
|
||||
store.set_bed_needs_clear(c, host, False, "quittiert")
|
||||
run_json = None
|
||||
run_row = store.run_for_abraeumen_verdict(c, host)
|
||||
if any(k in data for k in ("verdict", "verdict_reason", "pieces_ok", "pieces_bad")):
|
||||
run_row = store.run_for_abraeumen_verdict(c, host)
|
||||
if not run_row:
|
||||
raise GrottiError("Kein Lauf zum Bewerten — Protokoll prüfen.", "lauf", 404)
|
||||
try:
|
||||
@@ -1459,12 +1575,31 @@ def create_app(cfg):
|
||||
except ValueError as exc:
|
||||
raise GrottiError(str(exc), "urteil") from exc
|
||||
run_json = store.set_verdict(c, run_row["id"], verdict, reason, ok_n, bad_n)
|
||||
reprint = None
|
||||
if bool(data.get("reprint")) and run_row and run_row.get("file_id"):
|
||||
reprint = store.enqueue_print(
|
||||
c,
|
||||
run_row["file_id"],
|
||||
host,
|
||||
"",
|
||||
50,
|
||||
run_row.get("spool_id"),
|
||||
[],
|
||||
False,
|
||||
False,
|
||||
)
|
||||
store.add_event(
|
||||
c, host, "", "queue",
|
||||
"Nochmal eingereiht: %s" % (run_row.get("filename") or run_row["file_id"]),
|
||||
run_row.get("filename") or "",
|
||||
)
|
||||
return jsonify({
|
||||
"ok": True,
|
||||
"host": host,
|
||||
"needs_clear": False,
|
||||
"message": "Platte quittiert.",
|
||||
"message": "Platte quittiert." + (" Nochmal in der Warteschlange." if reprint else ""),
|
||||
"run": run_json,
|
||||
"reprint": reprint,
|
||||
"verdict_reasons": VERDICT_REASONS,
|
||||
})
|
||||
|
||||
@@ -1499,7 +1634,12 @@ def create_app(cfg):
|
||||
if status == "running" and not data.get("resume"):
|
||||
raise GrottiError("Nur ein gestoppter Ablauf lässt sich fortsetzen.", "status")
|
||||
if status == "waiting" and current_row.get("status") not in ("waiting",):
|
||||
raise GrottiError("Status lässt sich so nicht setzen.", "status")
|
||||
# Undo: abgebrochen/entfernt wieder einreihen
|
||||
if not (data.get("undo") and current_row.get("status") in ("cancelled", "dismissed")):
|
||||
raise GrottiError("Status lässt sich so nicht setzen.", "status")
|
||||
fields["message"] = ""
|
||||
fields["phase"] = ""
|
||||
fields["phase_started_at"] = None
|
||||
fields["status"] = status
|
||||
if status == "running" and data.get("resume"):
|
||||
row = current_row
|
||||
@@ -1557,11 +1697,14 @@ def create_app(cfg):
|
||||
@app.get("/api/v1/ereignisse")
|
||||
def events_list():
|
||||
unread = request.args.get("unread") == "1"
|
||||
alerts_only = request.args.get("alerts") == "1"
|
||||
try:
|
||||
limit = max(1, min(1000, int(request.args.get("limit") or 40)))
|
||||
except (TypeError, ValueError):
|
||||
limit = 40
|
||||
kinds = [k for k in (request.args.get("kind") or "").split(",") if k.strip()]
|
||||
if alerts_only and not kinds:
|
||||
kinds = list(store.ALERT_EVENT_KINDS)
|
||||
since = None
|
||||
if request.args.get("since"):
|
||||
try:
|
||||
@@ -1577,7 +1720,10 @@ def create_app(cfg):
|
||||
kinds=kinds or None,
|
||||
since=since,
|
||||
),
|
||||
"unread": store.unread_event_count(conn()),
|
||||
"unread": store.unread_event_count(
|
||||
conn(),
|
||||
kinds=list(store.ALERT_EVENT_KINDS) if alerts_only or unread else None,
|
||||
),
|
||||
})
|
||||
|
||||
@app.post("/api/v1/ereignisse/gelesen")
|
||||
@@ -1589,7 +1735,7 @@ def create_app(cfg):
|
||||
ids = [int(item) for item in ids]
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise GrottiError("IDs müssen Zahlen sein.", "id") from exc
|
||||
unread = store.mark_events_read(conn(), ids)
|
||||
unread = store.mark_events_read(conn(), ids, kinds=list(store.ALERT_EVENT_KINDS) if not ids else None)
|
||||
return jsonify({"unread": unread})
|
||||
|
||||
@app.post("/api/v1/dateien/von-drucker")
|
||||
|
||||
Reference in new issue
Block a user