Grotti 2.10.0: Orca-Senden, Slice-Ordner, ED-Strom, Hygiene.

Slice-Watch mit Ordnern/Filtern, Orca-Nachbearbeitung, Cockpit-Verlauf
mit ED, Zeitzone im Browser. Tasten-Grenze und README-API angeglichen,
toter Code entfernt, Requirements gepinnt.
This commit is contained in:
Dominic Gundel committed 2026-10-18 00:41:15 +02:00
1 parent efd0801cd1
commit 9e22cebb81
48 files changed
+2259 -422

No files matched your search

+12 -32
View File
@@ -29,33 +29,6 @@ function sourceLabel(source, estimate) {
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";
@@ -66,7 +39,14 @@ function render(data) {
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 energy = data.energy || {};
const energyEuro = energy.cost != null ? Number(energy.cost) : Number(run.energy_cost || 0);
const cost = Number(run.filament_cost || 0) + energyEuro;
const energyHint = energy.source === "ed"
? (Number(energy.kwh) || 0).toLocaleString("de-DE", { maximumFractionDigits: 2 }) + " kWh aus ED"
: (energy.kwh != null
? (Number(energy.kwh) || 0).toLocaleString("de-DE", { maximumFractionDigits: 2 }) + " kWh pauschal"
: "");
const spoolRows = (data.spools || []).map((s) => `
<tr>
@@ -79,7 +59,7 @@ function render(data) {
</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>`
`<li><span class="tiny">${escapeHtml(formatDateTime(e.created_at))}</span> ${escapeHtml(e.message || e.kind)}</li>`
).join("") || "<li class=\"tiny\">Keine Ereignisse.</li>";
const labels = (data.labels || []).filter((l) => !l.excluded);
@@ -96,11 +76,11 @@ function render(data) {
<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 class="tiny">${escapeHtml(formatDateRange(run.started_at, 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>
<p>Kosten <b>${euro(cost)}</b> (Filament ${euro(run.filament_cost)} · Strom ${euro(energyEuro)}${energyHint ? " · " + energyHint : ""})</p>
${run.message ? "<p class=\"msg\">" + escapeHtml(run.message) + "</p>" : ""}
${data.project ? "<p class=\"tiny\">Projekt: " + escapeHtml(data.project.name) + "</p>" : ""}
</div>
@@ -115,7 +95,7 @@ function render(data) {
</div>
<div class="card">
<h2>Verlauf</h2>
${chart(data.samples)}
${tempChartHtml(data.samples)}
</div>
<div class="card grid-2">
<div>
+267 -18
View File
@@ -11,6 +11,7 @@
</select>
<label class="button ghost" for="upload-file"><svg class="ico"><use href="#i-upload"/></svg>Hochladen</label>
<input id="upload-file" type="file" accept=".gcode,.3mf,.gcode.3mf" multiple hidden>
<button type="button" class="ghost" id="folder-create" hidden><svg class="ico"><use href="#i-folder"/></svg>Ordner anlegen</button>
<button type="button" class="ghost" id="rescan"><svg class="ico"><use href="#i-refresh"/></svg>Ordner neu lesen</button>
</div>
</div>
@@ -22,7 +23,7 @@
<span class="count" id="lib-count">0</span>
</div>
<div class="lib-tools">
<input id="file-search" type="search" placeholder="Datei suchen…" autocomplete="off">
<input id="file-search" type="search" placeholder="Name, Material, Projekt…" autocomplete="off">
<select id="file-filter-family">
<option value="">Alle Maschinen</option>
<option value="kobra_3">Kobra 3</option>
@@ -31,6 +32,20 @@
<select id="file-filter-project">
<option value="">Alle Projekte</option>
</select>
<select id="file-filter-material" title="Material">
<option value="">Alle Materialien</option>
</select>
<select id="file-filter-folder" title="Ordner">
<option value="">Alle Ordner</option>
</select>
<select id="file-sort" title="Sortierung">
<option value="mtime">Neueste</option>
<option value="name">Name A–Z</option>
<option value="material">Material</option>
<option value="folder">Ordner</option>
<option value="time">Druckzeit</option>
<option value="weight">Gewicht</option>
</select>
</div>
<p id="file-note" class="tiny"></p>
<div class="lib-list" id="lib-list">
@@ -152,8 +167,11 @@
const families = { kobra_s1: "Kobra S1", kobra_3: "Kobra 3" };
const ABLAUF_KEY = "grotti-ablauf-v2";
const SEND_KEY = "grotti-send-v1";
const LIB_KEY = "grotti-lib-v1";
const STEP_TITLES = { print: "Drucken", cool: "Abkühlen", sweep: "Schieben", check: "Tasten" };
let files = [];
let knownFolders = [];
let hasWatchDir = false;
let printers = [];
let spools = [];
let projects = [];
@@ -470,17 +488,101 @@ function collectAceMaps() {
/* ---------- Bibliothek ---------- */
function projectNameOf(file) {
const project = projects.find((p) => String(p.id) === String(file.project_id));
return project ? project.name : "";
}
function materialKey(file) {
return materialText(file) || "";
}
function folderKey(file) {
const folder = file.folder;
if (folder === "Upload") return "Upload";
if (folder) return folder;
return "";
}
function folderLabel(folder) {
if (folder === "Upload") return "Upload";
if (folder) return folder;
return "Wurzel";
}
function saveLibMemory() {
try {
localStorage.setItem(LIB_KEY, JSON.stringify({
search: document.getElementById("file-search").value || "",
family: document.getElementById("file-filter-family").value || "",
project: document.getElementById("file-filter-project").value || "",
material: document.getElementById("file-filter-material").value || "",
folder: document.getElementById("file-filter-folder").value || "",
sort: document.getElementById("file-sort").value || "mtime",
}));
} catch (error) { /* ignore */ }
}
function loadLibMemory() {
try {
const data = JSON.parse(localStorage.getItem(LIB_KEY) || "{}");
const set = (id, value) => {
const node = document.getElementById(id);
if (!node || value == null) return;
node.value = value;
};
set("file-search", data.search || "");
set("file-filter-family", data.family || "");
set("file-filter-project", data.project || "");
set("file-filter-material", data.material || "");
set("file-filter-folder", data.folder || "");
set("file-sort", data.sort || "mtime");
} catch (error) { /* ignore */ }
}
function filteredFiles() {
const q = (document.getElementById("file-search").value || "").trim().toLowerCase();
const family = document.getElementById("file-filter-family").value;
const project = document.getElementById("file-filter-project").value;
return files.filter((file) => {
const material = document.getElementById("file-filter-material").value;
const folder = document.getElementById("file-filter-folder").value;
const sort = document.getElementById("file-sort").value || "mtime";
const shown = files.filter((file) => {
if (family && file.printer_family !== family) return false;
if (project === "none" && file.project_id) return false;
if (project && project !== "none" && String(file.project_id) !== project) return false;
if (material === "none" && materialKey(file)) return false;
if (material && material !== "none") {
const types = ((file.filaments) || []).map((f) => f.type).filter(Boolean);
if (!types.includes(material)) return false;
}
if (folder === "root" && folderKey(file)) return false;
if (folder && folder !== "root" && folderKey(file) !== folder) return false;
if (!q) return true;
return String(file.name || "").toLowerCase().includes(q);
const hay = [
file.name || "",
materialKey(file),
projectNameOf(file),
folderKey(file),
].join(" ").toLowerCase();
return hay.includes(q);
});
const cmpStr = (a, b) => String(a || "").localeCompare(String(b || ""), "de", { sensitivity: "base" });
shown.sort((a, b) => {
if (sort === "name") return cmpStr(a.name, b.name) || (Number(b.mtime) || 0) - (Number(a.mtime) || 0);
if (sort === "material") {
return cmpStr(materialKey(a) || "\uffff", materialKey(b) || "\uffff")
|| cmpStr(a.name, b.name);
}
if (sort === "folder") {
return cmpStr(folderLabel(folderKey(a)), folderLabel(folderKey(b)))
|| cmpStr(a.name, b.name);
}
if (sort === "time") return (Number(b.estimated_s) || 0) - (Number(a.estimated_s) || 0) || cmpStr(a.name, b.name);
if (sort === "weight") return (Number(b.weight_g) || 0) - (Number(a.weight_g) || 0) || cmpStr(a.name, b.name);
return (Number(b.mtime) || 0) - (Number(a.mtime) || 0) || cmpStr(a.name, b.name);
});
return shown;
}
function fillProjectFilter() {
@@ -496,6 +598,70 @@ function fillProjectFilter() {
if ([...select.options].some((o) => o.value === current)) select.value = current;
}
function fillMaterialFilter() {
const select = document.getElementById("file-filter-material");
const current = select.value;
const types = [...new Set(files.flatMap((f) => ((f.filaments) || []).map((x) => x.type).filter(Boolean)))];
types.sort((a, b) => a.localeCompare(b, "de", { sensitivity: "base" }));
select.innerHTML = '<option value="">Alle Materialien</option><option value="none">Ohne Material</option>';
types.forEach((type) => {
const option = document.createElement("option");
option.value = type;
option.textContent = type;
select.appendChild(option);
});
if ([...select.options].some((o) => o.value === current)) select.value = current;
}
function allFolderNames() {
const fromFiles = files.map((f) => folderKey(f)).filter((f) => f && f !== "Upload");
return [...new Set([...knownFolders, ...fromFiles])]
.sort((a, b) => folderLabel(a).localeCompare(folderLabel(b), "de", { sensitivity: "base" }));
}
function fillFolderFilter() {
const select = document.getElementById("file-filter-folder");
const current = select.value;
const folders = allFolderNames();
select.innerHTML = '<option value="">Alle Ordner</option><option value="root">Wurzel</option>';
folders.forEach((folder) => {
const option = document.createElement("option");
option.value = folder;
option.textContent = folderLabel(folder);
select.appendChild(option);
});
if ([...select.options].some((o) => o.value === current)) select.value = current;
}
function moveSelectHtml(file) {
if (!hasWatchDir || file.source !== "watch") return "";
const current = folderKey(file);
const opts = [
`<option value="" selected disabled>Verschieben…</option>`,
`<option value="__root__">Wurzel</option>`,
...allFolderNames().map((folder) =>
`<option value="${escapeHtml(folder)}"${folder === current ? " disabled" : ""}>${escapeHtml(folderLabel(folder))}</option>`
),
`<option value="__new__">Neuer Ordner…</option>`,
];
return `<select class="lib-move" data-move="${file.id}" title="In Ordner verschieben" onclick="event.stopPropagation()">${opts.join("")}</select>`;
}
async function promptCreateFolder() {
const name = window.prompt("Neuer Ordner unter dem Slice-Pfad (z. B. PETG oder PLA/tests):", "");
if (name == null) return null;
const trimmed = String(name).trim();
if (!trimmed) {
toast("Ordnername fehlt.", "bad");
return null;
}
const result = await api("/dateien/ordner", { method: "POST", json: { folder: trimmed } });
knownFolders = result.folders || [];
fillFolderFilter();
toast("Ordner „" + (result.folder || trimmed) + "“ angelegt.", "good");
return result.folder || trimmed;
}
function fillProjects() {
const opts = projects.map((row) =>
`<option value="${escapeHtml(row.id)}">${escapeHtml(row.name)}</option>`
@@ -585,9 +751,21 @@ function thumbHtml(file) {
: `<span class="ph">${icon("cube")}</span>`;
}
function libItemHtml(file) {
const project = projects.find((p) => String(p.id) === String(file.project_id));
const del = file.source === "upload"
? `<button type="button" class="icon-btn" data-delete="${file.id}" title="Hochgeladene Datei löschen">${icon("x")}</button>` : "";
return `<div class="lib-item ${String(file.id) === String(sel.fileId) ? "on" : ""}" role="button" tabindex="0" data-file="${file.id}">
${thumbHtml(file)}
<span style="min-width:0"><span class="t" title="${escapeHtml(file.name)}">${escapeHtml(file.name)}</span><span class="m">${alwaysMeta(file, project)}</span></span>
<span class="side">${moveSelectHtml(file)}${infoPopupHtml(file)}${del}</span>
</div>`;
}
function renderLibrary() {
const list = document.getElementById("lib-list");
const shown = filteredFiles();
const sort = document.getElementById("file-sort").value || "mtime";
document.getElementById("lib-count").textContent = shown.length === files.length ? String(files.length) : shown.length + "/" + files.length;
if (!files.length) {
list.innerHTML = '<div class="picked-empty">' + icon("file") + '<span>Noch keine Datei.</span><span class="tiny">Hochladen oder Slice-Ordner in den Einstellungen setzen.</span></div>';
@@ -597,16 +775,19 @@ function renderLibrary() {
list.innerHTML = '<div class="picked-empty">' + icon("search") + '<span>Keine Datei passt.</span></div>';
return;
}
list.innerHTML = shown.map((file) => {
const project = projects.find((p) => String(p.id) === String(file.project_id));
const del = file.source === "upload"
? `<button type="button" class="icon-btn" data-delete="${file.id}" title="Hochgeladene Datei löschen">${icon("x")}</button>` : "";
return `<div class="lib-item ${String(file.id) === String(sel.fileId) ? "on" : ""}" role="button" tabindex="0" data-file="${file.id}">
${thumbHtml(file)}
<span style="min-width:0"><span class="t" title="${escapeHtml(file.name)}">${escapeHtml(file.name)}</span><span class="m">${alwaysMeta(file, project)}</span></span>
<span class="side">${infoPopupHtml(file)}${del}</span>
</div>`;
}).join("");
const parts = [];
let lastGroup = null;
shown.forEach((file) => {
let group = null;
if (sort === "material") group = materialKey(file) || "Ohne Material";
else if (sort === "folder") group = folderLabel(folderKey(file));
if (group != null && group !== lastGroup) {
parts.push(`<div class="lib-group">${escapeHtml(group)}</div>`);
lastGroup = group;
}
parts.push(libItemHtml(file));
});
list.innerHTML = parts.join("");
}
/* ---------- Flow rechts ---------- */
@@ -795,18 +976,23 @@ function selectFile(id) {
/* ---------- Laden ---------- */
async function load() {
const [settings, fileRows, printerRows, spoolRows, projectRows, defaults] = await Promise.all([
const [settings, fileRows, printerRows, spoolRows, projectRows, defaults, folderPayload] = await Promise.all([
api("/einstellungen"),
api("/dateien"),
api("/drucker?live=1"),
api("/spulen"),
api("/projekte").catch(() => []),
api("/abraeumen-defaults").catch(() => null),
api("/dateien/ordner").catch(() => ({ folders: [] })),
]);
document.getElementById("watch").textContent = settings.watch_dir
hasWatchDir = !!(settings.watch_dir);
document.getElementById("watch").textContent = hasWatchDir
? "Slice-Ordner: " + settings.watch_dir
: "Kein Slice-Ordner — Dateien hier hochladen.";
const createBtn = document.getElementById("folder-create");
if (createBtn) createBtn.hidden = !hasWatchDir;
files = fileRows;
knownFolders = (folderPayload && folderPayload.folders) || [];
printers = printerRows;
spools = spoolRows;
projects = projectRows || [];
@@ -816,11 +1002,26 @@ async function load() {
if (sel.fileId && !selectedFile()) sel.fileId = null;
fillProjects();
fillProjectFilter();
fillMaterialFilter();
fillFolderFilter();
loadLibMemory();
fillProjectFilter();
fillMaterialFilter();
fillFolderFilter();
renderLibrary();
renderTabs();
refreshFlow();
const file = selectedFile();
if (file) showToolpath(file, 0);
// Deep-Link von Orca /orca_senden: Senden in den Blick, Optionen offen für ACE
if (new URLSearchParams(location.search).get("file") && file) {
sel.tab = "send";
renderTabs();
const opts = document.getElementById("options");
if (opts) opts.open = true;
const panel = document.querySelector(".send-panel");
if (panel) panel.scrollIntoView({ behavior: "smooth", block: "start" });
}
}
async function refreshPrinters() {
@@ -1161,6 +1362,10 @@ async function doAblauf() {
/* ---------- Ereignisse ---------- */
document.getElementById("lib-list").addEventListener("click", (event) => {
if (event.target.closest(".lib-move")) {
event.stopPropagation();
return;
}
const del = event.target.closest("[data-delete]");
if (del) {
event.stopPropagation();
@@ -1179,6 +1384,29 @@ document.getElementById("lib-list").addEventListener("click", (event) => {
const item = event.target.closest("[data-file]");
if (item) selectFile(item.dataset.file);
});
document.getElementById("lib-list").addEventListener("change", async (event) => {
const select = event.target.closest("select.lib-move");
if (!select) return;
event.stopPropagation();
const fileId = select.dataset.move;
let folder = select.value;
select.value = "";
if (!folder || !fileId) return;
try {
if (folder === "__new__") {
folder = await promptCreateFolder();
if (!folder) return;
} else if (folder === "__root__") {
folder = "";
}
await api("/dateien/" + fileId + "/verschieben", { method: "POST", json: { folder } });
toast(folder ? ("Nach „" + folder + "“ verschoben.") : "In die Wurzel verschoben.", "good");
await load();
} catch (error) {
toast(error.message || String(error), "bad");
}
});
document.getElementById("lib-list").addEventListener("keydown", (event) => {
const item = event.target.closest("[data-file]");
if (item && (event.key === "Enter" || event.key === " ")) {
@@ -1186,9 +1414,22 @@ document.getElementById("lib-list").addEventListener("keydown", (event) => {
selectFile(item.dataset.file);
}
});
["file-search", "file-filter-family", "file-filter-project"].forEach((id) => {
document.getElementById(id).addEventListener("input", renderLibrary);
document.getElementById(id).addEventListener("change", renderLibrary);
[
"file-search",
"file-filter-family",
"file-filter-project",
"file-filter-material",
"file-filter-folder",
"file-sort",
].forEach((id) => {
const node = document.getElementById(id);
if (!node) return;
const onChange = () => {
saveLibMemory();
renderLibrary();
};
node.addEventListener("input", onChange);
node.addEventListener("change", onChange);
});
document.getElementById("targets").addEventListener("click", (event) => {
@@ -1323,6 +1564,14 @@ const libCard = document.getElementById("lib-card");
}));
libCard.addEventListener("drop", (event) => uploadFiles(event.dataTransfer.files));
document.getElementById("folder-create")?.addEventListener("click", async () => {
try {
await promptCreateFolder();
} catch (error) {
toast(error.message || String(error), "bad");
}
});
document.getElementById("rescan").addEventListener("click", async () => {
try {
const result = await api("/dateien/neu-lesen", { method: "POST" });
+118 -4
View File
@@ -18,6 +18,17 @@
</select>
</div>
<p class="hint">Regal: größere Bedienelemente, kompakte Navigation — ideal für ~1024×860 Touch am Regal. Shortcut: <code>?shelf=1</code></p>
<div class="field" style="max-width:16rem; margin-top:0.85rem">
<label for="tz-mode">Zeitzone</label>
<select id="tz-mode">
<option value="auto">Browser (automatisch)</option>
<option value="Europe/Berlin">Europe/Berlin</option>
<option value="Europe/Vienna">Europe/Vienna</option>
<option value="Europe/Zurich">Europe/Zurich</option>
<option value="UTC">UTC</option>
</select>
</div>
<p class="hint">Für Protokoll, Bericht und Aktivitätszeiten. Nur dieser Browser.</p>
<p id="display-note" class="ok" hidden>Gespeichert — gilt sofort.</p>
</section>
@@ -29,6 +40,20 @@
<p id="auto-note"></p>
</section>
<section class="card admin-only">
<h2>Slice-Ordner</h2>
<p class="hint">Ordner, den Grotti für die Bibliothek beobachtet (Orca-Export, „Ordner neu lesen“). Leer = nur manueller Upload. Standard: Ordner <code>slice</code> im Grotti-Repo.</p>
<div class="field" style="max-width:36rem">
<label for="watch-dir">Pfad</label>
<input id="watch-dir" type="text" autocomplete="off" placeholder="D:\Projekte\Grotti\slice" spellcheck="false">
</div>
<div class="actions">
<button type="button" id="watch-save">Speichern</button>
<button type="button" class="ghost" id="watch-default">Standardordner</button>
</div>
<p id="watch-note"></p>
</section>
<section class="card admin-only">
<h2>Öffentliche Adresse</h2>
<p class="hint">FQDN bzw. Basis-URL für QR-Codes auf Spulen-Etiketten. Leer = Host der aktuellen Anfrage (z.&nbsp;B. localhost).</p>
@@ -103,15 +128,19 @@
<section class="card admin-only">
<h2>Energie</h2>
<p class="hint">Für Kosten im Protokoll. Filament zählt erst mit zugeordneter Spule.</p>
<p class="hint">Kosten im Protokoll. Mit Düse-/Bett-Watt und ED-Proben schätzt Grotti den Verbrauch; sonst Dauer × Grundlast. Filament braucht eine Spule.</p>
<div class="grid-2">
<div class="field"><label for="en-watts">Drucker W</label><input id="en-watts" type="number" step="1"></div>
<div class="field"><label for="en-watts">Grundlast W</label><input id="en-watts" type="number" step="1" title="Elektronik/Motion — nicht die Heizer"></div>
<div class="field"><label for="en-kwh">€ / kWh</label><input id="en-kwh" type="number" step="0.01"></div>
<div class="field"><label for="en-nozzle">Düse W</label><input id="en-nozzle" type="number" step="1" placeholder="z. B. 60"></div>
<div class="field"><label for="en-bed">Bett W</label><input id="en-bed" type="number" step="1" placeholder="z. B. 220"></div>
<div class="field"><label for="en-dryer">Trockner W</label><input id="en-dryer" type="number" step="1"></div>
<div class="field"><label for="en-dryh">Trockenstunden</label><input id="en-dryh" type="number" step="0.1"></div>
<div class="field"><label for="en-waste">Ausschuss %</label><input id="en-waste" type="number" step="0.1"></div>
<div class="field"><label for="en-util">Auslastung %</label><input id="en-util" type="number" step="1"></div>
</div>
<p class="hint">Grundlast = Elektronik/Motion (nicht ganzer Drucker). Düse/Bett 0 = nur pauschal. Typisch Kobra: Düse ~60 W, Bett ~200–300 W.</p>
<p id="en-ed-hint" class="hint" hidden></p>
<div class="actions"><button type="button" id="en-save">Speichern</button></div>
<p id="en-note"></p>
</section>
@@ -330,6 +359,11 @@ async function load() {
]);
if (isAdmin) renderPrinters(printers);
document.getElementById("auto-queue").checked = settings.auto_queue !== false;
const watchDir = document.getElementById("watch-dir");
if (watchDir) {
watchDir.value = settings.watch_dir || "";
watchDir.dataset.default = settings.watch_dir_default || "";
}
const fqdn = document.getElementById("fqdn");
if (fqdn) fqdn.value = settings.fqdn || "";
const tgEnabled = document.getElementById("tg-enabled");
@@ -348,12 +382,15 @@ async function load() {
document.getElementById("al-ablauf").checked = !!alerts.ablauf;
document.getElementById("al-start").checked = !!alerts.start;
const en = settings.energy || {};
document.getElementById("en-watts").value = en.watts ?? 120;
document.getElementById("en-watts").value = en.watts ?? 40;
document.getElementById("en-nozzle").value = en.nozzle_watts ?? 60;
document.getElementById("en-bed").value = en.bed_watts ?? 220;
document.getElementById("en-kwh").value = en.euro_per_kwh ?? 0.35;
document.getElementById("en-dryer").value = en.dryer_watts ?? 0;
document.getElementById("en-dryh").value = en.dry_hours ?? 0;
document.getElementById("en-waste").value = en.waste_percent ?? 0;
document.getElementById("en-util").value = en.utilization ?? 50;
updateEnergyEdHint();
}
const legacy = document.getElementById("legacy-token-hint");
if (legacy) legacy.hidden = !settings.legacy_token_set;
@@ -417,6 +454,30 @@ document.getElementById("auto-save").addEventListener("click", async () => {
}
});
document.getElementById("watch-default")?.addEventListener("click", () => {
const input = document.getElementById("watch-dir");
if (input) input.value = input.dataset.default || "";
});
document.getElementById("watch-save")?.addEventListener("click", async () => {
try {
const out = await api("/einstellungen", {
method: "PATCH",
json: { watch_dir: document.getElementById("watch-dir").value.trim() },
});
document.getElementById("watch-dir").value = out.watch_dir || "";
showNote(
"watch-note",
null,
out.watch_dir
? "Slice-Ordner aktiv: " + out.watch_dir
: "Kein Slice-Ordner — nur manueller Upload."
);
} catch (error) {
showNote("watch-note", error);
}
});
document.getElementById("fqdn-save").addEventListener("click", async () => {
try {
const out = await api("/einstellungen", {
@@ -490,13 +551,36 @@ document.getElementById("tg-test").addEventListener("click", async () => {
}
});
function updateEnergyEdHint() {
const hint = document.getElementById("en-ed-hint");
if (!hint) return;
const nozzle = Number(document.getElementById("en-nozzle").value) || 0;
const bed = Number(document.getElementById("en-bed").value) || 0;
if (nozzle > 0 && bed > 0) {
hint.hidden = true;
hint.textContent = "";
return;
}
hint.hidden = false;
hint.textContent = "Düse oder Bett noch 0 — Bericht bleibt „pauschal“. Für ED z. B. Düse 60 W und Bett 220 W setzen.";
}
["en-nozzle", "en-bed"].forEach((id) => {
const node = document.getElementById(id);
if (node) node.addEventListener("input", updateEnergyEdHint);
});
document.getElementById("en-save").addEventListener("click", async () => {
try {
const nozzle = Number(document.getElementById("en-nozzle").value) || 0;
const bed = Number(document.getElementById("en-bed").value) || 0;
await api("/einstellungen", {
method: "PATCH",
json: {
energy: {
watts: Number(document.getElementById("en-watts").value),
nozzle_watts: nozzle,
bed_watts: bed,
euro_per_kwh: Number(document.getElementById("en-kwh").value),
dryer_watts: Number(document.getElementById("en-dryer").value),
dry_hours: Number(document.getElementById("en-dryh").value),
@@ -505,7 +589,17 @@ document.getElementById("en-save").addEventListener("click", async () => {
},
},
});
showNote("en-note", null, "Energie gespeichert.");
updateEnergyEdHint();
if (nozzle <= 0 || bed <= 0) {
const note = document.getElementById("en-note");
if (note) {
note.textContent = "Gespeichert. Düse/Bett 0 → nur pauschal. Für ED z. B. 60 / 220 W setzen.";
note.className = "msg";
}
toast("Energie gespeichert — ED noch aus (Düse/Bett 0).", "bad");
} else {
showNote("en-note", null, "Energie gespeichert.");
}
} catch (error) {
showNote("en-note", error);
}
@@ -534,6 +628,26 @@ document.getElementById("en-save").addEventListener("click", async () => {
});
})();
(function initTimeZone() {
const sel = document.getElementById("tz-mode");
if (!sel) return;
const known = ["auto", "Europe/Berlin", "Europe/Vienna", "Europe/Zurich", "UTC"];
try {
const value = localStorage.getItem(TZ_KEY) || "auto";
sel.value = known.includes(value) ? value : "auto";
} catch (error) {
sel.value = "auto";
}
sel.addEventListener("change", () => {
try { localStorage.setItem(TZ_KEY, sel.value || "auto"); } catch (error) {}
const note = document.getElementById("display-note");
if (note) {
note.hidden = false;
setTimeout(() => { note.hidden = true; }, 2500);
}
});
})();
load().catch((error) => showNote("printer-note", error));
</script>
{% endblock %}
+1 -1
View File
@@ -95,7 +95,7 @@
<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>
<p>Soll {{ '%.0f'|format(soll) }} g</p>
{% endif %}
<p>{{ run.printer_name }} · {{ (run.ended_at or run.started_at or '')[:10] }}</p>
{% for spool in spools %}
+55 -26
View File
@@ -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=45">
<link rel="stylesheet" href="{{ url_for('static', filename='grotti.css') }}?v=52">
<script>
(function () {
try {
@@ -157,7 +157,7 @@
<a href="/warteschlange" class="{{ 'on' if page == 'warteschlange' else '' }} nav-shelf"><svg class="ico"><use href="#i-queue"/></svg><span>Warteschlange</span></a>
<a href="/aktivitaet" class="{{ 'on' if page == 'aktivitaet' else '' }} nav-desk"><svg class="ico"><use href="#i-activity"/></svg><span>Aktivität</span></a>
</nav>
<script src="{{ url_for('static', filename='api.js') }}?v=24"></script>
<script src="{{ url_for('static', filename='api.js') }}?v=26"></script>
<script>
document.getElementById("logout-btn")?.addEventListener("click", async () => {
try { await api("/auth/logout", { method: "POST", json: {} }); } catch (e) {}
@@ -241,21 +241,44 @@ async function cancelQueueItem(job, button) {
}
window.cancelQueueItem = cancelQueueItem;
function runItemHtml(job) {
const rep = repeatText(job);
const steps = job.is_ablauf ? stepsHtml(job.step_labels, job.display_step_index != null ? job.display_step_index : job.step_index, true) : "";
return `<li class="queue-item running">
<div class="queue-row">
<a class="queue-link grow" href="/#${encodeURIComponent(job.host || "")}"><span class="queue-name">${escapeHtml(queueTarget(job))}</span></a>
${rep ? `<span class="queue-sub">${escapeHtml(rep)}</span>` : ""}
<span class="queue-tools"><button type="button" class="icon-btn danger" data-cancel-q="${job.id}" title="Abbrechen">${icon("x")}</button></span>
</div>
${steps}
<span class="queue-msg">${escapeHtml(phaseText(job))}</span>
<span class="queue-sub">${escapeHtml(jobLabel(job.filename))}</span>
function ablaufStepFrac(job) {
if (!job || !job.is_ablauf || !job.step_labels || !job.step_labels.length) return "";
const idx = Number(job.display_step_index != null ? job.display_step_index : job.step_index) || 0;
return (idx + 1) + "/" + job.step_labels.length;
}
function runLineHtml({ name, progress, remain, filename, tipExtra, cancelId }) {
const pct = Math.max(0, Math.min(100, Math.round((Number(progress) || 0) * 100)));
const finish = remain > 0 ? finishClock(remain) : "";
const bits = [name];
if (pct > 0 || remain > 0) bits.push(pct + " %");
if (finish) bits.push(finish);
if (tipExtra) bits.push(tipExtra);
const tip = [
filename ? jobLabel(filename) : "",
remain > 0 ? "noch " + hours(remain) : "",
tipExtra ? "Schritt " + tipExtra : "",
].filter(Boolean).join(" · ");
return `<li class="queue-item running run-line" style="--pct:${pct}">
<a class="queue-link run-line-link" href="/warteschlange" title="${escapeHtml(tip || "Zur Warteschlange")}">
<span class="run-line-fill" aria-hidden="true"></span>
<span class="run-line-text">${escapeHtml(bits.join(" · "))}</span>
</a>
${cancelId != null ? `<span class="queue-tools"><button type="button" class="icon-btn danger" data-cancel-q="${cancelId}" title="Abbrechen">${icon("x")}</button></span>` : ""}
</li>`;
}
function runItemHtml(job) {
return runLineHtml({
name: queueTarget(job),
progress: job.progress,
remain: job.remain,
filename: job.filename,
tipExtra: ablaufStepFrac(job),
cancelId: job.id,
});
}
function waitItemHtml(job, index, total) {
const isError = job.status === "error";
const label = job.is_ablauf ? "Ablauf · " + jobLabel(job.filename) : jobLabel(job.filename);
@@ -276,17 +299,12 @@ function waitItemHtml(job, index, total) {
}
function directItemHtml(p) {
const pct = Math.round((Number(p.progress) || 0) * 100);
const eta = p.remain > 0 ? "noch " + hours(p.remain) + " · fertig " + finishClock(p.remain) : "";
return `<li class="queue-item running">
<div class="queue-row">
<a class="queue-link grow" href="/#${encodeURIComponent(p.host)}"><span class="queue-name">${escapeHtml(p.name || p.host)}</span></a>
<span class="queue-sub">${pct} %</span>
</div>
<span class="bar slim live"><span style="width:${pct}%"></span></span>
<span class="queue-msg">${escapeHtml(STATE_LABELS[p.state] || p.state)}${eta ? " · " + escapeHtml(eta) : ""}</span>
<span class="queue-sub">${escapeHtml(jobLabel(p.filename))}</span>
</li>`;
return runLineHtml({
name: p.name || p.host,
progress: p.progress,
remain: p.remain,
filename: p.filename,
});
}
let queueCache = [];
@@ -322,7 +340,18 @@ async function refreshPrintQueue() {
[jobs, active] = await Promise.all([api("/druck-queue"), api("/laeuft").catch(() => [])]);
} catch (error) { return; }
queueCache = jobs || [];
const running = queueCache.filter((job) => job.status === "running");
const liveByHost = Object.create(null);
(active || []).forEach((p) => { if (p.host) liveByHost[p.host] = p; });
const running = queueCache.filter((job) => job.status === "running").map((job) => {
const live = liveByHost[job.host];
if (!live) return job;
return {
...job,
progress: live.progress,
remain: live.remain,
filename: stickyLiveFilename(job.host, live.filename || job.filename, live.state) || job.filename,
};
});
const queuedHosts = new Set(running.map((job) => job.host));
const direct = (active || []).filter((p) => !queuedHosts.has(p.host)).map((p) => ({
...p,
+101 -58
View File
@@ -61,35 +61,6 @@
<input id="spool-name" required>
</div>
<div class="grid-2">
<div class="field"><label for="spool-brand">Marke</label><input id="spool-brand"></div>
<div class="field"><label for="spool-type">Typ</label><input id="spool-type" placeholder="PLA"></div>
</div>
<div class="grid-2">
<div class="field">
<label for="spool-chargen">Grotti-Charge</label>
<input id="spool-chargen" readonly placeholder="wird vergeben">
</div>
<div class="field">
<label for="spool-hersteller">Hersteller-Charge</label>
<input id="spool-hersteller" placeholder="vom Rollenetikett">
</div>
</div>
<details class="mat-color" id="color-box">
<summary>
<svg class="ico caret"><use href="#i-down"/></svg>
<span class="grow">Farbe</span>
<span class="tiny" id="color-sum">noch keine</span>
</summary>
<div class="mat-color-body">
<div id="color-picked" class="picked"></div>
<div id="color-solids" class="color-grid"></div>
<div id="color-multi" class="color-grid"></div>
<label class="mix check"><input id="color-mix" type="checkbox"> Eigene Mischung</label>
</div>
</details>
<div class="grid-2" style="margin-top:0.55rem">
<div class="field">
<label for="spool-printer">Drucker</label>
<select id="spool-printer"></select>
@@ -108,11 +79,47 @@
<option value="8">ACE 2 · Platz 4</option>
</select>
</div>
<div class="field"><label for="spool-price">€ pro kg</label><input id="spool-price" type="number" min="0" step="0.01" value="0"></div>
<div class="field"><label for="spool-left">Rest gramm</label><input id="spool-left" type="number" step="1" value="1000"></div>
<div class="field"><label for="spool-density">Dichte g/cm³</label><input id="spool-density" type="number" min="0.1" step="0.01" value="1.24"></div>
<div class="field"><label for="spool-vol">Volumen mm³/s</label><input id="spool-vol" type="number" min="0.1" step="0.1" value="12"></div>
<div class="field"><label for="spool-left">Rest gramm</label><input id="spool-left" type="number" step="0.1" value="1000"></div>
</div>
<details class="mat-color" id="color-box">
<summary>
<svg class="ico caret"><use href="#i-down"/></svg>
<span class="grow">Farbe</span>
<span class="tiny" id="color-sum">noch keine</span>
</summary>
<div class="mat-color-body">
<div id="color-picked" class="picked"></div>
<div id="color-solids" class="color-grid"></div>
<div id="color-multi" class="color-grid"></div>
<label class="mix check"><input id="color-mix" type="checkbox"> Eigene Mischung</label>
</div>
</details>
<details class="mat-more" id="spool-more">
<summary>
<svg class="ico caret"><use href="#i-down"/></svg>
<span class="grow">Weitere Angaben</span>
</summary>
<div class="mat-more-body">
<div class="grid-2">
<div class="field"><label for="spool-brand">Marke</label><input id="spool-brand"></div>
<div class="field"><label for="spool-type">Typ</label><input id="spool-type" placeholder="PLA"></div>
<div class="field">
<label for="spool-chargen">Grotti-Charge</label>
<input id="spool-chargen" readonly placeholder="wird vergeben">
</div>
<div class="field">
<label for="spool-hersteller">Hersteller-Charge</label>
<input id="spool-hersteller" placeholder="vom Rollenetikett">
</div>
<div class="field"><label for="spool-price">€ pro kg</label><input id="spool-price" type="number" min="0" step="0.01" value="0"></div>
<div class="field"><label for="spool-density">Dichte g/cm³</label><input id="spool-density" type="number" min="0.1" step="0.01" value="1.24"></div>
<div class="field"><label for="spool-vol">Volumen mm³/s</label><input id="spool-vol" type="number" min="0.1" step="0.1" value="12"></div>
</div>
</div>
</details>
<div class="actions">
<button type="submit">Merken</button>
<button type="button" class="ghost" id="spool-shelf" hidden>Ins Regal</button>
@@ -122,8 +129,8 @@
</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 class="kpi-row akte-kpis" id="akte-kpis"></div>
<div class="akte-runs" id="akte-runs"></div>
</div>
<p id="spool-note"></p>
</section>
@@ -429,6 +436,35 @@ function payload() {
};
}
const akteStates = {
completed: "fertig", cancelled: "abgebrochen", error: "Fehler",
in_progress: "druckt", printing: "druckt", paused: "pausiert",
interrupted: "unterbrochen",
};
function akteWhen(iso) {
if (!iso) return "—";
const text = formatDateTime(iso, { year: undefined });
return text === "—" ? "—" : text.replace(/,\s*/, " ");
}
function akteFile(name) {
const base = (name || "").split("/").pop() || "";
if (!base) return "Druck";
if (base.length <= 42) return base;
return base.slice(0, 39) + "…";
}
function akteStatus(run) {
if (run.verdict === "ausschuss") return "Ausschuss";
if (run.verdict === "gut") return "gut";
return akteStates[run.state] || run.state || "";
}
function akteKpi(label, value) {
return '<div class="kpi"><div class="kpi-val">' + escapeHtml(value) + '</div><div class="kpi-label">' + escapeHtml(label) + "</div></div>";
}
async function loadAkte(spoolId) {
const box = document.getElementById("spool-akte");
if (!spoolId) {
@@ -442,29 +478,34 @@ async function loadAkte(spoolId) {
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;
else leer = "~" + Math.round(stats.empty_in_days) + " Tage";
document.getElementById("akte-kpis").innerHTML =
akteKpi("Verdruckt", grams(stats.total_g))
+ akteKpi("Ausschuss", grams(stats.scrap_g))
+ akteKpi("Kosten", euro(stats.total_cost))
+ akteKpi("Füllstand", fillPct + " %")
+ akteKpi("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>';
const runs = data.runs || [];
if (!runs.length) {
list.innerHTML = '<p class="tiny">Noch kein Druck auf dieser Spule.</p>';
} else {
list.innerHTML = runs.slice(0, 12).map((run) => {
const href = "/protokoll/" + run.id;
const charge = run.chargen_nr
? '<a class="tag akte-charge" href="' + href + '">' + escapeHtml(run.chargen_nr) + "</a>"
: '<span class="akte-charge tiny">—</span>';
const file = '<a class="akte-file" href="' + href + '" title="' + escapeHtml((run.filename || "").split("/").pop() || "") + '">'
+ escapeHtml(akteFile(run.filename)) + "</a>";
const gramsTxt = grams(run.spool_weight_g) + (run.spool_is_estimate ? " ≈" : "");
return '<div class="akte-run">'
+ '<span class="akte-when tiny">' + escapeHtml(akteWhen(run.started_at)) + "</span>"
+ charge
+ file
+ '<span class="akte-g">' + escapeHtml(gramsTxt) + "</span>"
+ '<span class="akte-state tiny">' + escapeHtml(akteStatus(run)) + "</span>"
+ "</div>";
}).join("");
}
box.hidden = false;
} catch (error) {
@@ -483,7 +524,9 @@ function fill(spool) {
document.getElementById("spool-printer").value = spool ? spool.printer_host : "";
document.getElementById("spool-slot").value = 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 ? spool.remaining_g : 1000;
document.getElementById("spool-left").value = spool
? Math.round((Number(spool.remaining_g) || 0) * 10) / 10
: 1000;
document.getElementById("spool-density").value = spool ? spool.density : 1.24;
document.getElementById("spool-vol").value = spool ? spool.volumetric : 12;
document.getElementById("spool-material").value = spool && spool.material_id ? String(spool.material_id) : "";
+72 -1
View File
@@ -61,6 +61,12 @@
</aside>
</div>
<div class="desk-tools">
<details class="desk-fold desk-tool" data-fold="verlauf" open>
<summary class="desk-fold-sum"><svg class="ico caret"><use href="#i-down"/></svg><span class="grow">Verlauf</span><span class="tiny" data-role="verlauf-caption"></span></summary>
<div class="desk-fold-body desk-verlauf" data-role="verlauf">
<p class="tiny">Lade Verlauf …</p>
</div>
</details>
<details class="desk-fold desk-tool" data-fold="path">
<summary class="desk-fold-sum"><svg class="ico caret"><use href="#i-down"/></svg><span class="grow">3D-Pfad</span><span class="tiny" data-role="path-caption"></span></summary>
<div class="desk-fold-body">
@@ -100,6 +106,9 @@ let deskCamFails = 0;
let deskViewerFileId = null;
let deskSyncGen = 0;
let deskExcludeSelected = "";
let deskVerlaufAt = 0;
let deskVerlaufHtml = "";
let deskVerlaufHost = "";
const wallPullTried = new Set();
const toolpathCache = new Map();
@@ -909,6 +918,61 @@ function patchDesk(printer) {
// Kamera nur über Timer aktualisieren — sonst doppelter Reload + Layout-Ruckler
syncDeskViewer(printer).catch(() => {});
refreshDeskVerlauf(false);
}
async function refreshDeskVerlauf(force) {
if (!deskHost || deskEl.hidden) return;
const now = Date.now();
if (!force && deskVerlaufHost === deskHost && now - deskVerlaufAt < 20000) return;
const host = deskHost;
deskVerlaufAt = now;
deskVerlaufHost = host;
const box = deskEl.querySelector('[data-role="verlauf"]');
const caption = deskEl.querySelector('[data-role="verlauf-caption"]');
if (!box) return;
try {
const data = await api("/laeufe/aktuell?host=" + encodeURIComponent(host));
if (deskHost !== host) return;
const run = data.run;
if (!run) {
const empty = '<p class="desk-empty"><svg class="ico"><use href="#i-activity"/></svg><span>Noch kein Verlauf</span></p>';
if (deskVerlaufHtml !== empty) {
deskVerlaufHtml = empty;
box.innerHTML = empty;
}
if (caption) caption.textContent = "";
return;
}
const active = ["in_progress", "printing", "paused"].includes(run.state);
const energy = data.energy || {};
const kwh = energy.kwh != null
? (Number(energy.kwh) || 0).toLocaleString("de-DE", { maximumFractionDigits: 2 }) + " kWh"
+ (energy.source === "ed" ? " ED" : "")
: "";
const bits = [
active ? "läuft" : "letzter Druck",
formatDateRange(run.started_at, run.ended_at),
kwh,
].filter(Boolean);
if (caption) caption.textContent = bits.join(" · ");
const head = `<div class="desk-verlauf-head">
<span class="tiny grow">${escapeHtml(jobLabel(run.filename) || "Druck")}${run.chargen_nr ? " · " + escapeHtml(run.chargen_nr) : ""}</span>
<a class="ghost tiny-btn" href="/protokoll/${run.id}">Bericht →</a>
</div>`;
const html = head + tempChartHtml(data.samples || []);
if (deskVerlaufHtml !== html) {
deskVerlaufHtml = html;
box.innerHTML = html;
}
} catch (error) {
if (deskHost !== host) return;
const fail = '<p class="tiny msg">' + escapeHtml(error.message || "Verlauf nicht geladen") + "</p>";
if (deskVerlaufHtml !== fail) {
deskVerlaufHtml = fail;
box.innerHTML = fail;
}
}
}
async function syncDeskViewer(printer) {
@@ -1068,10 +1132,15 @@ function openDesk(host, opts) {
const prev = deskHost;
deskHost = host;
setDeskVisible(true);
if (prev !== host) destroyDeskViewer();
if (prev !== host) {
destroyDeskViewer();
deskVerlaufHtml = "";
deskVerlaufAt = 0;
}
const steer = deskEl.querySelector("[data-role=steer]");
if (!same || !steer.querySelector(".steer")) setHtml(steer, steerPanelHtml(printer));
patchDesk(printer);
refreshDeskVerlauf(true);
startCameraTimer();
if (!same) {
const back = document.getElementById("desk-back");
@@ -1087,6 +1156,8 @@ function openDesk(host, opts) {
function closeDesk(opts) {
if (!deskHost && deskEl.hidden) return;
deskHost = "";
deskVerlaufHtml = "";
deskVerlaufHost = "";
stopCameraTimer();
destroyDeskViewer();
setDeskVisible(false);