Grotti 2.5.1: Druckbericht, Statistik, Spulenakte und Protokoll-Fixes.
This commit is contained in:
1 parent
b277ca48aa
commit
efd0801cd1
30 files changed
+3393
-178
No files matched your search
+202
-10
@@ -37,7 +37,7 @@ IDEA_STATUS = {"offen", "spaeter", "erledigt"}
|
||||
PRINTER_FAMILIES = set(FAMILY_LABELS) | {""}
|
||||
STEER_ACTIONS = {
|
||||
"nozzle", "bed", "speed", "flow", "fan", "emergency_stop", "clear_error",
|
||||
"exclude_object",
|
||||
"exclude_object", "dryer_start", "dryer_stop", "slot_setzen",
|
||||
}
|
||||
|
||||
|
||||
@@ -580,6 +580,58 @@ def create_app(cfg):
|
||||
def protokoll():
|
||||
return page("protokoll.html", "Protokoll")
|
||||
|
||||
@app.get("/protokoll/<int:run_id>")
|
||||
def protokoll_detail(run_id):
|
||||
row = store.get_run_row(conn(), run_id)
|
||||
if not row:
|
||||
abort(404)
|
||||
return page("bericht.html", "Druckbericht")
|
||||
|
||||
@app.get("/statistik")
|
||||
def statistik_page():
|
||||
return page("statistik.html", "Statistik")
|
||||
|
||||
@app.get("/etikett/lauf/<int:run_id>")
|
||||
def etikett_lauf(run_id):
|
||||
row = store.get_run_row(conn(), run_id)
|
||||
if not row:
|
||||
abort(404)
|
||||
run = store.run_out(row)
|
||||
if not run.get("chargen_nr"):
|
||||
store.ensure_run_chargen(conn(), run_id, run.get("weight_used_g"))
|
||||
row = store.get_run_row(conn(), run_id)
|
||||
run = store.run_out(row)
|
||||
if not run.get("chargen_nr"):
|
||||
abort(404)
|
||||
labels = store.list_run_labels(conn(), run_id)
|
||||
mode = (request.args.get("mode") or "druck").strip()
|
||||
qty = max(1, min(50, int(request.args.get("qty") or 1)))
|
||||
if mode == "objekt":
|
||||
labels = store.ensure_run_labels(conn(), run_id, run.get("objects") or [], qty=qty)
|
||||
sheets = [lab for lab in labels if not lab.get("excluded")]
|
||||
else:
|
||||
sheets = [{"object_name": "", "chargen_nr": run["chargen_nr"], "excluded": False}]
|
||||
if not sheets:
|
||||
abort(404)
|
||||
spools = store.etikett_spools_for_run(conn(), run)
|
||||
base = grotti_config.public_base(cfg, request.url_root)
|
||||
target_g = float(run.get("weight_planned_g") or run.get("weight_g") or 0)
|
||||
for sheet in sheets:
|
||||
sheet["qr_url"] = "%s/protokoll/%s" % (base, run_id)
|
||||
sheet["target_g"] = target_g
|
||||
if sheet.get("object_name"):
|
||||
sheet["qr_url"] += "#objekt-%s" % quote(sheet["object_name"])
|
||||
return render_template(
|
||||
"etikett_lauf.html",
|
||||
page="etikett",
|
||||
title="Etikett %s" % run["chargen_nr"],
|
||||
token="",
|
||||
run=run,
|
||||
sheets=sheets,
|
||||
spools=spools,
|
||||
target_g=target_g,
|
||||
)
|
||||
|
||||
@app.get("/ideen")
|
||||
def ideen():
|
||||
return page("ideen.html", "Ideen")
|
||||
@@ -672,6 +724,9 @@ def create_app(cfg):
|
||||
item["queue_failed"] = queue.get("failed")
|
||||
item["queue_waiting"] = queue.get("waiting", 0)
|
||||
item["queue_waiting_next"] = queue.get("waiting_next", "")
|
||||
live_run = store.find_live_run(conn(), host) if host else None
|
||||
item["live_run_id"] = (live_run or {}).get("id")
|
||||
item["live_chargen"] = (live_run or {}).get("chargen_nr") or ""
|
||||
|
||||
by_id = {row["host"]: row for row in rows}
|
||||
if request.args.get("live", "1") == "0":
|
||||
@@ -873,7 +928,7 @@ def create_app(cfg):
|
||||
row = store.get_file_row(conn(), file_id)
|
||||
if not row:
|
||||
abort(404)
|
||||
png = library.preview_png(row)
|
||||
png = library.preview_png(row, conn=conn(), data_dir=cfg["data_dir"])
|
||||
if not png:
|
||||
abort(404)
|
||||
return png, 200, {"Content-Type": "image/png", "Cache-Control": "private, max-age=300"}
|
||||
@@ -883,8 +938,8 @@ def create_app(cfg):
|
||||
row = store.get_file_row(conn(), file_id)
|
||||
if not row:
|
||||
raise GrottiError("Die Datei fehlt.", "datei", 404)
|
||||
path = Path(row["stored_path"])
|
||||
if not path.is_file():
|
||||
path = library.resolve_stored_path(conn(), row, cfg["data_dir"])
|
||||
if path is None or not path.is_file():
|
||||
raise GrottiError("Die Datei liegt nicht mehr auf der Platte.", "datei", 404)
|
||||
cache_dir = Path(cfg["data_dir"]) / "toolpath"
|
||||
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
@@ -1506,11 +1561,28 @@ def create_app(cfg):
|
||||
|
||||
@app.get("/api/v1/spulen/by-code/<path:code>")
|
||||
def spool_by_code(code):
|
||||
row = store.get_spool_by_code(conn(), code)
|
||||
text = (code or "").strip()
|
||||
if text.upper().startswith("D-"):
|
||||
run = store.get_run_by_chargen(conn(), text)
|
||||
if not run:
|
||||
raise GrottiError("Kein Druck mit dieser Charge.", "charge", 404)
|
||||
return jsonify({"kind": "lauf", "run": store.run_out(run), "redirect": "/protokoll/%s" % run["id"]})
|
||||
row = store.get_spool_by_code(conn(), text)
|
||||
if not row:
|
||||
raise GrottiError("Keine Spule mit diesem Code.", "spule", 404)
|
||||
return jsonify(row)
|
||||
|
||||
@app.get("/api/v1/spulen/<int:spool_id>/akte")
|
||||
def spool_akte(spool_id):
|
||||
spool = store.get_spool_row(conn(), spool_id)
|
||||
if not spool:
|
||||
raise GrottiError("Die Spule fehlt.", "spule", 404)
|
||||
return jsonify({
|
||||
"spool": store.spool_out(spool),
|
||||
"runs": store.list_spool_runs(conn(), spool_id),
|
||||
"stats": store.spool_usage_stats(conn(), spool_id),
|
||||
})
|
||||
|
||||
@app.post("/api/v1/spulen")
|
||||
def spool_create():
|
||||
return jsonify(store.create_spool(conn(), spool_fields(body()))), 201
|
||||
@@ -1531,6 +1603,18 @@ def create_app(cfg):
|
||||
spool = store.get_spool_row(conn(), spool_id)
|
||||
if not spool:
|
||||
raise GrottiError("Die Spule fehlt.", "spule", 404)
|
||||
live_run = store.find_live_run(conn(), host)
|
||||
mm_now = float((live_run or {}).get("filament_used_mm") or 0)
|
||||
printing = bool(
|
||||
live_run and (live_run.get("state") or "") in ("in_progress", "printing", "paused")
|
||||
)
|
||||
if printing:
|
||||
try:
|
||||
snap = moonraker.query(host)
|
||||
if isinstance(snap.get("filament_used"), (int, float)):
|
||||
mm_now = float(snap["filament_used"])
|
||||
except Exception:
|
||||
pass
|
||||
# Alte Rolle am Platz / externe Rolle am Host freimachen
|
||||
if slot:
|
||||
old = conn().execute(
|
||||
@@ -1542,22 +1626,32 @@ def create_app(cfg):
|
||||
"SELECT * FROM spools WHERE printer_host = %s AND ace_slot IS NULL AND id <> %s",
|
||||
(host, spool_id),
|
||||
).fetchall()
|
||||
printer = store.printer_by_host(conn(), host) or {}
|
||||
if printing:
|
||||
closed = store.close_open_segments(conn(), live_run["id"], slot, mm_now)
|
||||
if not closed and old:
|
||||
# Erster Wechsel: alter Abschnitt von 0 bis jetzt
|
||||
for row in old:
|
||||
store.open_segment(conn(), live_run["id"], row["id"], slot, 0)
|
||||
store.close_open_segments(conn(), live_run["id"], slot, mm_now)
|
||||
for row in old:
|
||||
store.update_spool(conn(), row["id"], {"printer_host": "", "ace_slot": None})
|
||||
store.add_event(
|
||||
conn(),
|
||||
host,
|
||||
(store.printer_by_host(conn(), host) or {}).get("name") or host,
|
||||
printer.get("name") or host,
|
||||
"spule",
|
||||
"%s: „%s“ ins Regal%s" % (
|
||||
(store.printer_by_host(conn(), host) or {}).get("name") or host,
|
||||
printer.get("name") or host,
|
||||
row["name"],
|
||||
(" (Platz %s)" % slot) if slot else "",
|
||||
),
|
||||
"",
|
||||
(live_run or {}).get("filename") or "",
|
||||
run_id=(live_run or {}).get("id"),
|
||||
)
|
||||
updated = store.update_spool(conn(), spool_id, {"printer_host": host, "ace_slot": slot})
|
||||
printer = store.printer_by_host(conn(), host) or {}
|
||||
if printing:
|
||||
store.open_segment(conn(), live_run["id"], spool_id, slot, mm_now)
|
||||
store.add_event(
|
||||
conn(),
|
||||
host,
|
||||
@@ -1568,7 +1662,8 @@ def create_app(cfg):
|
||||
updated["name"],
|
||||
(" auf Platz %s" % slot) if slot else "",
|
||||
),
|
||||
"",
|
||||
(live_run or {}).get("filename") or "",
|
||||
run_id=(live_run or {}).get("id"),
|
||||
)
|
||||
return jsonify(updated)
|
||||
|
||||
@@ -1704,6 +1799,36 @@ def create_app(cfg):
|
||||
def run_list():
|
||||
return jsonify(store.list_runs(conn()))
|
||||
|
||||
@app.get("/api/v1/laeufe/<int:run_id>")
|
||||
def run_get(run_id):
|
||||
row = store.get_run_row(conn(), run_id)
|
||||
if not row:
|
||||
raise GrottiError("Der Lauf fehlt.", "lauf", 404)
|
||||
run = store.run_out(row)
|
||||
if not run.get("chargen_nr"):
|
||||
store.ensure_run_chargen(conn(), run_id)
|
||||
row = store.get_run_row(conn(), run_id) or row
|
||||
run = store.run_out(row)
|
||||
if run.get("chargen_nr") and run.get("objects"):
|
||||
store.ensure_run_labels(conn(), run_id, run.get("objects") or [], qty=1)
|
||||
file_row = store.get_file_row(conn(), run["file_id"]) if run.get("file_id") else None
|
||||
project = store.get_project(conn(), run["project_id"]) if run.get("project_id") else None
|
||||
samples = store.list_run_samples(conn(), run_id)
|
||||
# Anzeige auf ca. 1 Punkt / Minute verdichten
|
||||
if len(samples) > 180:
|
||||
step = max(1, len(samples) // 120)
|
||||
samples = samples[::step]
|
||||
return jsonify({
|
||||
"run": run,
|
||||
"spools": store.list_run_spools(conn(), run_id),
|
||||
"segments": store.list_run_segments(conn(), run_id),
|
||||
"samples": samples,
|
||||
"labels": store.list_run_labels(conn(), run_id),
|
||||
"events": store.list_run_events(conn(), run_id),
|
||||
"file": store.file_out(file_row) if file_row else None,
|
||||
"project": project,
|
||||
})
|
||||
|
||||
@app.post("/api/v1/laeufe/abgleich")
|
||||
def run_sync():
|
||||
library.sync_watch(conn(), cfg, force=False)
|
||||
@@ -1726,4 +1851,71 @@ def create_app(cfg):
|
||||
row = store.run_out(current)
|
||||
return jsonify(row)
|
||||
|
||||
@app.get("/api/v1/statistik")
|
||||
def statistik_api():
|
||||
since = request.args.get("since") or None
|
||||
until = request.args.get("until") or None
|
||||
host = (request.args.get("host") or "").strip()
|
||||
material = (request.args.get("material") or "").strip()
|
||||
brand = (request.args.get("brand") or "").strip()
|
||||
project_id = optional_id(request.args.get("project_id"))
|
||||
return jsonify(store.statistics(
|
||||
conn(),
|
||||
since=since,
|
||||
until=until,
|
||||
host=host,
|
||||
material=material or "",
|
||||
brand=brand or "",
|
||||
project_id=project_id,
|
||||
))
|
||||
|
||||
@app.get("/api/v1/statistik.csv")
|
||||
def statistik_csv():
|
||||
import csv
|
||||
import io
|
||||
from flask import Response
|
||||
|
||||
since = request.args.get("since") or None
|
||||
until = request.args.get("until") or None
|
||||
host = (request.args.get("host") or "").strip()
|
||||
material = (request.args.get("material") or "").strip()
|
||||
brand = (request.args.get("brand") or "").strip()
|
||||
runs = store.list_runs(
|
||||
conn(),
|
||||
limit=2000,
|
||||
since=since,
|
||||
until=until,
|
||||
host=host,
|
||||
material=material,
|
||||
brand=brand,
|
||||
)
|
||||
buf = io.StringIO()
|
||||
writer = csv.writer(buf, delimiter=";")
|
||||
writer.writerow([
|
||||
"id", "drucker", "datei", "zustand", "start", "ende", "dauer_s",
|
||||
"soll_g", "ist_g", "quelle", "filament_eur", "strom_eur", "urteil", "charge",
|
||||
])
|
||||
for run in runs:
|
||||
writer.writerow([
|
||||
run["id"],
|
||||
run.get("printer_name") or "",
|
||||
run.get("filename") or "",
|
||||
run.get("state") or "",
|
||||
run.get("started_at") or "",
|
||||
run.get("ended_at") or "",
|
||||
run.get("duration_s") or 0,
|
||||
run.get("weight_planned_g") or 0,
|
||||
run.get("weight_used_g") or 0,
|
||||
run.get("weight_source") or "",
|
||||
run.get("filament_cost") or 0,
|
||||
run.get("energy_cost") or 0,
|
||||
run.get("verdict") or "",
|
||||
run.get("chargen_nr") or "",
|
||||
])
|
||||
return Response(
|
||||
buf.getvalue(),
|
||||
mimetype="text/csv; charset=utf-8",
|
||||
headers={"Content-Disposition": "attachment; filename=grotti-laeufe.csv"},
|
||||
)
|
||||
|
||||
return app
|
||||
+119
-1
@@ -405,6 +405,36 @@ body.shelf [data-role="hero"] { font-size: 1.35rem; }
|
||||
padding: 0.25rem 0.35rem;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
.steer-ace,
|
||||
.steer-ace-slots {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
padding-top: 0.35rem;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
.steer-ace-head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem 0.65rem;
|
||||
align-items: baseline;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
.steer-ace-head b { font-weight: 650; }
|
||||
.steer-slot-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem 0.55rem;
|
||||
align-items: center;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
.steer-slot-row .dot {
|
||||
width: 0.85rem;
|
||||
height: 0.85rem;
|
||||
border-radius: 99px;
|
||||
flex: none;
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
.steer-slot-row .grow { min-width: 0; overflow-wrap: anywhere; }
|
||||
.queue-name { color: var(--ink); font-size: var(--t-sm); font-weight: 650; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: block; }
|
||||
.queue-msg { color: var(--muted); font-size: var(--t-xs); line-height: 1.3; overflow-wrap: anywhere; }
|
||||
.queue-sub { color: var(--faint); font-size: 0.66rem; font-variant-numeric: tabular-nums; flex: none; }
|
||||
@@ -467,12 +497,16 @@ body.shelf [data-role="hero"] { font-size: 1.35rem; }
|
||||
.ace-printer-name { margin: 0; font-size: 0.82rem; font-weight: 650; color: var(--ink); }
|
||||
.ace-row {
|
||||
display: grid;
|
||||
grid-template-columns: 0.85rem minmax(0, 1fr) minmax(7rem, 0.9fr);
|
||||
grid-template-columns: 0.85rem minmax(0, 1fr) minmax(12rem, 1.4fr);
|
||||
gap: 0.45rem;
|
||||
align-items: center;
|
||||
margin: 0;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
.ace-row select {
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
.ace-swatch {
|
||||
width: 0.85rem;
|
||||
height: 0.85rem;
|
||||
@@ -514,6 +548,23 @@ h2 { font-size: 0.95rem; margin: 0 0 0.65rem; letter-spacing: -0.02em; font-weig
|
||||
border-radius: var(--radius-sm);
|
||||
min-height: var(--control-h);
|
||||
}
|
||||
.page-tools .date-field {
|
||||
width: auto;
|
||||
min-width: 9.5rem;
|
||||
max-width: 11rem;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.page-tools .period-presets {
|
||||
width: auto;
|
||||
max-width: 100%;
|
||||
flex: 1 1 100%;
|
||||
grid-auto-columns: auto;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.page-tools .period-presets button {
|
||||
padding: 0.4rem 0.55rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.lead, .tiny, .summary { color: var(--muted); }
|
||||
.summary { margin: 0.3rem 0 0; }
|
||||
.tiny { font-size: 0.8rem; margin: 0.2rem 0 0; line-height: 1.4; max-width: 52ch; }
|
||||
@@ -2305,3 +2356,70 @@ label.button:hover { background: rgba(255, 255, 255, 0.04); }
|
||||
.nav-secondary a { padding: 0.4rem; }
|
||||
.nav-main a.on { box-shadow: none; border-radius: 9px; padding-left: 0.55rem; }
|
||||
}
|
||||
|
||||
/* Druckbericht & Statistik */
|
||||
.bericht { display: grid; gap: 0.85rem; }
|
||||
.bericht-head { display: grid; gap: 0.5rem; }
|
||||
.bericht-meta p { margin: 0.2rem 0; }
|
||||
.temp-chart {
|
||||
width: 100%;
|
||||
max-width: 40rem;
|
||||
height: auto;
|
||||
background: #0c0c10;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
.chart-label { fill: var(--muted); font-size: 11px; }
|
||||
.chart-legend { display: flex; gap: 0.75rem; flex-wrap: wrap; margin-top: 0.35rem; }
|
||||
.chart-legend .leg::before {
|
||||
content: "";
|
||||
display: inline-block;
|
||||
width: 0.65rem;
|
||||
height: 0.65rem;
|
||||
border-radius: 50%;
|
||||
margin-right: 0.3rem;
|
||||
vertical-align: -0.05rem;
|
||||
}
|
||||
.chart-legend .nozzle::before { background: #ea580c; }
|
||||
.chart-legend .bed::before { background: #2563eb; }
|
||||
.chart-legend .chamber::before { background: #16a34a; }
|
||||
.tag {
|
||||
display: inline-block;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 650;
|
||||
padding: 0.05rem 0.35rem;
|
||||
border-radius: 6px;
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
}
|
||||
.kpi-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(8.5rem, 1fr));
|
||||
gap: 0.65rem;
|
||||
margin-bottom: 0.85rem;
|
||||
}
|
||||
.kpi {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
padding: 0.7rem 0.8rem;
|
||||
}
|
||||
.kpi-val {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 750;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
.kpi-label { color: var(--muted); font-size: 0.78rem; font-weight: 650; }
|
||||
.spool-akte {
|
||||
margin-top: 0.85rem;
|
||||
padding-top: 0.7rem;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
.spool-akte h3 { margin: 0 0 0.35rem; font-size: 0.95rem; }
|
||||
ul.plain { list-style: none; margin: 0; padding: 0; }
|
||||
ul.plain li { padding: 0.2rem 0; border-bottom: 1px solid var(--line); }
|
||||
ul.plain li:last-child { border-bottom: 0; }
|
||||
.grid-2 { display: grid; gap: 0.85rem; }
|
||||
@media (min-width: 800px) {
|
||||
.grid-2 { grid-template-columns: 1fr 1fr; }
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
{% extends "layout.html" %}
|
||||
{% block body %}
|
||||
<div class="page-head">
|
||||
<div>
|
||||
<h1>Druckbericht</h1>
|
||||
<p class="tiny" id="bericht-sub">Lauf wird geladen …</p>
|
||||
</div>
|
||||
<div class="page-head-side">
|
||||
<a class="ghost" href="/protokoll">← Protokoll</a>
|
||||
<a class="ghost" id="etikett-druck" href="#" hidden>Etikett Druck</a>
|
||||
<a class="ghost" id="etikett-objekt" href="#" hidden>Etiketten Objekte</a>
|
||||
</div>
|
||||
</div>
|
||||
<p id="bericht-note"></p>
|
||||
<div id="bericht" class="bericht"></div>
|
||||
{% endblock %}
|
||||
{% block script %}
|
||||
<script>
|
||||
const runId = Number(location.pathname.split("/").pop());
|
||||
const states = {
|
||||
completed: "fertig", cancelled: "abgebrochen", error: "Fehler",
|
||||
in_progress: "druckt", printing: "druckt", paused: "pausiert", interrupted: "unterbrochen",
|
||||
};
|
||||
|
||||
function sourceLabel(source, estimate) {
|
||||
if (estimate) return "Schätzwert";
|
||||
if (source === "measured") return "Messwert";
|
||||
if (source === "estimate") return "Schätzwert";
|
||||
return source || "";
|
||||
}
|
||||
|
||||
function chart(samples) {
|
||||
if (!samples || samples.length < 2) {
|
||||
return '<p class="tiny">Kein Temperaturverlauf (nur für neue Drucke).</p>';
|
||||
}
|
||||
const w = 640, h = 180, pad = 28;
|
||||
const temps = samples.flatMap((s) => [s.nozzle, s.bed, s.chamber, s.nozzle_target, s.bed_target].filter((v) => v != null));
|
||||
const minT = Math.min(...temps, 0);
|
||||
const maxT = Math.max(...temps, 100);
|
||||
const x = (i) => pad + (i / (samples.length - 1)) * (w - pad * 2);
|
||||
const y = (v) => h - pad - ((Number(v) - minT) / Math.max(1, maxT - minT)) * (h - pad * 2);
|
||||
function path(key, color) {
|
||||
const pts = samples.map((s, i) => s[key] == null ? null : `${x(i)},${y(s[key])}`).filter(Boolean);
|
||||
if (pts.length < 2) return "";
|
||||
return `<polyline fill="none" stroke="${color}" stroke-width="1.6" points="${pts.join(" ")}" />`;
|
||||
}
|
||||
return `<svg class="temp-chart" viewBox="0 0 ${w} ${h}" role="img" aria-label="Temperaturverlauf">
|
||||
${path("nozzle_target", "#fdba74")}
|
||||
${path("bed_target", "#93c5fd")}
|
||||
${path("nozzle", "#ea580c")}
|
||||
${path("bed", "#2563eb")}
|
||||
${path("chamber", "#16a34a")}
|
||||
<text x="${pad}" y="14" class="chart-label">${Math.round(maxT)} °C</text>
|
||||
<text x="${pad}" y="${h - 8}" class="chart-label">${Math.round(minT)} °C</text>
|
||||
</svg>
|
||||
<p class="tiny chart-legend"><span class="leg nozzle">Düse</span> <span class="leg bed">Bett</span> <span class="leg chamber">Kammer</span> · gestrichelt ≈ Soll</p>`;
|
||||
}
|
||||
|
||||
function render(data) {
|
||||
const run = data.run;
|
||||
const name = (run.filename || "").split("/").pop() || "ohne Datei";
|
||||
document.getElementById("bericht-sub").textContent =
|
||||
(run.printer_name || run.printer_host) + " · " + name
|
||||
+ (run.chargen_nr ? " · " + run.chargen_nr : "");
|
||||
|
||||
const planned = Number(run.weight_planned_g) || 0;
|
||||
const used = Number(run.weight_used_g) || 0;
|
||||
const diff = Math.round(used) - Math.round(planned);
|
||||
const cost = Number(run.filament_cost || 0) + Number(run.energy_cost || 0);
|
||||
|
||||
const spoolRows = (data.spools || []).map((s) => `
|
||||
<tr>
|
||||
<td>${escapeHtml(s.name)}${s.ace_slot ? " · ACE " + s.ace_slot : ""}</td>
|
||||
<td>${escapeHtml(s.material_type || "")}</td>
|
||||
<td class="num">${grams(s.weight_planned_g)}</td>
|
||||
<td class="num">${grams(s.weight_g)}${s.is_estimate ? ' <span class="tag">Schätzwert</span>' : ""}</td>
|
||||
<td class="num">${euro(s.cost)}</td>
|
||||
<td class="nowrap">${escapeHtml(s.chargen_nr || "")}</td>
|
||||
</tr>`).join("") || '<tr><td class="empty" colspan="6">Keine Spule zugeordnet.</td></tr>';
|
||||
|
||||
const events = (data.events || []).map((e) =>
|
||||
`<li><span class="tiny">${escapeHtml((e.created_at || "").replace("T", " ").slice(0, 19))}</span> ${escapeHtml(e.message || e.kind)}</li>`
|
||||
).join("") || "<li class=\"tiny\">Keine Ereignisse.</li>";
|
||||
|
||||
const labels = (data.labels || []).filter((l) => !l.excluded);
|
||||
const labelBits = labels.length
|
||||
? labels.map((l) => `<li id="objekt-${encodeURIComponent(l.object_name || "")}"><code>${escapeHtml(l.chargen_nr)}</code> ${escapeHtml(l.object_name || "Druck")}</li>`).join("")
|
||||
: (run.chargen_nr ? `<li><code>${escapeHtml(run.chargen_nr)}</code> gesamter Druck</li>` : "<li class=\"tiny\">Noch keine Charge.</li>");
|
||||
|
||||
const segments = (data.segments || []).map((s) => {
|
||||
const end = s.mm_end == null ? "…" : Math.round(s.mm_end);
|
||||
return `<li>${escapeHtml(s.spool_name)} · ${Math.round(s.mm_start)}–${end} mm${s.ace_slot ? " · Platz " + s.ace_slot : ""}</li>`;
|
||||
}).join("");
|
||||
|
||||
document.getElementById("bericht").innerHTML = `
|
||||
<div class="card bericht-head">
|
||||
<div class="bericht-meta">
|
||||
<p><b>${escapeHtml(states[run.state] || run.state)}</b>${run.verdict ? " · Urteil " + escapeHtml(run.verdict) : ""}</p>
|
||||
<p class="tiny">${escapeHtml(run.started_at || "—")} → ${escapeHtml(run.ended_at || "—")}</p>
|
||||
<p>Dauer <b>${hours(run.duration_s)}</b>${run.estimated_s ? " · Soll " + hours(run.estimated_s) : ""}</p>
|
||||
<p>Material <b>${grams(used)}</b> von ${grams(planned)}${planned ? " (" + (diff > 0 ? "+" : "") + diff + " g)" : ""}
|
||||
${run.weight_source ? ' · <span class="tag">' + sourceLabel(run.weight_source) + "</span>" : ""}</p>
|
||||
<p>Kosten <b>${euro(cost)}</b> (Filament ${euro(run.filament_cost)} · Strom ${euro(run.energy_cost)})</p>
|
||||
${run.message ? "<p class=\"msg\">" + escapeHtml(run.message) + "</p>" : ""}
|
||||
${data.project ? "<p class=\"tiny\">Projekt: " + escapeHtml(data.project.name) + "</p>" : ""}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>Spulen</h2>
|
||||
<div class="table-wrap"><table>
|
||||
<thead><tr><th>Spule</th><th>Sorte</th><th>Soll</th><th>Ist</th><th>Kosten</th><th>Charge</th></tr></thead>
|
||||
<tbody>${spoolRows}</tbody>
|
||||
</table></div>
|
||||
${segments ? "<h3>Abschnitte</h3><ul class=\"plain\">" + segments + "</ul>" : ""}
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>Verlauf</h2>
|
||||
${chart(data.samples)}
|
||||
</div>
|
||||
<div class="card grid-2">
|
||||
<div>
|
||||
<h2>Chargen</h2>
|
||||
<ul class="plain">${labelBits}</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h2>Ereignisse</h2>
|
||||
<ul class="plain">${events}</ul>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
const druck = document.getElementById("etikett-druck");
|
||||
const objekt = document.getElementById("etikett-objekt");
|
||||
if (run.chargen_nr) {
|
||||
druck.hidden = false;
|
||||
druck.href = "/etikett/lauf/" + run.id + "?mode=druck";
|
||||
objekt.hidden = false;
|
||||
objekt.href = "/etikett/lauf/" + run.id + "?mode=objekt&qty=1";
|
||||
} else {
|
||||
druck.hidden = true;
|
||||
objekt.hidden = true;
|
||||
}
|
||||
}
|
||||
|
||||
api("/laeufe/" + runId).then(render).catch((error) => showNote("bericht-note", error));
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -103,7 +103,7 @@
|
||||
<select id="send-project"><option value="">keins</option></select>
|
||||
<div id="ace-box" class="ace-box" hidden>
|
||||
<h3>ACE-Zuordnung <span class="tip" title="Datei-Farbe auf ACE-Platz. Ohne Zuordnung startet der ACE oft nicht."><svg class="ico"><use href="#i-info"/></svg></span></h3>
|
||||
<p class="tiny msg" id="ace-trailer-warn" hidden>Für Auto-Feed fehlen Slicer-Metadaten in der Datei.</p>
|
||||
<p class="tiny" id="ace-trailer-warn" hidden>Mehrfarb ohne Slicer-Trailer — Grotti ergänzt `; referenced_tools` beim Senden.</p>
|
||||
<div id="ace-maps"></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -252,13 +252,41 @@ function loadSendMemory() {
|
||||
|
||||
/* ---------- ACE / Spule ---------- */
|
||||
|
||||
function slotLabel(slot) {
|
||||
function spoolAtSlot(host, place) {
|
||||
return spoolsOnHost(host).find((s) => Number(s.ace_slot) === Number(place)) || null;
|
||||
}
|
||||
|
||||
function slotLabel(host, slot) {
|
||||
const place = Number(slot.index) + 1;
|
||||
const box = place <= 4 ? "ACE 1 · " + place : "ACE 2 · " + (place - 4);
|
||||
const spool = spoolAtSlot(host, place);
|
||||
if (spool) {
|
||||
const bits = [box];
|
||||
const kind = spool.material_type || slot.type || "";
|
||||
if (kind) bits.push(kind);
|
||||
if (spool.color) bits.push(spool.color);
|
||||
if (spool.name) bits.push(spool.name);
|
||||
bits.push(grams(spool.remaining_g));
|
||||
return bits.join(" · ");
|
||||
}
|
||||
const kind = slot.type ? " · " + slot.type : "";
|
||||
return box + kind;
|
||||
}
|
||||
|
||||
function slotTitle(host, slot) {
|
||||
const place = Number(slot.index) + 1;
|
||||
const spool = spoolAtSlot(host, place);
|
||||
if (!spool) return slot.type ? String(slot.type) : "";
|
||||
const bits = [];
|
||||
if (spool.brand) bits.push(spool.brand);
|
||||
if (spool.name) bits.push(spool.name);
|
||||
if (spool.material_type) bits.push(spool.material_type);
|
||||
if (spool.color) bits.push(spool.color);
|
||||
bits.push(grams(spool.remaining_g) + " Rest");
|
||||
if (spool.chargen_nr) bits.push(spool.chargen_nr);
|
||||
return bits.join(" · ");
|
||||
}
|
||||
|
||||
function filamentLabel(filament, index) {
|
||||
const kind = filament.type || "Filament";
|
||||
const color = filament.color ? " · " + filament.color : "";
|
||||
@@ -369,7 +397,10 @@ function renderAceMaps(force) {
|
||||
const paints = filePaints(file);
|
||||
box.hidden = false;
|
||||
document.getElementById("options").open = true;
|
||||
if (trailerWarn) trailerWarn.hidden = !!file.ace_trailer;
|
||||
// Mono: Trailer fehlt oft und ist kein Fehler — Grotti setzt ihn beim Senden mit ACE-Map.
|
||||
if (trailerWarn) {
|
||||
trailerWarn.hidden = !!file.ace_trailer || paints.length <= 1;
|
||||
}
|
||||
root.innerHTML = "";
|
||||
chosen.forEach((printer) => {
|
||||
const block = document.createElement("div");
|
||||
@@ -393,10 +424,15 @@ function renderAceMaps(force) {
|
||||
printer.slots.forEach((slot) => {
|
||||
const option = document.createElement("option");
|
||||
option.value = String(slot.index);
|
||||
option.textContent = slotLabel(slot);
|
||||
if (slot.color) option.style.borderLeft = "4px solid " + slot.color;
|
||||
// einfache Vorauswahl: gleiche Sorte, sonst Platz in Reihenfolge
|
||||
if (!select.dataset.picked && filament.type && slot.type && filament.type.toLowerCase() === String(slot.type).toLowerCase()) {
|
||||
option.textContent = slotLabel(printer.host, slot);
|
||||
option.title = slotTitle(printer.host, slot);
|
||||
const spool = spoolAtSlot(printer.host, Number(slot.index) + 1);
|
||||
const hex = (spool && spool.colors && spool.colors[0] && spool.colors[0].hex) || slot.color || "";
|
||||
if (hex) option.style.borderLeft = "4px solid " + hex;
|
||||
const spoolType = (spool && spool.material_type) || slot.type || "";
|
||||
// Vorauswahl: gleiche Sorte (Grotti-Spule bevorzugt), sonst Platz in Reihenfolge
|
||||
if (!select.dataset.picked && filament.type && spoolType
|
||||
&& filament.type.toLowerCase() === String(spoolType).toLowerCase()) {
|
||||
option.selected = true;
|
||||
select.dataset.picked = "1";
|
||||
}
|
||||
|
||||
@@ -41,14 +41,17 @@
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 22mm;
|
||||
gap: 3mm;
|
||||
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; }
|
||||
@@ -61,7 +64,19 @@
|
||||
margin-left: -1mm;
|
||||
}
|
||||
.dot:first-child { margin-left: 0; }
|
||||
#qr { width: 20mm; height: 20mm; }
|
||||
.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; }
|
||||
@@ -99,14 +114,20 @@
|
||||
<p>Hersteller: {{ spool.hersteller_charge }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
<canvas id="qr" width="120" height="120"></canvas>
|
||||
<div class="qr-wrap">
|
||||
<canvas id="qr" width="80" height="80"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
<script src="{{ url_for('static', filename='qrcode.min.js') }}?v=1"></script>
|
||||
<script>
|
||||
QRCode.toCanvas(document.getElementById("qr"), {{ qr_url|tojson }}, {
|
||||
width: 120,
|
||||
width: 80,
|
||||
margin: 1,
|
||||
color: { dark: "#111111", light: "#ffffff" }
|
||||
}, () => {
|
||||
const node = document.getElementById("qr");
|
||||
node.style.width = "20mm";
|
||||
node.style.height = "20mm";
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<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;
|
||||
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>
|
||||
<div class="toolbar">
|
||||
<button type="button" onclick="window.print()">Drucken</button>
|
||||
<a class="ghost" href="/protokoll/{{ run.id }}">Zurück</a>
|
||||
</div>
|
||||
{% for sheet in sheets %}
|
||||
<div class="sheet">
|
||||
<div class="meta">
|
||||
<h1>{{ sheet.object_name or (run.filename.split('/')[-1] if run.filename else 'Druck') }}</h1>
|
||||
<p class="code">{{ sheet.chargen_nr }}</p>
|
||||
{% set soll = sheet.target_g if sheet.target_g is defined else target_g %}
|
||||
{% if soll %}
|
||||
<p>Ziel {{ '%.0f'|format(soll) }} g</p>
|
||||
{% endif %}
|
||||
<p>{{ run.printer_name }} · {{ (run.ended_at or run.started_at or '')[:10] }}</p>
|
||||
{% for spool in spools %}
|
||||
<p>{% if spool.material_type %}{{ spool.material_type }}{% endif %}{% if spool.brand %} · {{ spool.brand }}{% endif %}{% if spool.name %} · {{ spool.name }}{% endif %}{% if spool.chargen_nr %} · {{ spool.chargen_nr }}{% endif %}{% if spool.hersteller_charge %} · H: {{ spool.hersteller_charge }}{% endif %}</p>
|
||||
{% else %}
|
||||
<p class="code" style="font-weight:500;color:#666">kein Material zugeordnet</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="qr-wrap">
|
||||
<canvas class="qr" id="qr-{{ loop.index0 }}" width="80" height="80" data-url="{{ sheet.qr_url }}"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
<script src="{{ url_for('static', filename='qrcode.min.js') }}?v=1"></script>
|
||||
<script>
|
||||
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";
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -9,7 +9,7 @@
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="{{ url_for('static', filename='brand/favicon-32.png') }}">
|
||||
<link rel="icon" type="image/png" sizes="512x512" href="{{ url_for('static', filename='brand/favicon.png') }}">
|
||||
<link rel="apple-touch-icon" href="{{ url_for('static', filename='brand/apple-touch-icon.png') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='grotti.css') }}?v=41">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='grotti.css') }}?v=45">
|
||||
<script>
|
||||
(function () {
|
||||
try {
|
||||
@@ -100,7 +100,8 @@
|
||||
<a href="/kameras" class="{{ 'on' if page == 'kameras' else '' }}" title="Kameras"><svg class="ico"><use href="#i-camera"/></svg><span>Kameras</span></a>
|
||||
<a href="/aktivitaet" class="{{ 'on' if page == 'aktivitaet' else '' }} nav-desk" title="Aktivität"><svg class="ico"><use href="#i-activity"/></svg><span>Aktivität</span></a>
|
||||
<a href="/warteschlange" class="{{ 'on' if page == 'warteschlange' else '' }}" title="Warteschlange"><svg class="ico"><use href="#i-queue"/></svg><span>Warteschlange</span></a>
|
||||
<a href="/protokoll" class="{{ 'on' if page == 'protokoll' else '' }} nav-desk" title="Protokoll"><svg class="ico"><use href="#i-list"/></svg><span>Protokoll</span></a>
|
||||
<a href="/protokoll" class="{{ 'on' if page == 'protokoll' or page == 'bericht' else '' }} nav-desk" title="Protokoll"><svg class="ico"><use href="#i-list"/></svg><span>Protokoll</span></a>
|
||||
<a href="/statistik" class="{{ 'on' if page == 'statistik' else '' }} nav-desk" title="Statistik"><svg class="ico"><use href="#i-activity"/></svg><span>Statistik</span></a>
|
||||
<a href="/material" class="{{ 'on' if page == 'material' else '' }} nav-desk" title="Material"><svg class="ico"><use href="#i-spool"/></svg><span>Material</span></a>
|
||||
<a href="/projekte" class="{{ 'on' if page == 'projekte' else '' }} nav-desk" title="Projekte"><svg class="ico"><use href="#i-folder"/></svg><span>Projekte</span></a>
|
||||
</nav>
|
||||
@@ -289,6 +290,31 @@ function directItemHtml(p) {
|
||||
}
|
||||
|
||||
let queueCache = [];
|
||||
const lastLiveFilename = Object.create(null);
|
||||
|
||||
function setListHtml(el, html) {
|
||||
if (!el) return;
|
||||
if (el._html === html) return;
|
||||
el._html = html;
|
||||
el.innerHTML = html;
|
||||
}
|
||||
|
||||
function stickyLiveFilename(host, filename, state) {
|
||||
const next = filename || "";
|
||||
if (next) {
|
||||
lastLiveFilename[host] = next;
|
||||
return next;
|
||||
}
|
||||
const prev = lastLiveFilename[host] || "";
|
||||
if (prev && ["printing", "paused", "heating", "complete"].includes(String(state || "").toLowerCase())) {
|
||||
return prev;
|
||||
}
|
||||
if (!["printing", "paused", "heating"].includes(String(state || "").toLowerCase())) {
|
||||
delete lastLiveFilename[host];
|
||||
}
|
||||
return prev && ["complete"].includes(String(state || "").toLowerCase()) ? prev : "";
|
||||
}
|
||||
|
||||
async function refreshPrintQueue() {
|
||||
let jobs;
|
||||
let active = [];
|
||||
@@ -298,7 +324,10 @@ async function refreshPrintQueue() {
|
||||
queueCache = jobs || [];
|
||||
const running = queueCache.filter((job) => job.status === "running");
|
||||
const queuedHosts = new Set(running.map((job) => job.host));
|
||||
const direct = (active || []).filter((p) => !queuedHosts.has(p.host));
|
||||
const direct = (active || []).filter((p) => !queuedHosts.has(p.host)).map((p) => ({
|
||||
...p,
|
||||
filename: stickyLiveFilename(p.host, p.filename, p.state),
|
||||
}));
|
||||
const waiting = queueCache.filter((job) => job.status === "waiting");
|
||||
const errors = queueCache
|
||||
.filter((job) => job.status === "error")
|
||||
@@ -308,15 +337,21 @@ async function refreshPrintQueue() {
|
||||
const runBox = document.getElementById("run-box");
|
||||
runBox.hidden = !(running.length || direct.length);
|
||||
document.getElementById("run-count").textContent = running.length + direct.length;
|
||||
document.getElementById("run-list").innerHTML = running.map(runItemHtml).join("") + direct.map(directItemHtml).join("");
|
||||
setListHtml(
|
||||
document.getElementById("run-list"),
|
||||
running.map(runItemHtml).join("") + direct.map(directItemHtml).join(""),
|
||||
);
|
||||
|
||||
const waitList = document.getElementById("wait-list");
|
||||
document.getElementById("wait-count").textContent = waiting.length;
|
||||
document.getElementById("wait-count").className = "count" + (errors.length ? " bad" : waiting.length ? " hot" : "");
|
||||
document.getElementById("wait-empty").hidden = !!(waiting.length || errors.length);
|
||||
document.getElementById("wait-box").classList.toggle("is-empty", !(waiting.length || errors.length));
|
||||
waitList.innerHTML = errors.map((job) => waitItemHtml(job, 0, 1)).join("")
|
||||
+ waiting.map((job, i) => waitItemHtml(job, i, waiting.length)).join("");
|
||||
setListHtml(
|
||||
waitList,
|
||||
errors.map((job) => waitItemHtml(job, 0, 1)).join("")
|
||||
+ waiting.map((job, i) => waitItemHtml(job, i, waiting.length)).join(""),
|
||||
);
|
||||
|
||||
const panel = document.getElementById("live-panel");
|
||||
panel.querySelectorAll("[data-cancel-q]").forEach((btn) => {
|
||||
|
||||
@@ -120,6 +120,11 @@
|
||||
<button type="button" class="warn" id="spool-delete" hidden><svg class="ico"><use href="#i-trash"/></svg>Löschen</button>
|
||||
</div>
|
||||
</form>
|
||||
<div id="spool-akte" class="spool-akte" hidden>
|
||||
<h3>Akte</h3>
|
||||
<p class="tiny" id="akte-summary"></p>
|
||||
<ul class="plain" id="akte-runs"></ul>
|
||||
</div>
|
||||
<p id="spool-note"></p>
|
||||
</section>
|
||||
</div>
|
||||
@@ -424,6 +429,49 @@ function payload() {
|
||||
};
|
||||
}
|
||||
|
||||
async function loadAkte(spoolId) {
|
||||
const box = document.getElementById("spool-akte");
|
||||
if (!spoolId) {
|
||||
box.hidden = true;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const data = await api("/spulen/" + spoolId + "/akte");
|
||||
const stats = data.stats || {};
|
||||
const fillPct = Math.round(Number(stats.fill_pct) || 0);
|
||||
let leer = "—";
|
||||
if (stats.empty_in_days == null) leer = "kein Verbrauch";
|
||||
else if (stats.empty_in_days <= 0) leer = "leer";
|
||||
else leer = "noch ~" + Math.round(stats.empty_in_days) + " Tage";
|
||||
document.getElementById("akte-summary").textContent =
|
||||
"Verdruckt " + grams(stats.total_g)
|
||||
+ " · Ausschuss " + grams(stats.scrap_g)
|
||||
+ " · " + euro(stats.total_cost)
|
||||
+ " · Füllstand " + fillPct + " % · Leer-in: " + leer;
|
||||
const list = document.getElementById("akte-runs");
|
||||
list.innerHTML = "";
|
||||
(data.runs || []).slice(0, 12).forEach((run) => {
|
||||
const li = document.createElement("li");
|
||||
const a = document.createElement("a");
|
||||
a.href = "/protokoll/" + run.id;
|
||||
a.textContent = (run.filename || "").split("/").pop() || ("#" + run.id);
|
||||
li.appendChild(a);
|
||||
li.appendChild(document.createTextNode(
|
||||
" · " + grams(run.spool_weight_g)
|
||||
+ (run.spool_is_estimate ? " ≈" : "")
|
||||
+ " · " + (run.verdict || run.state || "")
|
||||
));
|
||||
list.appendChild(li);
|
||||
});
|
||||
if (!(data.runs || []).length) {
|
||||
list.innerHTML = '<li class="tiny">Noch kein Druck auf dieser Spule.</li>';
|
||||
}
|
||||
box.hidden = false;
|
||||
} catch (error) {
|
||||
box.hidden = true;
|
||||
}
|
||||
}
|
||||
|
||||
function fill(spool) {
|
||||
openSpoolPanel();
|
||||
document.getElementById("spool-id").value = spool ? spool.id : "";
|
||||
@@ -451,6 +499,7 @@ function fill(spool) {
|
||||
showHint();
|
||||
renderPalette();
|
||||
renderSpools();
|
||||
loadAkte(spool ? spool.id : null);
|
||||
document.getElementById("spool-panel").scrollIntoView({ block: "nearest", behavior: "smooth" });
|
||||
}
|
||||
|
||||
@@ -580,6 +629,7 @@ function renderSpools() {
|
||||
const rest = Number(spool.remaining_g) || 0;
|
||||
tr.children[5].textContent = grams(rest);
|
||||
if (rest < 100) tr.children[5].classList.add("msg");
|
||||
if (rest < 0) tr.children[5].title = "Überzogen — Rest korrigieren";
|
||||
tr.addEventListener("click", () => fill(spool));
|
||||
body.appendChild(tr);
|
||||
});
|
||||
@@ -806,7 +856,14 @@ async function openByCode(raw) {
|
||||
const match = code.match(/[?&]spool=(\d+)/);
|
||||
if (match) spool = spools.find((row) => String(row.id) === match[1]) || null;
|
||||
}
|
||||
if (!spool) spool = await api("/spulen/by-code/" + encodeURIComponent(code));
|
||||
if (!spool) {
|
||||
const found = await api("/spulen/by-code/" + encodeURIComponent(code));
|
||||
if (found && found.kind === "lauf" && found.redirect) {
|
||||
location.href = found.redirect;
|
||||
return;
|
||||
}
|
||||
spool = found;
|
||||
}
|
||||
setView("spools");
|
||||
fill(spool);
|
||||
setScanNote("Spule " + (spool.chargen_nr || spool.name) + " geöffnet.", true);
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
<p id="run-note"></p>
|
||||
<div class="card table-wrap">
|
||||
<table>
|
||||
<thead><tr><th>Drucker</th><th>Datei</th><th>Zustand</th><th>Dauer</th><th>Gramm</th><th>Kosten</th><th>Urteil</th><th>Spule</th><th></th></tr></thead>
|
||||
<thead><tr><th>Drucker</th><th>Datei</th><th>Zustand</th><th>Dauer</th><th>Ist</th><th>Kosten</th><th>Urteil</th><th>Spule</th><th></th></tr></thead>
|
||||
<tbody id="run-rows"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -80,12 +80,31 @@ function render() {
|
||||
}
|
||||
shown.forEach((run) => {
|
||||
const tr = document.createElement("tr");
|
||||
tr.className = "pick";
|
||||
tr.innerHTML = "<td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td>";
|
||||
tr.children[0].textContent = run.printer_name;
|
||||
tr.children[1].textContent = (run.filename || "").split("/").pop();
|
||||
const fileCell = tr.children[1];
|
||||
const link = document.createElement("a");
|
||||
link.href = "/protokoll/" + run.id;
|
||||
link.textContent = (run.filename || "").split("/").pop() || ("#" + run.id);
|
||||
fileCell.appendChild(link);
|
||||
if (run.chargen_nr) {
|
||||
const code = document.createElement("div");
|
||||
code.className = "tiny";
|
||||
code.textContent = run.chargen_nr;
|
||||
fileCell.appendChild(code);
|
||||
}
|
||||
tr.children[2].textContent = states[run.state] || run.state;
|
||||
tr.children[3].textContent = hours(run.duration_s);
|
||||
tr.children[4].textContent = run.weight_g ? grams(run.weight_g) : "";
|
||||
const used = Number(run.weight_used_g) || Number(run.weight_g) || 0;
|
||||
const planned = Number(run.weight_planned_g) || Number(run.weight_g) || 0;
|
||||
tr.children[4].textContent = used ? grams(used) : "";
|
||||
if (used && planned && Math.abs(used - planned) > 0.5) {
|
||||
tr.children[4].title = "Soll " + grams(planned);
|
||||
}
|
||||
if (run.weight_source === "estimate" || (run.weight_source === "measured" && false)) {
|
||||
tr.children[4].textContent += run.weight_source === "estimate" ? " ≈" : "";
|
||||
}
|
||||
const cost = Number(run.filament_cost || 0) + Number(run.energy_cost || 0);
|
||||
tr.children[5].textContent = cost ? euro(cost) : "";
|
||||
const verdict = document.createElement("select");
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
{% extends "layout.html" %}
|
||||
{% block body %}
|
||||
<div class="page-head">
|
||||
<div>
|
||||
<h1>Statistik</h1>
|
||||
<p class="tiny">Auslastung, Verbrauch und Erfolgsquote aus dem Protokoll.</p>
|
||||
</div>
|
||||
<div class="page-head-side">
|
||||
<a class="ghost" id="csv-link" href="/api/v1/statistik.csv">CSV</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="page-tools">
|
||||
<div class="segmented period-presets" id="period" role="group" aria-label="Zeitraum">
|
||||
<button type="button" data-period="all" class="on">Alles</button>
|
||||
<button type="button" data-period="day">Tag</button>
|
||||
<button type="button" data-period="week">Woche</button>
|
||||
<button type="button" data-period="month">Monat</button>
|
||||
<button type="button" data-period="yesterday">Gestern</button>
|
||||
<button type="button" data-period="last_week">Letzte Woche</button>
|
||||
<button type="button" data-period="last_month">Letzter Monat</button>
|
||||
</div>
|
||||
<input type="date" id="since" title="Von" class="date-field">
|
||||
<input type="date" id="until" title="Bis" class="date-field">
|
||||
<select id="host"><option value="">Alle Drucker</option></select>
|
||||
<input type="text" id="material" placeholder="Sorte" size="10">
|
||||
<input type="text" id="brand" placeholder="Marke" size="10">
|
||||
<button type="button" id="reload">Aktualisieren</button>
|
||||
</div>
|
||||
<p id="stat-note"></p>
|
||||
<div id="stat" class="statistik"></div>
|
||||
{% endblock %}
|
||||
{% block script %}
|
||||
<script>
|
||||
function kpi(label, value, hint) {
|
||||
return `<div class="kpi"><div class="kpi-val">${value}</div><div class="kpi-label">${escapeHtml(label)}</div>${hint ? '<div class="tiny">' + escapeHtml(hint) + "</div>" : ""}</div>`;
|
||||
}
|
||||
|
||||
function isoDate(date) {
|
||||
const y = date.getFullYear();
|
||||
const m = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const d = String(date.getDate()).padStart(2, "0");
|
||||
return y + "-" + m + "-" + d;
|
||||
}
|
||||
|
||||
function startOfWeek(date) {
|
||||
const day = date.getDay();
|
||||
const mondayOffset = day === 0 ? -6 : 1 - day;
|
||||
const start = new Date(date.getFullYear(), date.getMonth(), date.getDate() + mondayOffset);
|
||||
return start;
|
||||
}
|
||||
|
||||
function periodRange(key) {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
if (key === "all") return { since: "", until: "" };
|
||||
if (key === "day") return { since: isoDate(today), until: isoDate(today) };
|
||||
if (key === "yesterday") {
|
||||
const day = new Date(today);
|
||||
day.setDate(day.getDate() - 1);
|
||||
return { since: isoDate(day), until: isoDate(day) };
|
||||
}
|
||||
if (key === "week") {
|
||||
const start = startOfWeek(today);
|
||||
const end = new Date(start);
|
||||
end.setDate(end.getDate() + 6);
|
||||
return { since: isoDate(start), until: isoDate(end) };
|
||||
}
|
||||
if (key === "last_week") {
|
||||
const start = startOfWeek(today);
|
||||
start.setDate(start.getDate() - 7);
|
||||
const end = new Date(start);
|
||||
end.setDate(end.getDate() + 6);
|
||||
return { since: isoDate(start), until: isoDate(end) };
|
||||
}
|
||||
if (key === "month") {
|
||||
const start = new Date(today.getFullYear(), today.getMonth(), 1);
|
||||
const end = new Date(today.getFullYear(), today.getMonth() + 1, 0);
|
||||
return { since: isoDate(start), until: isoDate(end) };
|
||||
}
|
||||
if (key === "last_month") {
|
||||
const start = new Date(today.getFullYear(), today.getMonth() - 1, 1);
|
||||
const end = new Date(today.getFullYear(), today.getMonth(), 0);
|
||||
return { since: isoDate(start), until: isoDate(end) };
|
||||
}
|
||||
return { since: "", until: "" };
|
||||
}
|
||||
|
||||
function setPeriodActive(key) {
|
||||
document.querySelectorAll("#period [data-period]").forEach((btn) => {
|
||||
btn.classList.toggle("on", btn.dataset.period === key);
|
||||
});
|
||||
}
|
||||
|
||||
function clearPeriodActive() {
|
||||
document.querySelectorAll("#period [data-period]").forEach((btn) => btn.classList.remove("on"));
|
||||
}
|
||||
|
||||
function fillPrinters(rows) {
|
||||
const select = document.getElementById("host");
|
||||
const current = select.value;
|
||||
const known = new Set([...select.options].map((o) => o.value));
|
||||
(rows || []).forEach((row) => {
|
||||
if (!row.printer_host || known.has(row.printer_host)) return;
|
||||
const opt = document.createElement("option");
|
||||
opt.value = row.printer_host;
|
||||
opt.textContent = row.printer_name || row.printer_host;
|
||||
select.appendChild(opt);
|
||||
known.add(row.printer_host);
|
||||
});
|
||||
if ([...select.options].some((o) => o.value === current)) select.value = current;
|
||||
}
|
||||
|
||||
function render(data) {
|
||||
fillPrinters(data.by_printer);
|
||||
const o = data.overview || {};
|
||||
const jobs = o.jobs || 0;
|
||||
const success = jobs ? Math.round(100 * (o.completed || 0) / jobs) : 0;
|
||||
const scrap = jobs ? Math.round(100 * (o.scrap || 0) / jobs) : 0;
|
||||
const ratio = data.estimate_ratio != null
|
||||
? (data.estimate_ratio * 100).toFixed(0) + " %"
|
||||
: "—";
|
||||
|
||||
const printers = (data.by_printer || []).map((p) => `
|
||||
<tr>
|
||||
<td>${escapeHtml(p.printer_name || p.printer_host)}</td>
|
||||
<td class="num">${p.jobs}</td>
|
||||
<td class="num">${hours(p.duration_s)}</td>
|
||||
<td class="num">${grams(p.weight_g)}</td>
|
||||
<td class="num">${euro(p.cost)}</td>
|
||||
<td class="num">${p.completed}</td>
|
||||
</tr>`).join("") || '<tr><td class="empty" colspan="6">Keine Daten.</td></tr>';
|
||||
|
||||
const materials = (data.by_material || []).map((m) => `
|
||||
<tr>
|
||||
<td>${escapeHtml(m.material_type || "—")}</td>
|
||||
<td>${escapeHtml(m.brand || "")}</td>
|
||||
<td class="num">${grams(m.weight_g)}</td>
|
||||
<td class="num">${euro(m.cost)}</td>
|
||||
<td class="num">${m.jobs}</td>
|
||||
</tr>`).join("") || '<tr><td class="empty" colspan="5">Kein Spulenabzug im Zeitraum.</td></tr>';
|
||||
|
||||
document.getElementById("stat").innerHTML = `
|
||||
<div class="kpi-row">
|
||||
${kpi("Läufe", String(jobs))}
|
||||
${kpi("Druckzeit", hours(o.duration_s))}
|
||||
${kpi("Verbrauch", grams(o.weight_g))}
|
||||
${kpi("Kosten", euro(o.cost))}
|
||||
${kpi("Erfolg", success + " %", (o.completed || 0) + " fertig · " + (o.failed || 0) + " abgebrochen")}
|
||||
${kpi("Ausschuss", scrap + " %", (o.scrap || 0) + " mit Urteil Ausschuss")}
|
||||
${kpi("Schätztreue", ratio, data.estimate_count ? data.estimate_count + " Läufe mit Soll-Zeit" : "keine Soll-Zeit")}
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>Nach Drucker</h2>
|
||||
<div class="table-wrap"><table>
|
||||
<thead><tr><th>Drucker</th><th>Läufe</th><th>Zeit</th><th>Gramm</th><th>Kosten</th><th>Fertig</th></tr></thead>
|
||||
<tbody>${printers}</tbody>
|
||||
</table></div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>Nach Material</h2>
|
||||
<div class="table-wrap"><table>
|
||||
<thead><tr><th>Sorte</th><th>Marke</th><th>Gramm</th><th>Kosten</th><th>Läufe</th></tr></thead>
|
||||
<tbody>${materials}</tbody>
|
||||
</table></div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
async function load() {
|
||||
const params = new URLSearchParams();
|
||||
const since = document.getElementById("since").value;
|
||||
const until = document.getElementById("until").value;
|
||||
const host = document.getElementById("host").value;
|
||||
const material = document.getElementById("material").value.trim();
|
||||
const brand = document.getElementById("brand").value.trim();
|
||||
if (since) params.set("since", since);
|
||||
if (until) params.set("until", until);
|
||||
if (host) params.set("host", host);
|
||||
if (material) params.set("material", material);
|
||||
if (brand) params.set("brand", brand);
|
||||
const q = params.toString();
|
||||
document.getElementById("csv-link").href = "/api/v1/statistik.csv" + (q ? "?" + q : "");
|
||||
const data = await api("/statistik" + (q ? "?" + q : ""));
|
||||
render(data);
|
||||
}
|
||||
|
||||
function applyPeriod(key) {
|
||||
const range = periodRange(key);
|
||||
document.getElementById("since").value = range.since;
|
||||
document.getElementById("until").value = range.until;
|
||||
setPeriodActive(key);
|
||||
load().catch((error) => showNote("stat-note", error));
|
||||
}
|
||||
|
||||
document.getElementById("period").addEventListener("click", (event) => {
|
||||
const button = event.target.closest("[data-period]");
|
||||
if (!button) return;
|
||||
applyPeriod(button.dataset.period);
|
||||
});
|
||||
|
||||
document.getElementById("reload").addEventListener("click", () => {
|
||||
load().catch((error) => showNote("stat-note", error));
|
||||
});
|
||||
["since", "until"].forEach((id) => {
|
||||
document.getElementById(id).addEventListener("change", () => {
|
||||
clearPeriodActive();
|
||||
load().catch((error) => showNote("stat-note", error));
|
||||
});
|
||||
});
|
||||
document.getElementById("host").addEventListener("change", () => {
|
||||
load().catch((error) => showNote("stat-note", error));
|
||||
});
|
||||
load().catch((error) => showNote("stat-note", error));
|
||||
</script>
|
||||
{% endblock %}
|
||||
+229
-39
@@ -98,9 +98,19 @@ let wallSpools = [];
|
||||
let deskViewer = null;
|
||||
let deskCamFails = 0;
|
||||
let deskViewerFileId = null;
|
||||
let deskSyncGen = 0;
|
||||
let deskExcludeSelected = "";
|
||||
const wallPullTried = new Set();
|
||||
const toolpathCache = new Map();
|
||||
|
||||
/** Kurzes leeres filename vom Drucker nicht übernehmen — sonst springt der Name alle paar Sekunden. */
|
||||
function stickyFilename(printer, previous) {
|
||||
const next = (printer && printer.filename) || "";
|
||||
if (next) return next;
|
||||
const state = shownState(printer || {});
|
||||
if (previous && ["printing", "paused", "heating", "complete"].includes(state)) return previous;
|
||||
return "";
|
||||
}
|
||||
const cardNodes = new Map();
|
||||
const runningJobs = new Map();
|
||||
const printerCache = new Map();
|
||||
@@ -208,6 +218,14 @@ function setHtml(el, html) {
|
||||
}
|
||||
}
|
||||
|
||||
function setText(el, text) {
|
||||
if (!el) return;
|
||||
const next = text == null ? "" : String(text);
|
||||
if (el._text === next) return;
|
||||
el._text = next;
|
||||
el.textContent = next;
|
||||
}
|
||||
|
||||
function tempbarHtml(label, value, target, scale, iconName) {
|
||||
if (value == null) return "";
|
||||
const now = Math.round(value);
|
||||
@@ -265,12 +283,19 @@ function swatchesHtml(printer) {
|
||||
}).join("");
|
||||
}
|
||||
|
||||
function actionsHtml(state) {
|
||||
function etikettHtml(printer) {
|
||||
const runId = printer && printer.live_run_id;
|
||||
if (!runId) return "";
|
||||
return `<a class="ghost" href="/etikett/lauf/${runId}?mode=druck" target="_blank" rel="noopener">Etikett</a>`;
|
||||
}
|
||||
|
||||
function actionsHtml(state, printer) {
|
||||
const label = (state === "printing" || state === "paused") ? etikettHtml(printer) : "";
|
||||
if (state === "printing") {
|
||||
return `<button type="button" class="ghost" data-cmd="pause">${icon("pause")}Pause</button><button type="button" class="warn" data-cmd="abbrechen">${icon("stop")}Abbrechen</button>`;
|
||||
return `${label}<button type="button" class="ghost" data-cmd="pause">${icon("pause")}Pause</button><button type="button" class="warn" data-cmd="abbrechen">${icon("stop")}Abbrechen</button>`;
|
||||
}
|
||||
if (state === "paused") {
|
||||
return `<button type="button" data-cmd="fortsetzen">${icon("play")}Weiter</button><button type="button" class="warn" data-cmd="abbrechen">${icon("stop")}Abbrechen</button>`;
|
||||
return `${label}<button type="button" data-cmd="fortsetzen">${icon("play")}Weiter</button><button type="button" class="warn" data-cmd="abbrechen">${icon("stop")}Abbrechen</button>`;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
@@ -327,13 +352,107 @@ function stripsHtml(printer, state) {
|
||||
return bits.join("");
|
||||
}
|
||||
|
||||
function dryerLabel(status) {
|
||||
if (status === "drying") return "trocknet";
|
||||
if (status === "heater_err") return "Fehler";
|
||||
return "aus";
|
||||
}
|
||||
|
||||
function dryerDefaultTemp(dryer) {
|
||||
if (dryer.target > 0) return dryer.target;
|
||||
if (dryer.temp > 0) return dryer.temp;
|
||||
return 45;
|
||||
}
|
||||
|
||||
function aceSteerKey(printer) {
|
||||
const dryers = printer.dryers || [];
|
||||
const dryerPart = dryers.map((d) => String(d.id)).join(",");
|
||||
const slots = wallSpools
|
||||
.filter((s) => s.printer_host === printer.host && s.ace_slot)
|
||||
.map((s) => Number(s.ace_slot))
|
||||
.sort((a, b) => a - b)
|
||||
.join(",");
|
||||
return dryerPart + "|" + slots;
|
||||
}
|
||||
|
||||
function spoolHex(spool) {
|
||||
const colors = spool && spool.colors;
|
||||
if (Array.isArray(colors)) {
|
||||
for (const item of colors) {
|
||||
if (item && item.hex) return item.hex;
|
||||
if (typeof item === "string" && item.trim()) return item.trim();
|
||||
}
|
||||
}
|
||||
const label = spool && spool.color;
|
||||
if (typeof label === "string" && /^#?[0-9a-fA-F]{6}([0-9a-fA-F]{2})?$/.test(label.trim())) {
|
||||
return label.trim().startsWith("#") ? label.trim() : "#" + label.trim();
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function aceSlotLabel(slot) {
|
||||
const n = Number(slot);
|
||||
if (n <= 4) return "ACE 1 · " + n;
|
||||
return "ACE 2 · " + (n - 4);
|
||||
}
|
||||
|
||||
function aceSteerHtml(printer) {
|
||||
const dryers = printer.dryers || [];
|
||||
if (!dryers.length) return "";
|
||||
const bits = [];
|
||||
dryers.forEach((dryer) => {
|
||||
const id = Number(dryer.id);
|
||||
const label = "ACE " + (id + 1);
|
||||
const status = dryerLabel(dryer.status);
|
||||
const temp = dryer.temp || 0;
|
||||
const target = dryer.target || 0;
|
||||
const remain = dryer.remain || 0;
|
||||
const dur = remain > 0 ? remain : 240;
|
||||
const hot = dryerDefaultTemp(dryer);
|
||||
const info = [
|
||||
status,
|
||||
temp || target ? temp + "°" + (target ? " / " + target + "°" : "") : "",
|
||||
remain > 0 ? "noch " + remain + " min" : "",
|
||||
].filter(Boolean).join(" · ");
|
||||
bits.push(`<div class="steer-ace" data-dryer-id="${id}">
|
||||
<div class="steer-ace-head"><b>${escapeHtml(label)} Trockner</b><span class="tiny" data-dryer-info>${escapeHtml(info)}</span></div>
|
||||
<div class="steer-row">
|
||||
<label>Temp °C <input type="number" data-steer="dryer-temp-${id}" min="35" max="65" value="${escapeHtml(String(hot))}"></label>
|
||||
<label>Dauer min <input type="number" data-steer="dryer-dur-${id}" min="1" max="720" value="${escapeHtml(String(dur))}"></label>
|
||||
<button type="button" data-steer-go="dryer_start" data-dryer-id="${id}">Start</button>
|
||||
<button type="button" class="ghost" data-steer-go="dryer_stop" data-dryer-id="${id}">Stopp</button>
|
||||
</div>
|
||||
</div>`);
|
||||
});
|
||||
const loaded = wallSpools
|
||||
.filter((s) => s.printer_host === printer.host && s.ace_slot)
|
||||
.slice()
|
||||
.sort((a, b) => Number(a.ace_slot) - Number(b.ace_slot));
|
||||
if (loaded.length) {
|
||||
bits.push(`<div class="steer-ace-slots"><div class="steer-ace-head"><b>Plätze</b><span class="tiny">Sorte und Farbe aus Grotti</span></div>`);
|
||||
loaded.forEach((spool) => {
|
||||
const slot = Number(spool.ace_slot);
|
||||
const hex = spoolHex(spool) || "#555";
|
||||
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>
|
||||
<button type="button" class="ghost" data-steer-go="slot_setzen" data-ace-slot="${slot}">an den Drucker</button>
|
||||
</div>`);
|
||||
});
|
||||
bits.push(`</div>`);
|
||||
}
|
||||
return bits.join("");
|
||||
}
|
||||
|
||||
function steerPanelHtml(printer) {
|
||||
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;
|
||||
const flow = typeof printer.extrude_factor === "number" ? Math.round(printer.extrude_factor * 100) : 100;
|
||||
const fan = typeof printer.fan === "number" ? Math.round(printer.fan * 100) : 0;
|
||||
return `<div class="steer" data-steer-host="${escapeHtml(printer.host)}">
|
||||
const aceKey = aceSteerKey(printer);
|
||||
return `<div class="steer" data-steer-host="${escapeHtml(printer.host)}" data-ace-key="${escapeHtml(aceKey)}">
|
||||
<div class="steer-row">
|
||||
<label>${icon("nozzle")} Düse <input type="number" data-steer="nozzle" data-steer-action="nozzle" min="0" max="350" value="${escapeHtml(String(n))}"></label>
|
||||
<label>${icon("bed")} Bett <input type="number" data-steer="bed" data-steer-action="bed" min="0" max="150" value="${escapeHtml(String(b))}"></label>
|
||||
@@ -345,6 +464,7 @@ function steerPanelHtml(printer) {
|
||||
<label>Flow % <input type="number" data-steer="flow" data-steer-action="flow" min="50" max="150" value="${escapeHtml(String(flow))}"></label>
|
||||
<label>${icon("fan")} Fan % <input type="number" data-steer="fan" data-steer-action="fan" min="0" max="100" value="${escapeHtml(String(fan))}"></label>
|
||||
</div>
|
||||
${aceSteerHtml(printer)}
|
||||
<p class="tiny">Enter im Feld setzt nur diesen Wert.</p>
|
||||
</div>`;
|
||||
}
|
||||
@@ -447,8 +567,9 @@ function anycubicTaskLine(printer) {
|
||||
return "<span>Anycubic <b>" + escapeHtml(bits.join(" · ")) + "</b></span>";
|
||||
}
|
||||
|
||||
function jobLineVisible(printer, state) {
|
||||
return !!printer.filename && state !== "standby" && state !== "offline";
|
||||
function jobLineVisible(printer, state, filename) {
|
||||
const name = filename != null ? filename : (printer && printer.filename);
|
||||
return !!name && state !== "standby" && state !== "offline";
|
||||
}
|
||||
|
||||
function shortName(name) {
|
||||
@@ -517,11 +638,13 @@ function patchSteerValues(root, printer) {
|
||||
if (steerHasFocus(root)) return;
|
||||
const steer = root.querySelector("[data-role=steer]");
|
||||
if (!steer) return;
|
||||
const nozzle = steer.querySelector('[data-steer="nozzle"]');
|
||||
const bed = steer.querySelector('[data-steer="bed"]');
|
||||
const speed = steer.querySelector('[data-steer="speed"]');
|
||||
const flow = steer.querySelector('[data-steer="flow"]');
|
||||
const fan = steer.querySelector('[data-steer="fan"]');
|
||||
const panel = steer.querySelector(".steer");
|
||||
if (!panel) return;
|
||||
const nozzle = panel.querySelector('[data-steer="nozzle"]');
|
||||
const bed = panel.querySelector('[data-steer="bed"]');
|
||||
const speed = panel.querySelector('[data-steer="speed"]');
|
||||
const flow = panel.querySelector('[data-steer="flow"]');
|
||||
const fan = panel.querySelector('[data-steer="fan"]');
|
||||
if (nozzle && document.activeElement !== nozzle) {
|
||||
nozzle.value = printer.nozzle_target != null ? Math.round(printer.nozzle_target) : "";
|
||||
}
|
||||
@@ -537,13 +660,39 @@ function patchSteerValues(root, printer) {
|
||||
if (fan && document.activeElement !== fan && typeof printer.fan === "number") {
|
||||
fan.value = Math.round(printer.fan * 100);
|
||||
}
|
||||
(printer.dryers || []).forEach((dryer) => {
|
||||
const id = Number(dryer.id);
|
||||
const box = panel.querySelector(`[data-dryer-id="${id}"]`);
|
||||
if (!box) return;
|
||||
const info = box.querySelector("[data-dryer-info]");
|
||||
if (info) {
|
||||
const status = dryerLabel(dryer.status);
|
||||
const temp = dryer.temp || 0;
|
||||
const target = dryer.target || 0;
|
||||
const remain = dryer.remain || 0;
|
||||
info.textContent = [
|
||||
status,
|
||||
temp || target ? temp + "°" + (target ? " / " + target + "°" : "") : "",
|
||||
remain > 0 ? "noch " + remain + " min" : "",
|
||||
].filter(Boolean).join(" · ");
|
||||
}
|
||||
const tempInput = box.querySelector(`[data-steer="dryer-temp-${id}"]`);
|
||||
const durInput = box.querySelector(`[data-steer="dryer-dur-${id}"]`);
|
||||
if (tempInput && document.activeElement !== tempInput && dryer.status === "drying" && dryer.target > 0) {
|
||||
tempInput.value = dryer.target;
|
||||
}
|
||||
if (durInput && document.activeElement !== durInput && dryer.remain > 0) {
|
||||
durInput.value = dryer.remain;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function patchCard(article, printer) {
|
||||
const state = shownState(printer);
|
||||
const clearNow = needsClear(printer);
|
||||
const shownFile = stickyFilename(printer, article.dataset.filename || "");
|
||||
article.dataset.state = state;
|
||||
article.dataset.filename = printer.filename || "";
|
||||
article.dataset.filename = shownFile;
|
||||
article.dataset.progress = String(printer.progress || 0);
|
||||
article.dataset.family = printer.family || "";
|
||||
article.dataset.needsClear = clearNow ? "1" : "";
|
||||
@@ -558,7 +707,7 @@ function patchCard(article, printer) {
|
||||
setHtml(role("stats"), statsHtml(printer, state));
|
||||
setHtml(role("temps"), tempbarsHtml(printer, state));
|
||||
const jobline = role("jobline");
|
||||
const jobVisible = jobLineVisible(printer, state);
|
||||
const jobVisible = jobLineVisible(printer, state, shownFile);
|
||||
const hasThumb = !!(jobVisible && printer.preview);
|
||||
if (jobline) {
|
||||
jobline.hidden = !jobVisible;
|
||||
@@ -590,13 +739,13 @@ function patchCard(article, printer) {
|
||||
}
|
||||
const job = role("job");
|
||||
if (job) {
|
||||
job.textContent = jobName(printer.filename) || "";
|
||||
job.title = printer.filename || "";
|
||||
setText(job, jobName(shownFile) || "");
|
||||
job.title = shownFile || "";
|
||||
}
|
||||
setHtml(role("swatches"), swatchesHtml(printer));
|
||||
setHtml(role("flow"), flowHtml(printer));
|
||||
setHtml(role("strips"), stripsHtml(printer, state));
|
||||
setHtml(role("controls"), actionsHtml(state));
|
||||
setHtml(role("controls"), actionsHtml(state, printer));
|
||||
}
|
||||
|
||||
function refreshDeskCamera(printer) {
|
||||
@@ -687,9 +836,10 @@ function patchDesk(printer) {
|
||||
if (!printer || printer.host !== deskHost) return;
|
||||
const state = shownState(printer);
|
||||
const prevFile = deskEl.dataset.filename || "";
|
||||
const fileChanged = prevFile !== (printer.filename || "");
|
||||
const shownFile = stickyFilename(printer, prevFile);
|
||||
const fileChanged = prevFile !== shownFile;
|
||||
deskEl.dataset.host = printer.host;
|
||||
deskEl.dataset.filename = printer.filename || "";
|
||||
deskEl.dataset.filename = shownFile;
|
||||
deskEl.dataset.progress = String(printer.progress || 0);
|
||||
deskEl.classList.remove(...DESK_STATES);
|
||||
deskEl.classList.add(state);
|
||||
@@ -712,11 +862,12 @@ function patchDesk(printer) {
|
||||
setHtml(role("temps"), tempbarsHtml(printer, state));
|
||||
|
||||
const jobline = role("jobline");
|
||||
if (jobline) jobline.hidden = !jobLineVisible(printer, state) && !printer.preview;
|
||||
const jobVisible = jobLineVisible(printer, state, shownFile);
|
||||
if (jobline) jobline.hidden = !jobVisible && !printer.preview;
|
||||
const job = role("job");
|
||||
if (job) {
|
||||
job.textContent = jobName(printer.filename) || (jobLineVisible(printer, state) ? "" : "Kein Auftrag");
|
||||
job.title = printer.filename || "";
|
||||
setText(job, jobName(shownFile) || (jobVisible ? "" : "Kein Auftrag"));
|
||||
job.title = shownFile || "";
|
||||
}
|
||||
const preview = role("preview");
|
||||
if (preview) {
|
||||
@@ -732,7 +883,7 @@ function patchDesk(printer) {
|
||||
setHtml(role("facts"), factsHtml(printer));
|
||||
setHtml(role("flow"), flowHtml(printer));
|
||||
setHtml(role("strips"), stripsHtml(printer, state));
|
||||
setHtml(role("controls"), actionsHtml(state));
|
||||
setHtml(role("controls"), actionsHtml(state, printer));
|
||||
const estop = role("estop");
|
||||
if (estop) {
|
||||
const showStop = state === "printing" || state === "paused" || state === "heating";
|
||||
@@ -745,10 +896,14 @@ function patchDesk(printer) {
|
||||
}
|
||||
|
||||
const steer = role("steer");
|
||||
if (steer && (!steer.querySelector(".steer") || fileChanged)) {
|
||||
setHtml(steer, steerPanelHtml(printer));
|
||||
} else {
|
||||
patchSteerValues(deskEl, printer);
|
||||
if (steer) {
|
||||
const panel = steer.querySelector(".steer");
|
||||
const nextKey = aceSteerKey(printer);
|
||||
if (!panel || fileChanged || panel.dataset.aceKey !== nextKey) {
|
||||
setHtml(steer, steerPanelHtml(printer));
|
||||
} else {
|
||||
patchSteerValues(deskEl, printer);
|
||||
}
|
||||
}
|
||||
setHtml(role("mesh"), heatMapHtml(printer));
|
||||
|
||||
@@ -761,8 +916,9 @@ async function syncDeskViewer(printer) {
|
||||
const canvas = deskEl.querySelector('[data-role="path-canvas"]');
|
||||
const caption = deskEl.querySelector('[data-role="path-caption"]');
|
||||
if (!canvas || !window.GrottiGcodeViewer || !window.THREE) return;
|
||||
const filename = printer.filename || "";
|
||||
const filename = stickyFilename(printer, deskEl.dataset.filename || "");
|
||||
const progress = Number(printer.progress || 0);
|
||||
const gen = ++deskSyncGen;
|
||||
if (!deskViewer) {
|
||||
deskViewer = GrottiGcodeViewer.create(canvas);
|
||||
deskViewer.setExcludeObjects({ objects: [], excluded: [], current: null }, {
|
||||
@@ -777,45 +933,55 @@ async function syncDeskViewer(printer) {
|
||||
let file = matchLibraryFile(filename);
|
||||
if (!file && filename && !wallPullTried.has(deskHost + "|" + filename)) {
|
||||
wallPullTried.add(deskHost + "|" + filename);
|
||||
if (caption) caption.textContent = "lade vom Drucker …";
|
||||
setText(caption, "lade vom Drucker …");
|
||||
try {
|
||||
const saved = await api("/dateien/von-drucker", {
|
||||
method: "POST",
|
||||
json: { host: deskHost, filename },
|
||||
});
|
||||
if (gen !== deskSyncGen) return;
|
||||
libraryFiles = await api("/dateien");
|
||||
if (gen !== deskSyncGen) return;
|
||||
file = saved || matchLibraryFile(filename);
|
||||
} catch (error) {
|
||||
/* bleibt leer */
|
||||
}
|
||||
}
|
||||
if (!file) {
|
||||
// Kurz leerer filename: Pfad und Caption nicht verwerfen (sonst Flackern alle 5 s).
|
||||
if (!filename && deskViewerFileId) return;
|
||||
deskViewer.setPath({ points: [] });
|
||||
deskViewerFileId = null;
|
||||
if (caption) caption.textContent = filename ? "Datei nicht in Grotti" : "kein Auftrag";
|
||||
setText(caption, filename ? "Datei nicht in Grotti" : "kein Auftrag");
|
||||
renderExcludePanel(printer);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
let data = toolpathCache.get(file.id);
|
||||
const needLoad = !data || data.version !== 8 || deskViewerFileId !== file.id;
|
||||
if (needLoad) {
|
||||
if (caption) caption.textContent = "wird gelesen …";
|
||||
const cachedOk = data && Number(data.version) === 8;
|
||||
const needFetch = !cachedOk;
|
||||
const needSetPath = deskViewerFileId !== file.id;
|
||||
if (needFetch) {
|
||||
if (needSetPath) setText(caption, "wird gelesen …");
|
||||
data = await api("/dateien/" + file.id + "/pfad?tp=8");
|
||||
if (gen !== deskSyncGen) return;
|
||||
toolpathCache.set(file.id, data);
|
||||
const sameFile = deskViewerFileId === file.id;
|
||||
deskViewer.setPath(data, { keepView: sameFile });
|
||||
}
|
||||
if (needFetch || needSetPath) {
|
||||
deskViewer.setPath(data, { keepView: deskViewerFileId === file.id });
|
||||
deskViewerFileId = file.id;
|
||||
}
|
||||
if (!(data.points && data.points.length)) {
|
||||
if (caption) caption.textContent = "leer in Datei";
|
||||
setText(caption, "leer in Datei");
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
// File-Id behalten — sonst setzt der nächste Poll die 3D-Ansicht zurück
|
||||
if (caption) caption.textContent = error.message || "fehlgeschlagen";
|
||||
if (gen !== deskSyncGen) return;
|
||||
setText(caption, error.message || "fehlgeschlagen");
|
||||
return;
|
||||
}
|
||||
if (gen !== deskSyncGen) return;
|
||||
deskViewer.setProgress(progress, {
|
||||
layer: printer.layer,
|
||||
layers: printer.layers,
|
||||
@@ -836,7 +1002,7 @@ async function syncDeskViewer(printer) {
|
||||
const layerTxt = printer.layers
|
||||
? " · Schicht " + (printer.layer || 0) + "/" + printer.layers
|
||||
: "";
|
||||
if (caption) caption.textContent = file.name + " · " + Math.round(progress * 100) + " %" + layerTxt;
|
||||
setText(caption, jobName(file.name) + " · " + Math.round(progress * 100) + " %" + layerTxt);
|
||||
}
|
||||
|
||||
function renderExcludePanel(printer) {
|
||||
@@ -1036,8 +1202,8 @@ async function refresh() {
|
||||
if (!libraryFiles.length) {
|
||||
try { libraryFiles = await api("/dateien"); } catch (error) { libraryFiles = []; }
|
||||
}
|
||||
if (!wallSpools.length) {
|
||||
try { wallSpools = await api("/spulen"); } catch (error) { wallSpools = []; }
|
||||
if (!wallSpools.length || deskHost) {
|
||||
try { wallSpools = await api("/spulen"); } catch (error) { /* behalten */ }
|
||||
}
|
||||
const all = await api("/drucker");
|
||||
const printers = sortPrinters(all.slice());
|
||||
@@ -1259,11 +1425,35 @@ async function handleWallClick(event) {
|
||||
method: "POST", json: { action: "fan", percent: 0 },
|
||||
});
|
||||
toast("Heizung und Lüfter aus.");
|
||||
} else if (go === "dryer_start") {
|
||||
const id = Number(steerBtn.dataset.dryerId);
|
||||
const temp = val("dryer-temp-" + id);
|
||||
const duration = val("dryer-dur-" + id);
|
||||
await api("/drucker/" + encodeURIComponent(host) + "/steuern", {
|
||||
method: "POST",
|
||||
json: { action: "dryer_start", id, temp, duration },
|
||||
});
|
||||
toast("Trockner gestartet.");
|
||||
} else if (go === "dryer_stop") {
|
||||
const id = Number(steerBtn.dataset.dryerId);
|
||||
await api("/drucker/" + encodeURIComponent(host) + "/steuern", {
|
||||
method: "POST",
|
||||
json: { action: "dryer_stop", id },
|
||||
});
|
||||
toast("Trockner gestoppt.");
|
||||
} else if (go === "slot_setzen") {
|
||||
const aceSlot = Number(steerBtn.dataset.aceSlot);
|
||||
await api("/drucker/" + encodeURIComponent(host) + "/steuern", {
|
||||
method: "POST",
|
||||
json: { action: "slot_setzen", ace_slot: aceSlot },
|
||||
});
|
||||
toast("Platz an den Drucker geschrieben.");
|
||||
}
|
||||
showNote("note", null);
|
||||
refresh();
|
||||
} catch (error) {
|
||||
showNote("note", error);
|
||||
toast(error.message, "bad");
|
||||
} finally {
|
||||
steerBtn.disabled = false;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user