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:
Dominic Gundel committed 2026-10-18 14:58:32 +02:00
1 parent 6e59df22e5
commit 4ddffbbeb1
68 files changed
+4348 -1215

No files matched your search

+154 -8
View File
@@ -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")