Grotti 3.7.0: Treiber für Kobra 4 und Marlin, smartere Dateien-Auswahl und einstellbare Etiketten.
Senden und Abbruch laufen über den Treiber, Orca-Doppelzeilen und tote Ideen-API fallen weg, Drucker folgen der Datei, Etikettenformat liegt in den Einstellungen.
This commit is contained in:
1 parent
5081212212
commit
db6a85a703
54 files changed
+3379
-384
No files matched your search
+77
-51
@@ -40,10 +40,13 @@ DOCS_DIR = TOOL_ROOT / "docs"
|
||||
PROJECT_STATUS = {"idee", "bereit", "druckt", "fertig"}
|
||||
VERDICTS = {"", "gut", "ausschuss"}
|
||||
VERDICT_REASONS = sorted(grotti_verdict.VERDICT_REASONS)
|
||||
IDEA_STATUS = {"offen", "spaeter", "erledigt"}
|
||||
PRINTER_FAMILIES = set(FAMILY_LABELS) | {""}
|
||||
UPLOAD_SUFFIXES = (".gcode", ".3mf", ".gcode.3mf")
|
||||
HOST_RE = re.compile(r"^[A-Za-z0-9.\-]+(?::\d{1,5})?$")
|
||||
SERIAL_RE = re.compile(
|
||||
r"^(?:COM\d+|/dev/tty(?:USB|ACM)\d+|/dev/serial/by-id/[A-Za-z0-9._\-]+)(?:@\d{3,7})?$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
TOKEN_QUERY_PATHS = (
|
||||
"/api/v1/kamera",
|
||||
"/api/v1/kamera/stream",
|
||||
@@ -59,13 +62,11 @@ STEER_ACTIONS = {
|
||||
|
||||
def validate_printer_host(host):
|
||||
host = (host or "").strip()
|
||||
if not host or not HOST_RE.fullmatch(host):
|
||||
if not host or ".." in host or not (HOST_RE.fullmatch(host) or SERIAL_RE.fullmatch(host)):
|
||||
raise GrottiError(
|
||||
"Host nur als Hostname oder IP, optional mit Port (z. B. 10.10.1.60).",
|
||||
"Host als IP, Hostname, COM-Port (COM5 oder COM5@250000) oder /dev/tty…",
|
||||
"host",
|
||||
)
|
||||
if ".." in host:
|
||||
raise GrottiError("Ungültiger Host.", "host")
|
||||
return host
|
||||
|
||||
|
||||
@@ -732,6 +733,7 @@ def create_app(cfg):
|
||||
token="",
|
||||
spool=spool,
|
||||
qr_url=qr_url,
|
||||
etikett=cfg.get("etikett") or grotti_config._etikett({}),
|
||||
)
|
||||
|
||||
@app.get("/protokoll")
|
||||
@@ -792,6 +794,7 @@ def create_app(cfg):
|
||||
sheets=sheets,
|
||||
spools=spools,
|
||||
target_g=target_g,
|
||||
etikett=cfg.get("etikett") or grotti_config._etikett({}),
|
||||
)
|
||||
|
||||
@app.get("/ideen")
|
||||
@@ -879,6 +882,7 @@ def create_app(cfg):
|
||||
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
|
||||
etikett = data.get("etikett") if isinstance(data.get("etikett"), dict) else None
|
||||
return jsonify(grotti_config.save_settings(
|
||||
cfg,
|
||||
energy=energy,
|
||||
@@ -889,6 +893,7 @@ def create_app(cfg):
|
||||
auto_retry=auto_retry,
|
||||
fqdn=fqdn,
|
||||
watch_dir=watch_dir,
|
||||
etikett=etikett,
|
||||
))
|
||||
|
||||
@app.post("/api/v1/telegram/test")
|
||||
@@ -931,8 +936,12 @@ def create_app(cfg):
|
||||
|
||||
by_id = {row["host"]: row for row in rows}
|
||||
if request.args.get("live", "1") == "0":
|
||||
from grotti.drivers import for_printer
|
||||
for row in rows:
|
||||
row["mainsail"] = "http://%s/" % row["host"]
|
||||
if for_printer(row).transport == "moonraker":
|
||||
row["mainsail"] = "http://%s/" % row["host"]
|
||||
else:
|
||||
row["mainsail"] = ""
|
||||
attach(row)
|
||||
return jsonify(rows)
|
||||
# Eine Live-Quelle: Watcher. Frischer als 60 s, sonst einmal selbst abfragen.
|
||||
@@ -940,7 +949,8 @@ def create_app(cfg):
|
||||
cached = watcher.by_host()
|
||||
age = watcher.age_s()
|
||||
if not cached or age is None or age > 60:
|
||||
lives = moonraker.snapshot(rows)
|
||||
from grotti.drivers import snapshot
|
||||
lives = snapshot(rows)
|
||||
else:
|
||||
lives = []
|
||||
for row in rows:
|
||||
@@ -955,7 +965,11 @@ def create_app(cfg):
|
||||
item = dict(row)
|
||||
item["ok"] = False
|
||||
item["state"] = "offline"
|
||||
item["mainsail"] = "http://%s/" % row["host"]
|
||||
from grotti.drivers import for_printer as _for_printer
|
||||
if _for_printer(row).transport == "moonraker":
|
||||
item["mainsail"] = "http://%s/" % row["host"]
|
||||
else:
|
||||
item["mainsail"] = ""
|
||||
lives.append(item)
|
||||
for live in lives:
|
||||
meta = by_id.get(live.get("host") or "") or {}
|
||||
@@ -963,6 +977,53 @@ def create_app(cfg):
|
||||
attach(live)
|
||||
return jsonify(lives)
|
||||
|
||||
@app.get("/api/v1/marlin-dienst")
|
||||
def marlin_dienst():
|
||||
from grotti.marlin_supervisor import current_status, ensure_if_enabled
|
||||
|
||||
ensure_if_enabled()
|
||||
return jsonify(current_status())
|
||||
|
||||
@app.post("/api/v1/meldungen")
|
||||
def meldung_create():
|
||||
from grotti import meldung as grotti_meldung
|
||||
|
||||
require_role("operator")
|
||||
data = body()
|
||||
try:
|
||||
row = grotti_meldung.create(
|
||||
conn(),
|
||||
cfg,
|
||||
text=data.get("text") or "",
|
||||
page=data.get("page") or "",
|
||||
printer_host=data.get("printer_host") or "",
|
||||
error_text=data.get("error_text") or "",
|
||||
user=g.user,
|
||||
)
|
||||
conn().commit()
|
||||
except ValueError as exc:
|
||||
raise GrottiError(str(exc), "meldung") from exc
|
||||
return jsonify(row), 201
|
||||
|
||||
@app.get("/api/v1/meldungen")
|
||||
def meldung_list():
|
||||
from grotti import meldung as grotti_meldung
|
||||
|
||||
require_role("admin")
|
||||
status = (request.args.get("status") or "").strip() or None
|
||||
return jsonify(grotti_meldung.list_rows(conn(), status=status))
|
||||
|
||||
@app.post("/api/v1/meldungen/<int:meldung_id>/erledigt")
|
||||
def meldung_close(meldung_id):
|
||||
from grotti import meldung as grotti_meldung
|
||||
|
||||
require_role("admin")
|
||||
row = grotti_meldung.close(conn(), meldung_id)
|
||||
if not row:
|
||||
raise GrottiError("Die Meldung fehlt.", "meldung", 404)
|
||||
conn().commit()
|
||||
return jsonify(row)
|
||||
|
||||
@app.post("/api/v1/drucker")
|
||||
def printer_create():
|
||||
data = body()
|
||||
@@ -1555,6 +1616,7 @@ def create_app(cfg):
|
||||
optional_id(data.get("spool_id")),
|
||||
ace_maps,
|
||||
bed_leveling=bool(data.get("bed_leveling")),
|
||||
marlin_mode=(data.get("marlin_mode") or "stream"),
|
||||
)
|
||||
return jsonify({"jobs": created, "count": len(created), "mode": "now"}), 202
|
||||
|
||||
@@ -1973,8 +2035,13 @@ def create_app(cfg):
|
||||
# laufenden Druck auf dem Host best-effort stoppen
|
||||
if was_running and row.get("host"):
|
||||
try:
|
||||
moonraker.command(row["host"], "abbrechen")
|
||||
stopped = ", Druck gestoppt"
|
||||
from grotti.drivers import for_printer
|
||||
printer = store.printer_by_host(conn(), row["host"])
|
||||
if printer:
|
||||
for_printer(printer).command(row["host"], "abbrechen")
|
||||
stopped = ", Druck gestoppt"
|
||||
else:
|
||||
stopped = ", Druck nicht gestoppt"
|
||||
except Exception:
|
||||
stopped = ", Druck nicht gestoppt"
|
||||
store.add_event(
|
||||
@@ -2142,47 +2209,6 @@ def create_app(cfg):
|
||||
def color_list_api():
|
||||
return jsonify(color_catalog())
|
||||
|
||||
@app.get("/api/v1/ideen")
|
||||
def idea_list():
|
||||
return jsonify(store.list_ideas(conn()))
|
||||
|
||||
@app.post("/api/v1/ideen")
|
||||
def idea_create():
|
||||
data = body()
|
||||
title = (data.get("title") or "").strip()
|
||||
if not title:
|
||||
raise GrottiError("Die Idee braucht einen Titel.", "titel")
|
||||
status = data.get("status") or "offen"
|
||||
if status not in IDEA_STATUS:
|
||||
raise GrottiError("Unbekannter Status.", "status")
|
||||
return jsonify(store.create_idea(conn(), title, (data.get("body") or "").strip(), status)), 201
|
||||
|
||||
@app.patch("/api/v1/ideen/<int:idea_id>")
|
||||
def idea_patch(idea_id):
|
||||
data = body()
|
||||
fields = {}
|
||||
if "title" in data:
|
||||
title = (data.get("title") or "").strip()
|
||||
if not title:
|
||||
raise GrottiError("Die Idee braucht einen Titel.", "titel")
|
||||
fields["title"] = title
|
||||
if "body" in data:
|
||||
fields["body"] = (data.get("body") or "").strip()
|
||||
if "status" in data:
|
||||
if data.get("status") not in IDEA_STATUS:
|
||||
raise GrottiError("Unbekannter Status.", "status")
|
||||
fields["status"] = data.get("status")
|
||||
row = store.update_idea(conn(), idea_id, fields)
|
||||
if not row:
|
||||
raise GrottiError("Die Idee fehlt.", "idee", 404)
|
||||
return jsonify(row)
|
||||
|
||||
@app.delete("/api/v1/ideen/<int:idea_id>")
|
||||
def idea_delete(idea_id):
|
||||
if not store.delete_idea(conn(), idea_id):
|
||||
raise GrottiError("Die Idee fehlt.", "idee", 404)
|
||||
return "", 204
|
||||
|
||||
@app.get("/api/v1/kunden")
|
||||
def kunden_list():
|
||||
return jsonify(grotti_orders.list_customers(conn()))
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
{# Gemeinsames Druck-CSS für Spulen- und Lauf-Etiketten. Maße aus Einstellungen. #}
|
||||
{% set w = etikett.width_mm if etikett is defined else 70 %}
|
||||
{% set h = etikett.height_mm if etikett is defined else 40 %}
|
||||
{% set qr = etikett.qr_mm if etikett is defined else 20 %}
|
||||
<style>
|
||||
:root { color-scheme: light; }
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
font: 14px/1.35 "Segoe UI", system-ui, sans-serif;
|
||||
color: #111;
|
||||
background: #f3f3f3;
|
||||
}
|
||||
.toolbar {
|
||||
display: flex;
|
||||
gap: 0.6rem;
|
||||
padding: 0.8rem 1rem;
|
||||
background: #1a1a1a;
|
||||
color: #eee;
|
||||
}
|
||||
.toolbar a, .toolbar button {
|
||||
color: #111;
|
||||
background: #f97316;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
padding: 0.4rem 0.85rem;
|
||||
font: inherit;
|
||||
font-weight: 650;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.toolbar a.ghost { background: transparent; color: #eee; border: 1px solid #555; }
|
||||
.sheet {
|
||||
width: {{ w }}mm;
|
||||
min-height: {{ h }}mm;
|
||||
margin: 1.2rem auto;
|
||||
padding: 3.5mm;
|
||||
background: #fff;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr {{ qr }}mm;
|
||||
gap: 2.5mm;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
.meta { min-width: 0; }
|
||||
.meta h1 {
|
||||
margin: 0 0 2mm;
|
||||
font-size: 4.2mm;
|
||||
letter-spacing: -0.02em;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.meta p { margin: 0 0 1.2mm; font-size: 3.1mm; color: #333; }
|
||||
.meta .code { font-weight: 700; font-variant-numeric: tabular-nums; }
|
||||
.dots { display: inline-flex; vertical-align: middle; margin-right: 1.5mm; }
|
||||
.dot {
|
||||
width: 3.2mm;
|
||||
height: 3.2mm;
|
||||
border-radius: 50%;
|
||||
border: 0.2mm solid rgba(0,0,0,0.25);
|
||||
margin-left: -1mm;
|
||||
}
|
||||
.dot:first-child { margin-left: 0; }
|
||||
.qr-wrap {
|
||||
width: {{ qr }}mm;
|
||||
height: {{ qr }}mm;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.qr-wrap canvas,
|
||||
.qr-wrap img {
|
||||
display: block;
|
||||
width: {{ qr }}mm !important;
|
||||
height: {{ qr }}mm !important;
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
}
|
||||
@page {
|
||||
size: {{ w }}mm {{ h }}mm;
|
||||
margin: 0;
|
||||
}
|
||||
@media print {
|
||||
body { background: #fff; }
|
||||
.toolbar { display: none; }
|
||||
.sheet {
|
||||
margin: 0;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
+199
-58
@@ -32,6 +32,9 @@
|
||||
<option value="">Alle Maschinen</option>
|
||||
<option value="kobra_3">Kobra 3</option>
|
||||
<option value="kobra_s1">Kobra S1</option>
|
||||
<option value="kobra_4">Kobra 4</option>
|
||||
<option value="anycubic_i3">Anycubic i3</option>
|
||||
<option value="tenlog">Tenlog</option>
|
||||
</select>
|
||||
<select id="file-filter-project">
|
||||
<option value="">Alle Projekte</option>
|
||||
@@ -116,7 +119,15 @@
|
||||
<div class="opt-body">
|
||||
<div id="loaded-spools" class="loaded-spools"></div>
|
||||
<p class="tiny">Verbrauch geht von der Rolle am Drucker. Wechsel unter <a href="/material">Material</a>.</p>
|
||||
<label class="check"><input id="send-bed-level" type="checkbox"> Bettnetz vor dem Druck messen</label>
|
||||
<label class="check" id="send-bed-level-wrap"><input id="send-bed-level" type="checkbox"> Bettnetz vor dem Druck messen</label>
|
||||
<label class="check" id="send-marlin-wrap" hidden>
|
||||
Start über USB
|
||||
<select id="send-marlin-mode">
|
||||
<option value="stream">Strom (Zeile für Zeile)</option>
|
||||
<option value="sd">SD-Karte</option>
|
||||
</select>
|
||||
</label>
|
||||
<p class="tiny" id="send-marlin-hint" hidden>„Wenn frei“ startet immer als Strom.</p>
|
||||
<label class="check"><input id="send-override" type="checkbox"> Auch senden, wenn die Maschine nicht passt</label>
|
||||
<label for="send-project">Projekt</label>
|
||||
<select id="send-project"><option value="">keins</option></select>
|
||||
@@ -168,7 +179,12 @@
|
||||
<script src="{{ url_for('static', filename='gcode-viewer.js') }}?v=10"></script>
|
||||
<script src="{{ url_for('static', filename='sweep-viewer.js') }}?v=1"></script>
|
||||
<script>
|
||||
const families = { kobra_s1: "Kobra S1", kobra_3: "Kobra 3" };
|
||||
const families = {
|
||||
kobra_s1: "Kobra S1", kobra_3: "Kobra 3", kobra_4: "Kobra 4",
|
||||
anycubic_i3: "Anycubic i3", tenlog: "Tenlog",
|
||||
};
|
||||
const MOONRAKER_FAMILIES = new Set(["kobra_3", "kobra_s1"]);
|
||||
const MARLIN_FAMILIES = new Set(["anycubic_i3", "tenlog"]);
|
||||
const ABLAUF_KEY = "grotti-ablauf-v2";
|
||||
const SEND_KEY = "grotti-send-v1";
|
||||
const LIB_KEY = "grotti-lib-v1";
|
||||
@@ -359,40 +375,8 @@ function spoolWarnText() {
|
||||
if (!file || need <= 0) return "";
|
||||
const maps = collectAceMaps();
|
||||
const hosts = selectedHosts();
|
||||
const fileTypes = ((file.filaments || []).map((f) => (f.type || "").toLowerCase()).filter(Boolean));
|
||||
const fileProfiles = ((file.filaments || []).map((f) => (f.profile || "").toLowerCase()).filter(Boolean));
|
||||
const PROFILE_VARIANTS = ["meta", "matte", "matt", "silk", "plus", "hf", "cf"];
|
||||
function profileHasVariant(want) {
|
||||
return PROFILE_VARIANTS.some((v) => new RegExp("\\b" + v + "\\b", "i").test(want || ""));
|
||||
}
|
||||
function profileBaseType(want) {
|
||||
const m = String(want || "").toLowerCase().match(/\b(pla|petg|abs|asa|tpu|pa|pc|hips|pva)\b/);
|
||||
return m ? m[1] : "";
|
||||
}
|
||||
function profileOk(spoolRows) {
|
||||
if (!fileProfiles.length) return true;
|
||||
const names = spoolRows.map((s) => (s.name || "").toLowerCase()).filter(Boolean);
|
||||
const types = spoolRows.map((s) => (s.material_type || "").toLowerCase()).filter(Boolean);
|
||||
if (!names.length && !types.length) return true;
|
||||
return fileProfiles.every((want) => {
|
||||
if (names.some((n) => {
|
||||
if (n === want) return true;
|
||||
if (!n.startsWith(want) || /[a-z0-9]/i.test(n.charAt(want.length) || "")) return false;
|
||||
const rest = n.slice(want.length).replace(/^[\s·\-_|/]+/, "");
|
||||
const first = (rest.split(/\s+/)[0] || "").toLowerCase();
|
||||
return !PROFILE_VARIANTS.includes(first);
|
||||
})) return true;
|
||||
// Basis-Profil (nur „… PLA“): Typ reicht, Marke egal — keine Meta/Matte-Spule
|
||||
if (profileHasVariant(want)) return false;
|
||||
const kind = profileBaseType(want);
|
||||
if (!kind) return true;
|
||||
return spoolRows.some((s) => {
|
||||
const t = (s.material_type || "").toLowerCase();
|
||||
const n = (s.name || "").toLowerCase();
|
||||
return t === kind && !profileHasVariant(n);
|
||||
});
|
||||
});
|
||||
}
|
||||
const fileTypes = fileTypesOf(file);
|
||||
const fileProfiles = fileProfilesOf(file);
|
||||
for (const host of hosts) {
|
||||
const name = (printers.find((p) => p.host === host) || {}).name || host;
|
||||
const mapping = maps[host] || [];
|
||||
@@ -414,7 +398,7 @@ function spoolWarnText() {
|
||||
});
|
||||
if (!any) return name + ": keine Grotti-Spule auf den gewählten ACE-Plätzen — Abzug entfällt.";
|
||||
if (left + 0.5 < need) return "ACE auf " + name + ": nur " + grams(left) + ", Datei braucht " + grams(need) + ".";
|
||||
if (!profileOk(mappedSpools)) {
|
||||
if (!profileOk(file, mappedSpools)) {
|
||||
return name + ": Sorte passt nicht (Datei " + fileProfiles.join("/") + ").";
|
||||
}
|
||||
if (!fileProfiles.length && fileTypes.length && types.length && !fileTypes.some((t) => types.includes(t))) {
|
||||
@@ -431,7 +415,7 @@ function spoolWarnText() {
|
||||
if (left + 0.5 < need) {
|
||||
return name + ": nur " + grams(left) + ", Datei braucht " + grams(need) + ".";
|
||||
}
|
||||
if (!profileOk(external)) {
|
||||
if (!profileOk(file, external)) {
|
||||
return name + ": Sorte passt nicht (Datei " + fileProfiles.join("/") + ").";
|
||||
}
|
||||
const types = external.map((s) => (s.material_type || "").toLowerCase()).filter(Boolean);
|
||||
@@ -986,13 +970,116 @@ function familyMismatch(p, file) {
|
||||
return !!(file && file.printer_family && p.family && file.printer_family !== p.family);
|
||||
}
|
||||
|
||||
function renderTargets() {
|
||||
const box = document.getElementById("targets");
|
||||
const PROFILE_VARIANTS = ["meta", "matte", "matt", "silk", "plus", "hf", "cf"];
|
||||
|
||||
function fileProfilesOf(file) {
|
||||
return ((file && file.filaments) || []).map((f) => (f.profile || "").toLowerCase()).filter(Boolean);
|
||||
}
|
||||
|
||||
function fileTypesOf(file) {
|
||||
return ((file && file.filaments) || []).map((f) => (f.type || "").toLowerCase()).filter(Boolean);
|
||||
}
|
||||
|
||||
function profileHasVariant(want) {
|
||||
return PROFILE_VARIANTS.some((v) => new RegExp("\\b" + v + "\\b", "i").test(want || ""));
|
||||
}
|
||||
|
||||
function profileBaseType(want) {
|
||||
const m = String(want || "").toLowerCase().match(/\b(pla|petg|abs|asa|tpu|pa|pc|hips|pva)\b/);
|
||||
return m ? m[1] : "";
|
||||
}
|
||||
|
||||
function profileOk(file, spoolRows) {
|
||||
const fileProfiles = fileProfilesOf(file);
|
||||
if (!fileProfiles.length) return true;
|
||||
const names = spoolRows.map((s) => (s.name || "").toLowerCase()).filter(Boolean);
|
||||
const types = spoolRows.map((s) => (s.material_type || "").toLowerCase()).filter(Boolean);
|
||||
if (!names.length && !types.length) return true;
|
||||
return fileProfiles.every((want) => {
|
||||
if (names.some((n) => {
|
||||
if (n === want) return true;
|
||||
if (!n.startsWith(want) || /[a-z0-9]/i.test(n.charAt(want.length) || "")) return false;
|
||||
const rest = n.slice(want.length).replace(/^[\s·\-_|/]+/, "");
|
||||
const first = (rest.split(/\s+/)[0] || "").toLowerCase();
|
||||
return !PROFILE_VARIANTS.includes(first);
|
||||
})) return true;
|
||||
if (profileHasVariant(want)) return false;
|
||||
const kind = profileBaseType(want);
|
||||
if (!kind) return true;
|
||||
return spoolRows.some((s) => {
|
||||
const t = (s.material_type || "").toLowerCase();
|
||||
const n = (s.name || "").toLowerCase();
|
||||
return t === kind && !profileHasVariant(n);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function materialFitsHost(file, host) {
|
||||
const profiles = fileProfilesOf(file);
|
||||
const types = fileTypesOf(file);
|
||||
if (!profiles.length && !types.length) return true;
|
||||
const onHost = spoolsOnHost(host);
|
||||
if (!onHost.length) return false;
|
||||
if (profiles.length) return profileOk(file, onHost);
|
||||
const spoolTypes = onHost.map((s) => (s.material_type || "").toLowerCase()).filter(Boolean);
|
||||
if (!spoolTypes.length) return false;
|
||||
return types.some((t) => spoolTypes.includes(t));
|
||||
}
|
||||
|
||||
function printerFitsFile(p, file, override) {
|
||||
if (!p || !file) return false;
|
||||
if (sel.tab === "ablauf" && p.family !== "kobra_3") return false;
|
||||
if (familyMismatch(p, file) && !override) return false;
|
||||
if (!materialFitsHost(file, p.host)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function printerPickScore(p, file) {
|
||||
let score = 0;
|
||||
if (canStartNow(p)) score += 100;
|
||||
else if (isFree(p)) score += 50;
|
||||
else if (p.needs_clear) score += 20;
|
||||
if (spoolsOnHost(p.host).length) score += 10;
|
||||
if (file && file.printer_family && p.family === file.printer_family) score += 5;
|
||||
return score;
|
||||
}
|
||||
|
||||
function suggestHostsForFile(file) {
|
||||
if (!file) return new Set();
|
||||
const override = !!(document.getElementById("send-override") || {}).checked;
|
||||
const kept = [...sel.hosts].filter((host) => {
|
||||
const p = printers.find((x) => x.host === host);
|
||||
return printerFitsFile(p, file, override);
|
||||
});
|
||||
if (kept.length) return new Set(kept);
|
||||
const candidates = printers.filter((p) => printerFitsFile(p, file, override));
|
||||
candidates.sort((a, b) => printerPickScore(b, file) - printerPickScore(a, file)
|
||||
|| String(a.name || "").localeCompare(String(b.name || ""), "de"));
|
||||
if (!candidates.length) return new Set();
|
||||
return new Set([candidates[0].host]);
|
||||
}
|
||||
|
||||
function noPrinterReason(file) {
|
||||
if (!file || !printers.length) return "";
|
||||
const override = !!(document.getElementById("send-override") || {}).checked;
|
||||
const familyOk = printers.filter((p) => {
|
||||
if (sel.tab === "ablauf" && p.family !== "kobra_3") return false;
|
||||
return !familyMismatch(p, file) || override;
|
||||
});
|
||||
if (!familyOk.length) {
|
||||
const want = families[file.printer_family] || file.printer_family || "diese Maschine";
|
||||
return "Kein Drucker für " + want + ".";
|
||||
}
|
||||
if (familyOk.some((p) => materialFitsHost(file, p.host))) return "";
|
||||
const need = fileProfilesOf(file).join("/") || fileTypesOf(file).join("/") || "Material";
|
||||
return "Keine passende Rolle am Drucker (" + need + "). Unter Material zuweisen.";
|
||||
}
|
||||
|
||||
function targetsHtml() {
|
||||
const file = selectedFile();
|
||||
const override = document.getElementById("send-override").checked;
|
||||
const nextMode = sel.tab === "send" && sel.mode === "next";
|
||||
box.classList.toggle("dimmed", nextMode);
|
||||
box.innerHTML = printers.map((p) => {
|
||||
return printers.map((p) => {
|
||||
const s = targetState(p);
|
||||
const mismatch = familyMismatch(p, file);
|
||||
const notK3 = sel.tab === "ablauf" && p.family !== "kobra_3";
|
||||
@@ -1005,6 +1092,17 @@ function renderTargets() {
|
||||
<span class="row2 ${s.bad || off ? "bad" : ""}">${escapeHtml(why)}</span>
|
||||
</button>`;
|
||||
}).join("") || '<div class="picked-empty">' + icon("printer") + '<span>Noch kein Drucker.</span><span class="tiny"><a href="/einstellungen">In Einstellungen anlegen</a></span></div>';
|
||||
}
|
||||
|
||||
function renderTargets() {
|
||||
const box = document.getElementById("targets");
|
||||
const nextMode = sel.tab === "send" && sel.mode === "next";
|
||||
box.classList.toggle("dimmed", nextMode);
|
||||
const html = targetsHtml();
|
||||
if (box._html !== html) {
|
||||
box._html = html;
|
||||
box.innerHTML = html;
|
||||
}
|
||||
document.getElementById("step-target").classList.toggle("ok", nextMode || selectedHosts().length > 0);
|
||||
}
|
||||
|
||||
@@ -1060,7 +1158,12 @@ function updateSendButton() {
|
||||
button.disabled = !!problem || !!sendPoll;
|
||||
hint.textContent = problem || note;
|
||||
hint.classList.toggle("bad", !!problem);
|
||||
targetHint.textContent = sel.mode === "next" && sel.tab === "send" ? "Ziel wählt Grotti nach Sorte." : chosen.length > 1 ? "Je Drucker ein eigener Auftrag." : "";
|
||||
let targetNote = "";
|
||||
if (sel.mode === "next" && sel.tab === "send") targetNote = "Ziel wählt Grotti nach Sorte.";
|
||||
else if (chosen.length > 1) targetNote = "Je Drucker ein eigener Auftrag.";
|
||||
else if (file && !chosen.length && sel.mode === "this") targetNote = noPrinterReason(file);
|
||||
targetHint.textContent = targetNote;
|
||||
targetHint.classList.toggle("bad", !!(file && !chosen.length && sel.mode === "this" && targetNote));
|
||||
}
|
||||
|
||||
function updateAblaufButton() {
|
||||
@@ -1082,12 +1185,33 @@ function updateAblaufButton() {
|
||||
hint.classList.toggle("bad", !!problem);
|
||||
}
|
||||
|
||||
function syncSendOptions() {
|
||||
const chosen = selectedPrinters();
|
||||
const moonraker = chosen.some((p) => MOONRAKER_FAMILIES.has(p.family));
|
||||
const marlin = chosen.some((p) => MARLIN_FAMILIES.has(p.family));
|
||||
const bedWrap = document.getElementById("send-bed-level-wrap");
|
||||
const marlinWrap = document.getElementById("send-marlin-wrap");
|
||||
const marlinHint = document.getElementById("send-marlin-hint");
|
||||
if (bedWrap) {
|
||||
bedWrap.hidden = chosen.length > 0 && !moonraker;
|
||||
if (bedWrap.hidden) document.getElementById("send-bed-level").checked = false;
|
||||
}
|
||||
if (marlinWrap) marlinWrap.hidden = !marlin;
|
||||
if (marlinHint) marlinHint.hidden = !marlin;
|
||||
}
|
||||
|
||||
function updateOptionsSummary() {
|
||||
syncSendOptions();
|
||||
const parts = [];
|
||||
const hosts = selectedHosts();
|
||||
const loaded = hosts.reduce((n, h) => n + spoolsOnHost(h).length, 0);
|
||||
if (loaded) parts.push(loaded + (loaded === 1 ? " Spule" : " Spulen"));
|
||||
if (document.getElementById("send-bed-level").checked) parts.push("Bettnetz");
|
||||
if (!document.getElementById("send-bed-level-wrap")?.hidden && document.getElementById("send-bed-level").checked) {
|
||||
parts.push("Bettnetz");
|
||||
}
|
||||
if (!document.getElementById("send-marlin-wrap")?.hidden) {
|
||||
parts.push(document.getElementById("send-marlin-mode").value === "sd" ? "SD-Karte" : "Strom");
|
||||
}
|
||||
if (document.getElementById("send-override").checked) parts.push("Maschine egal");
|
||||
const project = projects.find((p) => String(p.id) === document.getElementById("send-project")?.value);
|
||||
if (project) parts.push(project.name);
|
||||
@@ -1116,7 +1240,9 @@ function refreshFlow() {
|
||||
updateAblaufButton();
|
||||
}
|
||||
|
||||
function selectFile(id) {
|
||||
function selectFile(id, opts) {
|
||||
const keepHosts = !!(opts && opts.keepHosts);
|
||||
const fromUrlHost = !!(opts && opts.fromUrlHost);
|
||||
sel.fileId = id;
|
||||
const file = selectedFile();
|
||||
if (file && file.project_id && document.getElementById("send-project")) {
|
||||
@@ -1127,6 +1253,10 @@ function selectFile(id) {
|
||||
saveAblaufMemory();
|
||||
if (sel.tab === "ablauf") renderAblauf();
|
||||
}
|
||||
if (file && !keepHosts && !fromUrlHost && !(sel.tab === "send" && sel.mode === "next")) {
|
||||
sel.hosts = suggestHostsForFile(file);
|
||||
saveSendMemory();
|
||||
}
|
||||
document.querySelectorAll("#lib-list .lib-item").forEach((n) => n.classList.toggle("on", n.dataset.file === String(id)));
|
||||
refreshFlow();
|
||||
scheduleToolpath(file, 0, 0);
|
||||
@@ -1170,12 +1300,17 @@ async function load() {
|
||||
fillProjectFilter();
|
||||
fillMaterialFilter();
|
||||
fillFolderFilter();
|
||||
const deepProject = new URLSearchParams(location.search).get("project");
|
||||
const deepParams = new URLSearchParams(location.search);
|
||||
const deepProject = deepParams.get("project");
|
||||
if (deepProject) {
|
||||
const pf = document.getElementById("file-filter-project");
|
||||
if (pf && [...pf.options].some((o) => o.value === deepProject)) pf.value = deepProject;
|
||||
}
|
||||
releaseRootForProject();
|
||||
// Beim Laden: gemerkten Drucker nur behalten, wenn er zur Datei passt — außer Deep-Link ?host=
|
||||
if (selectedFile() && !deepParams.get("host") && !(sel.tab === "send" && sel.mode === "next")) {
|
||||
sel.hosts = suggestHostsForFile(selectedFile());
|
||||
}
|
||||
renderLibrary();
|
||||
renderTabs();
|
||||
refreshFlow();
|
||||
@@ -1323,18 +1458,22 @@ async function watchJobs(ids) {
|
||||
}
|
||||
|
||||
async function postSenden(hosts, whenFree) {
|
||||
return api("/senden", {
|
||||
method: "POST",
|
||||
json: {
|
||||
file_id: Number(sel.fileId),
|
||||
hosts,
|
||||
confirm: true,
|
||||
override_machine: document.getElementById("send-override").checked,
|
||||
bed_leveling: document.getElementById("send-bed-level").checked,
|
||||
ace_maps: collectAceMaps(),
|
||||
when_free: whenFree,
|
||||
},
|
||||
});
|
||||
syncSendOptions();
|
||||
const marlinOn = !document.getElementById("send-marlin-wrap")?.hidden;
|
||||
const payload = {
|
||||
file_id: Number(sel.fileId),
|
||||
hosts,
|
||||
confirm: true,
|
||||
override_machine: document.getElementById("send-override").checked,
|
||||
bed_leveling: !document.getElementById("send-bed-level-wrap")?.hidden
|
||||
&& document.getElementById("send-bed-level").checked,
|
||||
ace_maps: collectAceMaps(),
|
||||
when_free: whenFree,
|
||||
};
|
||||
if (marlinOn && !whenFree) {
|
||||
payload.marlin_mode = document.getElementById("send-marlin-mode").value || "stream";
|
||||
}
|
||||
return api("/senden", { method: "POST", json: payload });
|
||||
}
|
||||
|
||||
async function doSend() {
|
||||
@@ -1760,6 +1899,7 @@ document.querySelector(".tabs").addEventListener("click", (event) => {
|
||||
});
|
||||
|
||||
document.getElementById("send-bed-level").addEventListener("change", updateOptionsSummary);
|
||||
document.getElementById("send-marlin-mode")?.addEventListener("change", updateOptionsSummary);
|
||||
document.getElementById("send-override").addEventListener("change", () => { refreshFlow(); });
|
||||
document.getElementById("send-project")?.addEventListener("change", updateOptionsSummary);
|
||||
|
||||
@@ -1863,6 +2003,7 @@ document.getElementById("rescan").addEventListener("click", async () => {
|
||||
});
|
||||
|
||||
window.onQueueChanged = refreshPrinters;
|
||||
window.onDruckerLive = refreshPrinters;
|
||||
loadSendMemory();
|
||||
loadAblaufMemory();
|
||||
load().catch((error) => toast(error.message || String(error), "bad"));
|
||||
|
||||
@@ -14,7 +14,9 @@
|
||||
<a href="#sec-auto"><svg class="ico"><use href="#i-zap"/></svg>Automatik</a>
|
||||
<a href="#sec-slice" class="admin-only"><svg class="ico"><use href="#i-folder"/></svg>Slice</a>
|
||||
<a href="#sec-fqdn" class="admin-only"><svg class="ico"><use href="#i-link"/></svg>Adresse</a>
|
||||
<a href="#sec-etikett" class="admin-only"><svg class="ico"><use href="#i-tag"/></svg>Etiketten</a>
|
||||
<a href="#sec-drucker" class="admin-only"><svg class="ico"><use href="#i-printer"/></svg>Drucker</a>
|
||||
<a href="#sec-meldungen" class="admin-only"><svg class="ico"><use href="#i-alert"/></svg>Meldungen</a>
|
||||
<a href="#sec-telegram" class="admin-only"><svg class="ico"><use href="#i-message"/></svg>Telegram</a>
|
||||
<a href="#sec-ntfy" class="admin-only"><svg class="ico"><use href="#i-bell"/></svg>ntfy</a>
|
||||
<a href="#sec-backup" class="admin-only"><svg class="ico"><use href="#i-hard-drive"/></svg>Backup</a>
|
||||
@@ -100,9 +102,34 @@
|
||||
<p id="fqdn-note"></p>
|
||||
</section>
|
||||
|
||||
<section class="card admin-only" id="sec-etikett">
|
||||
<h2>Etiketten</h2>
|
||||
<p class="hint">Papierformat für Spulen- und Drucketiketten (Browser-Druck). Standard 70×40 mm. Der QR schrumpft auf flacheren Rollen mit.</p>
|
||||
<div class="field" style="max-width:16rem">
|
||||
<label for="etikett-preset">Voreinstellung</label>
|
||||
<select id="etikett-preset">
|
||||
<option value="">Eigene Maße</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="grid-2" style="max-width:20rem">
|
||||
<div class="field">
|
||||
<label for="etikett-w">Breite (mm)</label>
|
||||
<input id="etikett-w" type="number" min="20" max="200" step="0.1" value="70">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="etikett-h">Höhe (mm)</label>
|
||||
<input id="etikett-h" type="number" min="15" max="200" step="0.1" value="40">
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions"><button type="button" id="etikett-save" hidden>Speichern</button></div>
|
||||
<p id="etikett-note"></p>
|
||||
</section>
|
||||
|
||||
<section class="card admin-only" id="sec-drucker">
|
||||
<h2>Drucker</h2>
|
||||
<p class="hint">Gesperrt = kein neuer „wenn frei“-Start. Zeitfenster begrenzt Starts (z. B. Nachtstrom). Tippen öffnet den Dialog.</p>
|
||||
<p class="hint">USB (Anycubic i3, Tenlog): Schnittstelle statt IP, zum Beispiel <code>COM5</code> oder <code>COM5@250000</code>. Grotti startet den USB-Dienst dazu selbst und lässt ihn bei einem Neustart weiterlaufen.</p>
|
||||
<p class="hint" id="marlin-line" hidden></p>
|
||||
<div id="printer-list" class="printer-edit-cards"></div>
|
||||
<form id="printer-add" class="grid-2 section-gap">
|
||||
<div class="field">
|
||||
@@ -110,7 +137,7 @@
|
||||
<input id="add-name" required placeholder="z. B. Kobra 3 Werkstatt">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="add-host">Host / IP</label>
|
||||
<label for="add-host" id="add-host-label">Host / IP</label>
|
||||
<input id="add-host" required placeholder="10.10.1.60">
|
||||
</div>
|
||||
<div class="field">
|
||||
@@ -119,6 +146,9 @@
|
||||
<option value="">automatisch</option>
|
||||
<option value="kobra_3">Kobra 3</option>
|
||||
<option value="kobra_s1">Kobra S1</option>
|
||||
<option value="kobra_4">Kobra 4</option>
|
||||
<option value="anycubic_i3">Anycubic i3</option>
|
||||
<option value="tenlog">Tenlog</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="actions" style="align-self:end"><button type="submit">Hinzufügen</button></div>
|
||||
@@ -126,6 +156,13 @@
|
||||
<p id="printer-note"></p>
|
||||
</section>
|
||||
|
||||
<section class="card admin-only" id="sec-meldungen">
|
||||
<h2>Problem-Meldungen</h2>
|
||||
<p class="hint">Kopien von „Problem melden“. Offene Einträge hier erledigen. Issues landen beim Entwickler über den Empfänger auf git.gundel.dev.</p>
|
||||
<div id="meldung-list" class="printer-edit-cards"></div>
|
||||
<p id="meldung-admin-note" class="tiny"></p>
|
||||
</section>
|
||||
|
||||
<section class="card admin-only" id="sec-telegram">
|
||||
<h2>Telegram</h2>
|
||||
<label class="check"><input type="checkbox" id="tg-enabled"> Benachrichtigungen aktiv</label>
|
||||
@@ -272,11 +309,14 @@
|
||||
<input type="hidden" id="pe-id">
|
||||
<div class="grid-2">
|
||||
<div class="field"><label for="pe-name">Name</label><input id="pe-name" required></div>
|
||||
<div class="field"><label for="pe-host">Host / IP</label><input id="pe-host" required></div>
|
||||
<div class="field"><label for="pe-host" id="pe-host-label">Host / IP</label><input id="pe-host" required placeholder="10.10.1.60"></div>
|
||||
<div class="field"><label for="pe-family">Familie</label>
|
||||
<select id="pe-family">
|
||||
<option value="kobra_3">Kobra 3</option>
|
||||
<option value="kobra_s1">Kobra S1</option>
|
||||
<option value="kobra_4">Kobra 4</option>
|
||||
<option value="anycubic_i3">Anycubic i3</option>
|
||||
<option value="tenlog">Tenlog</option>
|
||||
<option value="">—</option>
|
||||
</select>
|
||||
</div>
|
||||
@@ -299,7 +339,18 @@
|
||||
{% block script %}
|
||||
<script src="{{ url_for('static', filename='qrcode.min.js') }}?v=1"></script>
|
||||
<script>
|
||||
const families = { kobra_3: "Kobra 3", kobra_s1: "Kobra S1", "": "—" };
|
||||
const families = {
|
||||
kobra_3: "Kobra 3", kobra_s1: "Kobra S1", kobra_4: "Kobra 4",
|
||||
anycubic_i3: "Anycubic i3", tenlog: "Tenlog", "": "—",
|
||||
};
|
||||
const USB_FAMILIES = new Set(["anycubic_i3", "tenlog"]);
|
||||
|
||||
function syncHostField(family, labelEl, inputEl, nameHint) {
|
||||
const usb = USB_FAMILIES.has(family)
|
||||
|| (!family && /tenlog|i3/i.test(nameHint || ""));
|
||||
if (labelEl) labelEl.textContent = usb ? "Schnittstelle" : "Host / IP";
|
||||
if (inputEl) inputEl.placeholder = usb ? "COM5 oder COM5@250000" : "10.10.1.60";
|
||||
}
|
||||
const isAdmin = document.querySelector('meta[name="user-role"]')?.content === "admin";
|
||||
document.querySelectorAll(".admin-only").forEach((el) => { el.hidden = !isAdmin; });
|
||||
if (!isAdmin) {
|
||||
@@ -333,6 +384,7 @@ function openPrinterDialog(row) {
|
||||
document.getElementById("pe-name").value = row.name || "";
|
||||
document.getElementById("pe-host").value = row.host || "";
|
||||
document.getElementById("pe-family").value = row.family || "";
|
||||
syncHostField(row.family || "", document.getElementById("pe-host-label"), document.getElementById("pe-host"), row.name || "");
|
||||
document.getElementById("pe-locked").checked = !!row.locked;
|
||||
document.getElementById("pe-win-on").checked = win.enabled;
|
||||
document.getElementById("pe-win-from").value = win.from;
|
||||
@@ -455,6 +507,62 @@ function renderTokens(rows) {
|
||||
});
|
||||
}
|
||||
|
||||
async function loadMeldungen() {
|
||||
const root = document.getElementById("meldung-list");
|
||||
const note = document.getElementById("meldung-admin-note");
|
||||
if (!root || !isAdmin) return;
|
||||
try {
|
||||
const rows = await api("/meldungen?status=offen");
|
||||
if (!rows.length) {
|
||||
root.innerHTML = '<p class="tiny">Keine offenen Meldungen.</p>';
|
||||
return;
|
||||
}
|
||||
root.innerHTML = rows.map((row) => `
|
||||
<article class="printer-edit-card">
|
||||
<div class="grow">
|
||||
<strong>#${row.id}</strong>
|
||||
<span class="tiny">${escapeHtml((row.created_at || "").replace("T", " ").slice(0, 16))} · ${escapeHtml(row.username || "—")} · ${escapeHtml(row.page || "")}</span>
|
||||
<p>${escapeHtml(row.text || "")}</p>
|
||||
<p class="tiny">${row.relay_ok ? "Beim Entwickler angekommen" : ("Lokal: " + escapeHtml(row.relay_message || "Empfänger fehlte"))}</p>
|
||||
</div>
|
||||
<button type="button" class="ghost" data-meldung-done="${row.id}">Erledigt</button>
|
||||
</article>`).join("");
|
||||
root.querySelectorAll("[data-meldung-done]").forEach((button) => {
|
||||
button.addEventListener("click", async () => {
|
||||
button.disabled = true;
|
||||
try {
|
||||
await api("/meldungen/" + button.dataset.meldungDone + "/erledigt", { method: "POST", json: {} });
|
||||
loadMeldungen();
|
||||
} catch (error) {
|
||||
if (note) note.textContent = error.message || String(error);
|
||||
}
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
if (note) note.textContent = error.message || String(error);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMarlin(printers) {
|
||||
const line = document.getElementById("marlin-line");
|
||||
if (!line) return;
|
||||
const usb = (printers || []).some((row) => row.family === "tenlog" || row.family === "anycubic_i3");
|
||||
if (!usb) {
|
||||
line.hidden = true;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const status = await api("/marlin-dienst");
|
||||
line.hidden = false;
|
||||
line.textContent = status.running
|
||||
? "USB-Dienst läuft."
|
||||
: (status.message || "USB-Dienst ist aus.");
|
||||
} catch (err) {
|
||||
line.hidden = false;
|
||||
line.textContent = "USB-Dienst: " + (err.message || "keine Antwort");
|
||||
}
|
||||
}
|
||||
|
||||
async function loadUsers() {
|
||||
if (!isAdmin) return;
|
||||
renderUsers(await api("/users"));
|
||||
@@ -469,7 +577,11 @@ async function load() {
|
||||
api("/einstellungen"),
|
||||
api("/drucker?live=0"),
|
||||
]);
|
||||
if (isAdmin) renderPrinters(printers);
|
||||
if (isAdmin) {
|
||||
renderPrinters(printers);
|
||||
loadMarlin(printers);
|
||||
loadMeldungen();
|
||||
}
|
||||
document.getElementById("auto-queue").checked = settings.auto_queue !== false;
|
||||
const ar = document.getElementById("auto-retry");
|
||||
if (ar) ar.checked = settings.auto_retry !== false;
|
||||
@@ -480,6 +592,7 @@ async function load() {
|
||||
}
|
||||
const fqdn = document.getElementById("fqdn");
|
||||
if (fqdn) fqdn.value = settings.fqdn || "";
|
||||
fillEtikett(settings.etikett || {});
|
||||
const tgEnabled = document.getElementById("tg-enabled");
|
||||
if (tgEnabled) {
|
||||
tgEnabled.checked = !!(settings.telegram && settings.telegram.enabled);
|
||||
@@ -643,6 +756,109 @@ document.getElementById("fqdn-save").addEventListener("click", async () => {
|
||||
}
|
||||
});
|
||||
|
||||
let etikettPresets = [];
|
||||
|
||||
function fillEtikett(etikett) {
|
||||
const w = document.getElementById("etikett-w");
|
||||
const h = document.getElementById("etikett-h");
|
||||
const preset = document.getElementById("etikett-preset");
|
||||
if (!w || !h || !preset) return;
|
||||
etikettPresets = Array.isArray(etikett.presets) ? etikett.presets : etikettPresets;
|
||||
const width = Number(etikett.width_mm) || 70;
|
||||
const height = Number(etikett.height_mm) || 40;
|
||||
w.value = String(width);
|
||||
h.value = String(height);
|
||||
const current = preset.value;
|
||||
preset.innerHTML = '<option value="">Eigene Maße</option>';
|
||||
etikettPresets.forEach((row) => {
|
||||
const option = document.createElement("option");
|
||||
option.value = row.id;
|
||||
option.textContent = row.label;
|
||||
option.dataset.w = String(row.width_mm);
|
||||
option.dataset.h = String(row.height_mm);
|
||||
preset.appendChild(option);
|
||||
});
|
||||
const hit = etikettPresets.find(
|
||||
(row) => Number(row.width_mm) === width && Number(row.height_mm) === height
|
||||
);
|
||||
preset.value = hit ? hit.id : (current && [...preset.options].some((o) => o.value === current) ? current : "");
|
||||
}
|
||||
|
||||
function syncEtikettPresetFromFields() {
|
||||
const preset = document.getElementById("etikett-preset");
|
||||
const width = Number(document.getElementById("etikett-w").value);
|
||||
const height = Number(document.getElementById("etikett-h").value);
|
||||
const hit = etikettPresets.find(
|
||||
(row) => Number(row.width_mm) === width && Number(row.height_mm) === height
|
||||
);
|
||||
if (preset) preset.value = hit ? hit.id : "";
|
||||
}
|
||||
|
||||
document.getElementById("etikett-preset")?.addEventListener("change", () => {
|
||||
const preset = document.getElementById("etikett-preset");
|
||||
const option = preset.selectedOptions[0];
|
||||
if (!option || !option.value) return;
|
||||
document.getElementById("etikett-w").value = option.dataset.w || "70";
|
||||
document.getElementById("etikett-h").value = option.dataset.h || "40";
|
||||
document.getElementById("etikett-save")?.click();
|
||||
});
|
||||
|
||||
["etikett-w", "etikett-h"].forEach((id) => {
|
||||
document.getElementById(id)?.addEventListener("change", () => {
|
||||
syncEtikettPresetFromFields();
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById("etikett-save").addEventListener("click", async () => {
|
||||
try {
|
||||
const out = await api("/einstellungen", {
|
||||
method: "PATCH",
|
||||
json: {
|
||||
etikett: {
|
||||
width_mm: Number(document.getElementById("etikett-w").value) || 70,
|
||||
height_mm: Number(document.getElementById("etikett-h").value) || 40,
|
||||
},
|
||||
},
|
||||
});
|
||||
fillEtikett(out.etikett || {});
|
||||
const e = out.etikett || {};
|
||||
showNote(
|
||||
"etikett-note",
|
||||
null,
|
||||
"Gespeichert — " + (e.width_mm || 70) + " × " + (e.height_mm || 40) + " mm (QR " + (e.qr_mm || 20) + " mm)."
|
||||
);
|
||||
} catch (error) {
|
||||
showNote("etikett-note", error);
|
||||
}
|
||||
});
|
||||
|
||||
function syncAddHost() {
|
||||
syncHostField(
|
||||
document.getElementById("add-family").value,
|
||||
document.getElementById("add-host-label"),
|
||||
document.getElementById("add-host"),
|
||||
document.getElementById("add-name").value,
|
||||
);
|
||||
}
|
||||
document.getElementById("add-family")?.addEventListener("change", syncAddHost);
|
||||
document.getElementById("add-name")?.addEventListener("input", syncAddHost);
|
||||
document.getElementById("pe-family")?.addEventListener("change", () => {
|
||||
syncHostField(
|
||||
document.getElementById("pe-family").value,
|
||||
document.getElementById("pe-host-label"),
|
||||
document.getElementById("pe-host"),
|
||||
document.getElementById("pe-name").value,
|
||||
);
|
||||
});
|
||||
document.getElementById("pe-name")?.addEventListener("input", () => {
|
||||
syncHostField(
|
||||
document.getElementById("pe-family").value,
|
||||
document.getElementById("pe-host-label"),
|
||||
document.getElementById("pe-host"),
|
||||
document.getElementById("pe-name").value,
|
||||
);
|
||||
});
|
||||
|
||||
document.getElementById("printer-add").addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
try {
|
||||
@@ -976,6 +1192,9 @@ document.getElementById("auto-retry")?.addEventListener("change", wireAutosave("
|
||||
document.getElementById(id)?.addEventListener("change", wireAutosave("watch-save", "watch-note"));
|
||||
});
|
||||
document.getElementById("fqdn")?.addEventListener("change", wireAutosave("fqdn-save", "fqdn-note"));
|
||||
["etikett-w", "etikett-h"].forEach((id) => {
|
||||
document.getElementById(id)?.addEventListener("change", wireAutosave("etikett-save", "etikett-note"));
|
||||
});
|
||||
["tg-enabled", "tg-token", "tg-chat", "al-fertig", "al-fehler", "al-offline", "al-queue", "al-ablauf", "al-start"].forEach((id) => {
|
||||
document.getElementById(id)?.addEventListener("change", wireAutosave("tg-save", "tg-note"));
|
||||
});
|
||||
|
||||
@@ -4,90 +4,7 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{ title }}</title>
|
||||
<style>
|
||||
:root { color-scheme: light; }
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
font: 14px/1.35 "Segoe UI", system-ui, sans-serif;
|
||||
color: #111;
|
||||
background: #f3f3f3;
|
||||
}
|
||||
.toolbar {
|
||||
display: flex;
|
||||
gap: 0.6rem;
|
||||
padding: 0.8rem 1rem;
|
||||
background: #1a1a1a;
|
||||
color: #eee;
|
||||
}
|
||||
.toolbar a, .toolbar button {
|
||||
color: #111;
|
||||
background: #f97316;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
padding: 0.4rem 0.85rem;
|
||||
font: inherit;
|
||||
font-weight: 650;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.toolbar a.ghost { background: transparent; color: #eee; border: 1px solid #555; }
|
||||
.sheet {
|
||||
width: 70mm;
|
||||
min-height: 40mm;
|
||||
margin: 1.2rem auto;
|
||||
padding: 3.5mm;
|
||||
background: #fff;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 20mm;
|
||||
gap: 2.5mm;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
.meta { min-width: 0; }
|
||||
.meta h1 {
|
||||
margin: 0 0 2mm;
|
||||
font-size: 4.2mm;
|
||||
letter-spacing: -0.02em;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.meta p { margin: 0 0 1.2mm; font-size: 3.1mm; color: #333; }
|
||||
.meta .code { font-weight: 700; font-variant-numeric: tabular-nums; }
|
||||
.dots { display: inline-flex; vertical-align: middle; margin-right: 1.5mm; }
|
||||
.dot {
|
||||
width: 3.2mm;
|
||||
height: 3.2mm;
|
||||
border-radius: 50%;
|
||||
border: 0.2mm solid rgba(0,0,0,0.25);
|
||||
margin-left: -1mm;
|
||||
}
|
||||
.dot:first-child { margin-left: 0; }
|
||||
.qr-wrap {
|
||||
width: 20mm;
|
||||
height: 20mm;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.qr-wrap canvas {
|
||||
display: block;
|
||||
width: 20mm !important;
|
||||
height: 20mm !important;
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
}
|
||||
@media print {
|
||||
body { background: #fff; }
|
||||
.toolbar { display: none; }
|
||||
.sheet {
|
||||
margin: 0;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
{% include "_etikett_style.html" %}
|
||||
</head>
|
||||
<body>
|
||||
<div class="toolbar">
|
||||
@@ -120,14 +37,15 @@
|
||||
</div>
|
||||
<script src="{{ url_for('static', filename='qrcode.min.js') }}?v=1"></script>
|
||||
<script>
|
||||
const qrMm = {{ (etikett.qr_mm if etikett is defined else 20)|tojson }};
|
||||
QRCode.toCanvas(document.getElementById("qr"), {{ qr_url|tojson }}, {
|
||||
width: 80,
|
||||
margin: 1,
|
||||
color: { dark: "#111111", light: "#ffffff" }
|
||||
}, () => {
|
||||
const node = document.getElementById("qr");
|
||||
node.style.width = "20mm";
|
||||
node.style.height = "20mm";
|
||||
node.style.width = qrMm + "mm";
|
||||
node.style.height = qrMm + "mm";
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
|
||||
@@ -4,83 +4,10 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{ title }}</title>
|
||||
{% include "_etikett_style.html" %}
|
||||
<style>
|
||||
:root { color-scheme: light; }
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
font: 14px/1.35 "Segoe UI", system-ui, sans-serif;
|
||||
color: #111;
|
||||
background: #f3f3f3;
|
||||
}
|
||||
.toolbar {
|
||||
display: flex;
|
||||
gap: 0.6rem;
|
||||
padding: 0.8rem 1rem;
|
||||
background: #1a1a1a;
|
||||
color: #eee;
|
||||
}
|
||||
.toolbar a, .toolbar button {
|
||||
color: #111;
|
||||
background: #f97316;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
padding: 0.4rem 0.85rem;
|
||||
font: inherit;
|
||||
font-weight: 650;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.toolbar a.ghost { background: transparent; color: #eee; border: 1px solid #555; }
|
||||
.sheet {
|
||||
width: 70mm;
|
||||
min-height: 40mm;
|
||||
margin: 1.2rem auto;
|
||||
padding: 3.5mm;
|
||||
background: #fff;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 20mm;
|
||||
gap: 2.5mm;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
page-break-after: always;
|
||||
}
|
||||
.sheet { page-break-after: always; }
|
||||
.sheet:last-child { page-break-after: auto; }
|
||||
.meta { min-width: 0; }
|
||||
.meta h1 {
|
||||
margin: 0 0 2mm;
|
||||
font-size: 4.2mm;
|
||||
letter-spacing: -0.02em;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.meta p { margin: 0 0 1.2mm; font-size: 3.1mm; color: #333; }
|
||||
.meta .code { font-weight: 700; font-variant-numeric: tabular-nums; }
|
||||
.qr-wrap {
|
||||
width: 20mm;
|
||||
height: 20mm;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.qr-wrap canvas,
|
||||
.qr-wrap img {
|
||||
display: block;
|
||||
width: 20mm !important;
|
||||
height: 20mm !important;
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
}
|
||||
@media print {
|
||||
body { background: #fff; }
|
||||
.toolbar { display: none; }
|
||||
.sheet {
|
||||
margin: 0;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -111,14 +38,15 @@
|
||||
{% endfor %}
|
||||
<script src="{{ url_for('static', filename='qrcode.min.js') }}?v=1"></script>
|
||||
<script>
|
||||
const qrMm = {{ (etikett.qr_mm if etikett is defined else 20)|tojson }};
|
||||
document.querySelectorAll("canvas.qr").forEach((node) => {
|
||||
QRCode.toCanvas(node, node.dataset.url, {
|
||||
width: 80,
|
||||
margin: 1,
|
||||
color: { dark: "#111111", light: "#ffffff" }
|
||||
}, () => {
|
||||
node.style.width = "20mm";
|
||||
node.style.height = "20mm";
|
||||
node.style.width = qrMm + "mm";
|
||||
node.style.height = qrMm + "mm";
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -107,6 +107,7 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="topbar-tools">
|
||||
<button type="button" class="icon-btn" id="meldung-open" title="Problem melden" aria-label="Problem melden"><svg class="ico"><use href="#i-alert"/></svg></button>
|
||||
<a class="icon-btn {{ 'on' if page == 'einstellungen' else '' }}" href="/einstellungen" title="Einstellungen" aria-label="Einstellungen"><svg class="ico"><use href="#i-settings"/></svg></a>
|
||||
<a class="icon-btn {{ 'on' if page == 'doku' else '' }}" href="/doku" title="Doku" aria-label="Doku"><svg class="ico"><use href="#i-book"/></svg></a>
|
||||
{% if current_user %}
|
||||
@@ -163,6 +164,22 @@
|
||||
{% endif %}
|
||||
<form method="dialog"><button class="ghost" value="cancel">Schließen</button></form>
|
||||
</dialog>
|
||||
<dialog id="meldung-dlg" class="abraeumen-dlg">
|
||||
<form method="dialog" id="meldung-form" class="abraeumen-form">
|
||||
<h2>Problem melden</h2>
|
||||
<p class="tiny">Kurz beschreiben. Grotti hängt Version und Seite an und speichert eine Kopie hier. Zusätzlich geht die Meldung an den Entwickler.</p>
|
||||
<div class="field">
|
||||
<label for="meldung-text">Was stimmt nicht?</label>
|
||||
<textarea id="meldung-text" rows="5" required maxlength="4000" placeholder="z. B. Start auf dem Tenlog bricht ab…"></textarea>
|
||||
</div>
|
||||
<p class="tiny" id="meldung-error-hint" hidden></p>
|
||||
<div class="actions">
|
||||
<button type="submit" class="btn-primary" id="meldung-send">Senden</button>
|
||||
<button type="button" class="ghost" id="meldung-cancel">Abbrechen</button>
|
||||
</div>
|
||||
<p id="meldung-note" class="tiny"></p>
|
||||
</form>
|
||||
</dialog>
|
||||
<script src="{{ url_for('static', filename='api.js') }}?v=33"></script>
|
||||
<script src="{{ url_for('static', filename='ui.js') }}?v=6"></script>
|
||||
<script>
|
||||
@@ -172,6 +189,80 @@ async function doLogout() {
|
||||
}
|
||||
document.getElementById("logout-btn")?.addEventListener("click", doLogout);
|
||||
document.getElementById("logout-btn-m")?.addEventListener("click", doLogout);
|
||||
|
||||
(function meldungUi() {
|
||||
const dlg = document.getElementById("meldung-dlg");
|
||||
const form = document.getElementById("meldung-form");
|
||||
const text = document.getElementById("meldung-text");
|
||||
const errHint = document.getElementById("meldung-error-hint");
|
||||
const note = document.getElementById("meldung-note");
|
||||
if (!dlg || !form) return;
|
||||
let lastError = "";
|
||||
|
||||
function openMeldung(prefill) {
|
||||
note.textContent = "";
|
||||
note.className = "tiny";
|
||||
if (prefill) text.value = prefill;
|
||||
if (lastError) {
|
||||
errHint.hidden = false;
|
||||
errHint.textContent = "Letzter Fehler: " + lastError.slice(0, 240);
|
||||
} else {
|
||||
errHint.hidden = true;
|
||||
errHint.textContent = "";
|
||||
}
|
||||
dlg.showModal();
|
||||
text.focus();
|
||||
}
|
||||
|
||||
window.openMeldung = openMeldung;
|
||||
window.rememberUiError = (message) => {
|
||||
lastError = String(message || "").slice(0, 2000);
|
||||
};
|
||||
|
||||
document.getElementById("meldung-open")?.addEventListener("click", () => openMeldung(""));
|
||||
document.getElementById("meldung-cancel")?.addEventListener("click", () => dlg.close());
|
||||
form.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
const button = document.getElementById("meldung-send");
|
||||
button.disabled = true;
|
||||
try {
|
||||
const desk = document.querySelector("#desk[data-host]:not([hidden])");
|
||||
const host = desk?.dataset?.host || "";
|
||||
const row = await api("/meldungen", {
|
||||
method: "POST",
|
||||
json: {
|
||||
text: text.value,
|
||||
page: location.pathname + location.search + location.hash,
|
||||
printer_host: host,
|
||||
error_text: lastError,
|
||||
},
|
||||
});
|
||||
if (row.relay_ok) {
|
||||
toast("Meldung gespeichert und an den Entwickler geschickt.", "good");
|
||||
} else {
|
||||
toast("Lokal gespeichert. Beim Entwickler nicht angekommen: " + (row.relay_message || ""), "bad");
|
||||
}
|
||||
text.value = "";
|
||||
lastError = "";
|
||||
dlg.close();
|
||||
} catch (error) {
|
||||
note.textContent = error.message || String(error);
|
||||
note.className = "tiny msg";
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener("error", (event) => {
|
||||
const message = event?.message || String(event?.error || "");
|
||||
if (message) lastError = message.slice(0, 2000);
|
||||
});
|
||||
window.addEventListener("unhandledrejection", (event) => {
|
||||
const reason = event?.reason;
|
||||
const message = reason && reason.message ? reason.message : String(reason || "");
|
||||
if (message) lastError = message.slice(0, 2000);
|
||||
});
|
||||
});
|
||||
(function userMenuClose() {
|
||||
const menu = document.getElementById("user-menu");
|
||||
if (!menu) return;
|
||||
@@ -624,8 +715,8 @@ refreshJetzt();
|
||||
aufgaben: () => refreshJetzt(),
|
||||
drucker: () => {
|
||||
refreshJetzt();
|
||||
// Nur onDruckerLive — sonst zeichnen Wand und Dateien doppelt.
|
||||
if (typeof window.onDruckerLive === "function") window.onDruckerLive();
|
||||
if (typeof window.onQueueChanged === "function") window.onQueueChanged();
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -270,12 +270,58 @@ function dots(colors) {
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function slotLabel(slot) {
|
||||
function printerFamily(host) {
|
||||
return (printers.find((p) => p.host === host) || {}).family || "";
|
||||
}
|
||||
|
||||
function slotLabel(slot, host) {
|
||||
if (!slot) return "";
|
||||
const family = printerFamily(host);
|
||||
if (family === "tenlog") return "Kopf " + slot;
|
||||
if (family === "anycubic_i3") return "am Drucker";
|
||||
if (slot <= 4) return "ACE 1 · " + slot;
|
||||
return "ACE 2 · " + (slot - 4);
|
||||
}
|
||||
|
||||
function fillSlotOptions(host, keep) {
|
||||
const select = document.getElementById("spool-slot");
|
||||
const label = document.querySelector('label[for="spool-slot"]');
|
||||
const current = keep != null ? String(keep) : select.value;
|
||||
const family = printerFamily(host);
|
||||
let options = [['', 'nicht im ACE']];
|
||||
let tip = "ACE 1 = Plätze 1–4, ACE 2 = 5–8. Leer = externe Spule am Drucker. Ohne Drucker = Regal.";
|
||||
if (!host) {
|
||||
options = [['', 'Regal — kein Platz']];
|
||||
tip = "Ohne Drucker liegt die Rolle im Regal.";
|
||||
} else if (family === "tenlog") {
|
||||
options = [['', 'kein Kopf'], ['1', 'Kopf 1'], ['2', 'Kopf 2']];
|
||||
tip = "Tenlog: Kopf 1 oder Kopf 2. Leer = Rolle am Drucker ohne festen Kopf.";
|
||||
} else if (family === "anycubic_i3") {
|
||||
options = [['', 'am Drucker']];
|
||||
tip = "Anycubic i3 hat keinen ACE — die Rolle liegt am Drucker.";
|
||||
} else if (["kobra_3", "kobra_s1", "kobra_4"].includes(family)) {
|
||||
options = [
|
||||
['', 'nicht im ACE'],
|
||||
['1', 'ACE 1 · Platz 1'], ['2', 'ACE 1 · Platz 2'], ['3', 'ACE 1 · Platz 3'], ['4', 'ACE 1 · Platz 4'],
|
||||
['5', 'ACE 2 · Platz 1'], ['6', 'ACE 2 · Platz 2'], ['7', 'ACE 2 · Platz 3'], ['8', 'ACE 2 · Platz 4'],
|
||||
];
|
||||
} else {
|
||||
options = [['', 'am Drucker']];
|
||||
tip = "Platz nur bei ACE-Druckern.";
|
||||
}
|
||||
select.innerHTML = options.map(([value, text]) =>
|
||||
`<option value="${escapeHtml(value)}">${escapeHtml(text)}</option>`
|
||||
).join("");
|
||||
if ([...select.options].some((o) => o.value === current)) select.value = current;
|
||||
else select.value = "";
|
||||
if (label) {
|
||||
const tipNode = label.querySelector(".tip");
|
||||
if (tipNode) tipNode.title = tip;
|
||||
const title = family === "tenlog" ? "Kopf" : family === "anycubic_i3" ? "Lage" : "ACE-Platz";
|
||||
label.childNodes[0].textContent = title + " ";
|
||||
}
|
||||
}
|
||||
|
||||
function printerName(host) {
|
||||
return (printers.find((p) => p.host === host) || {}).name || host || "";
|
||||
}
|
||||
@@ -283,7 +329,7 @@ function printerName(host) {
|
||||
function ortLabel(spool) {
|
||||
if (!spool.printer_host) return "Regal";
|
||||
const name = printerName(spool.printer_host);
|
||||
if (spool.ace_slot) return name + " · " + slotLabel(spool.ace_slot);
|
||||
if (spool.ace_slot) return name + " · " + slotLabel(spool.ace_slot, spool.printer_host);
|
||||
return name + " · extern";
|
||||
}
|
||||
|
||||
@@ -556,7 +602,7 @@ function fill(spool) {
|
||||
document.getElementById("spool-chargen").value = spool ? (spool.chargen_nr || spool.code || "") : "";
|
||||
document.getElementById("spool-hersteller").value = spool ? (spool.hersteller_charge || "") : "";
|
||||
document.getElementById("spool-printer").value = spool ? spool.printer_host : "";
|
||||
document.getElementById("spool-slot").value = spool && spool.ace_slot ? String(spool.ace_slot) : "";
|
||||
fillSlotOptions(spool ? spool.printer_host : "", spool && spool.ace_slot ? String(spool.ace_slot) : "");
|
||||
document.getElementById("spool-price").value = spool ? spool.price_per_kg : 0;
|
||||
document.getElementById("spool-left").value = spool
|
||||
? Math.round((Number(spool.remaining_g) || 0) * 10) / 10
|
||||
@@ -676,6 +722,7 @@ function fillSelects() {
|
||||
select.appendChild(option);
|
||||
});
|
||||
select.value = current;
|
||||
fillSlotOptions(select.value);
|
||||
|
||||
const materialSelect = document.getElementById("spool-material");
|
||||
const currentMaterial = materialSelect.value;
|
||||
@@ -905,6 +952,7 @@ document.getElementById("sorten-close").addEventListener("click", closeSortenPan
|
||||
|
||||
document.getElementById("spool-material").addEventListener("change", () => applyMaterial(selectedMaterial(), true));
|
||||
document.getElementById("spool-printer").addEventListener("change", () => {
|
||||
fillSlotOptions(document.getElementById("spool-printer").value);
|
||||
const material = selectedMaterial();
|
||||
if (material) document.getElementById("spool-vol").value = volumeOf(material);
|
||||
showHint();
|
||||
@@ -1134,8 +1182,7 @@ load().then(() => {
|
||||
document.getElementById("spool-new")?.click?.();
|
||||
const hostField = document.getElementById("spool-printer");
|
||||
if (hostField) hostField.value = hostHint;
|
||||
const slotField = document.getElementById("spool-slot");
|
||||
if (slotField && slotHint) slotField.value = String(slotHint);
|
||||
fillSlotOptions(hostHint, slotHint ? String(slotHint) : "");
|
||||
document.getElementById("spool-name")?.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
toast("Drucker und Platz vorausgefüllt — Rolle wählen und speichern.");
|
||||
}
|
||||
|
||||
@@ -81,11 +81,26 @@ function shortRunName(filename) {
|
||||
return name || (filename || "").split("/").pop() || "";
|
||||
}
|
||||
|
||||
function render() {
|
||||
let lastRunsKey = "";
|
||||
|
||||
function runsKey(rows) {
|
||||
return (rows || []).map((run) => [
|
||||
run.id, run.state, run.duration_s, run.pause_s, run.weight_used_g,
|
||||
run.verdict, run.spool_id, run.merge_with_id, run.chargen_nr,
|
||||
].join(":")).join("|");
|
||||
}
|
||||
|
||||
function render(force) {
|
||||
fillPrinterFilter();
|
||||
const body = document.getElementById("run-rows");
|
||||
body.innerHTML = "";
|
||||
const shown = filteredRuns();
|
||||
const key = runsKey(shown) + "|" + runPage + "|"
|
||||
+ (document.getElementById("run-filter-printer")?.value || "")
|
||||
+ "|" + (document.getElementById("run-filter-verdict")?.value || "")
|
||||
+ "|" + (document.getElementById("run-filter-spool")?.value || "");
|
||||
if (!force && key === lastRunsKey && body.children.length) return;
|
||||
lastRunsKey = key;
|
||||
body.innerHTML = "";
|
||||
if (!runs.length) {
|
||||
body.innerHTML = '<tr><td class="empty" colspan="9"><div class="empty-state">' + icon("list") + '<p>Noch kein Lauf.</p><p class="tiny">„Läufe holen“ liest die Drucker aus.</p></div></td></tr>';
|
||||
renderPager(document.getElementById("run-pager"), null, () => {});
|
||||
@@ -324,13 +339,13 @@ function render() {
|
||||
});
|
||||
}
|
||||
|
||||
async function load() {
|
||||
async function load(resetPage) {
|
||||
[runs, spools] = await Promise.all([api("/laeufe?limit=500"), api("/spulen")]);
|
||||
runPage = 1;
|
||||
if (resetPage !== false) runPage = 1;
|
||||
render();
|
||||
}
|
||||
window.onDruckerLive = () => { load().catch(() => {}); };
|
||||
window.onQueueChanged = () => { load().catch(() => {}); };
|
||||
window.onDruckerLive = () => { load(false).catch(() => {}); };
|
||||
window.onQueueChanged = () => { load(false).catch(() => {}); };
|
||||
|
||||
["run-filter-printer", "run-filter-verdict", "run-filter-spool"].forEach((id) => {
|
||||
document.getElementById(id).addEventListener("change", () => { runPage = 1; render(); });
|
||||
|
||||
+20
-7
@@ -266,7 +266,13 @@ function tempbarHtml(label, value, target, scale, iconName) {
|
||||
|
||||
function tempbarsHtml(printer, state) {
|
||||
if (state === "offline") return "";
|
||||
const nozzle = tempbarHtml("Düse", printer.nozzle, printer.nozzle_target, 300, "nozzle");
|
||||
const tools = Array.isArray(printer.tools) ? printer.tools : [];
|
||||
const nozzle = tools.length > 1
|
||||
? tools.map((tool) => tempbarHtml(
|
||||
tool.name || ("Kopf " + (Number(tool.index) + 1)),
|
||||
tool.temp, tool.target, 300, "nozzle",
|
||||
)).join("")
|
||||
: tempbarHtml("Düse", printer.nozzle, printer.nozzle_target, 300, "nozzle");
|
||||
const bed = tempbarHtml("Bett", printer.bed, printer.bed_target, 110, "bed");
|
||||
const chamber = tempbarHtml("Bauraum", printer.chamber, printer.chamber_target, 80, "chamber");
|
||||
if (!nozzle && !bed && !chamber) return "";
|
||||
@@ -443,8 +449,10 @@ function spoolHex(spool) {
|
||||
return "";
|
||||
}
|
||||
|
||||
function aceSlotLabel(slot) {
|
||||
function aceSlotLabel(slot, family) {
|
||||
const n = Number(slot);
|
||||
if (family === "tenlog") return "Kopf " + n;
|
||||
if (family === "anycubic_i3") return "am Drucker";
|
||||
if (n <= 4) return "ACE 1 · " + n;
|
||||
return "ACE 2 · " + (n - 4);
|
||||
}
|
||||
@@ -489,7 +497,7 @@ function aceSteerHtml(printer) {
|
||||
const kind = spool.material_type || "—";
|
||||
bits.push(`<div class="steer-slot-row">
|
||||
<span class="dot" style="background:${escapeHtml(hex)}"></span>
|
||||
<span class="grow"><b>${escapeHtml(aceSlotLabel(slot))}</b> · ${escapeHtml(kind)} · ${escapeHtml(spool.name || "")}</span>
|
||||
<span class="grow"><b>${escapeHtml(aceSlotLabel(slot, printer.family))}</b> · ${escapeHtml(kind)} · ${escapeHtml(spool.name || "")}</span>
|
||||
<button type="button" class="ghost" data-steer-go="slot_setzen" data-ace-slot="${slot}">an den Drucker</button>
|
||||
</div>`);
|
||||
});
|
||||
@@ -499,6 +507,7 @@ function aceSteerHtml(printer) {
|
||||
}
|
||||
|
||||
function steerPanelHtml(printer) {
|
||||
if (printer.family === "tenlog" || printer.family === "anycubic_i3") return "";
|
||||
const n = printer.nozzle_target != null ? Math.round(printer.nozzle_target) : "";
|
||||
const b = printer.bed_target != null ? Math.round(printer.bed_target) : "";
|
||||
const sp = typeof printer.speed_factor === "number" ? Math.round(printer.speed_factor * 100) : 100;
|
||||
@@ -1590,8 +1599,8 @@ async function refresh() {
|
||||
printerCache.clear();
|
||||
for (const printer of all) printerCache.set(printer.host, printer);
|
||||
deskHostsOrder = printers.map((p) => p.host);
|
||||
document.getElementById("summary").innerHTML = summaryText(all) || "keine Drucker";
|
||||
document.getElementById("when").textContent = new Date().toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" });
|
||||
setHtml(document.getElementById("summary"), summaryText(all) || "keine Drucker");
|
||||
setText(document.getElementById("when"), new Date().toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" }));
|
||||
|
||||
if (!all.length) {
|
||||
lastFleetKey = "";
|
||||
@@ -1614,7 +1623,6 @@ async function refresh() {
|
||||
bucket.appendChild(article);
|
||||
} else {
|
||||
patchCard(article, printer);
|
||||
if (article.parentElement !== bucket) bucket.appendChild(article);
|
||||
}
|
||||
}
|
||||
for (const [host, article] of [...cardNodes.entries()]) {
|
||||
@@ -1628,9 +1636,14 @@ async function refresh() {
|
||||
? document.querySelector("#cards [data-fleet]")
|
||||
: document.querySelector(`#cards [data-fleet="${CSS.escape(section.label)}"]`);
|
||||
if (!bucket) continue;
|
||||
let marker = bucket.firstChild;
|
||||
for (const printer of section.printers) {
|
||||
const article = cardNodes.get(printer.host);
|
||||
if (article) bucket.appendChild(article);
|
||||
if (!article) continue;
|
||||
if (article !== marker) {
|
||||
bucket.insertBefore(article, marker);
|
||||
}
|
||||
marker = article.nextSibling;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user