Grotti 3.3.0: Projekte mit Ordner, Datei-Löschen/Verschieben, Material-Archiv, UI-Polish.

This commit is contained in:
Dominic Gundel committed 2026-10-18 20:10:32 +02:00
1 parent 4ddffbbeb1
commit 67a89d1794
45 files changed
+2856 -320

No files matched your search

+28 -16
View File
@@ -29,7 +29,8 @@ function sourceLabel(source, estimate) {
return source || "";
}
const ENERGY_PART_COLORS = {
const COST_PART_COLORS = {
filament: "#0d9488",
base: "#94a3b8",
nozzle: "#ea580c",
bed: "#2563eb",
@@ -37,19 +38,20 @@ const ENERGY_PART_COLORS = {
};
function kwhLabel(value) {
if (value == null || value === "") return "—";
return (Number(value) || 0).toLocaleString("de-DE", { maximumFractionDigits: 3 }) + " kWh";
}
function energyPieSvg(parts) {
const items = (parts || []).filter((p) => Number(p.kwh) > 0);
const total = items.reduce((sum, p) => sum + Number(p.kwh || 0), 0);
function costPieSvg(parts) {
const items = (parts || []).filter((p) => Number(p.cost) > 0);
const total = items.reduce((sum, p) => sum + Number(p.cost || 0), 0);
if (!items.length || total <= 0) return "";
const cx = 42;
const cy = 42;
const r = 36;
let angle = -Math.PI / 2;
const slices = items.map((p) => {
const share = Number(p.kwh) / total;
const share = Number(p.cost) / total;
const sweep = share * Math.PI * 2;
const x1 = cx + r * Math.cos(angle);
const y1 = cy + r * Math.sin(angle);
@@ -57,38 +59,48 @@ function energyPieSvg(parts) {
const x2 = cx + r * Math.cos(angle);
const y2 = cy + r * Math.sin(angle);
const large = sweep > Math.PI ? 1 : 0;
const color = ENERGY_PART_COLORS[p.key] || "#64748b";
const color = COST_PART_COLORS[p.key] || "#64748b";
if (share >= 0.999) {
return `<circle cx="${cx}" cy="${cy}" r="${r}" fill="${color}"/>`;
}
return `<path d="M ${cx} ${cy} L ${x1.toFixed(2)} ${y1.toFixed(2)} A ${r} ${r} 0 ${large} 1 ${x2.toFixed(2)} ${y2.toFixed(2)} Z" fill="${color}"/>`;
}).join("");
return `<svg class="energy-pie" viewBox="0 0 84 84" role="img" aria-label="Stromanteile">${slices}</svg>`;
return `<svg class="energy-pie" viewBox="0 0 84 84" role="img" aria-label="Kostenanteile">${slices}</svg>`;
}
function energyBreakdownHtml(energy) {
const parts = energy.parts || [];
function costBreakdownHtml(energy, filamentCost) {
const fil = Number(filamentCost) || 0;
const energyParts = (energy && energy.parts) || [];
const parts = [];
if (fil > 1e-6) {
parts.push({ key: "filament", label: "Filament", kwh: null, cost: fil });
}
energyParts.forEach((p) => parts.push(p));
if (!parts.length) return "";
const src = energy.source === "ed" ? "aus ED" : "pauschal";
const rate = energy.euro_per_kwh != null
const energyEuro = Number((energy && energy.cost) || 0);
const totalEuro = fil + energyEuro;
const src = energy && energy.source === "ed" ? "Strom aus ED" : (energyParts.length ? "Strom pauschal" : "");
const rate = energy && energy.euro_per_kwh != null
? Number(energy.euro_per_kwh).toLocaleString("de-DE", { style: "currency", currency: "EUR", maximumFractionDigits: 3 }) + "/kWh"
: "";
const rows = parts.map((p) => {
const color = ENERGY_PART_COLORS[p.key] || "#64748b";
const color = COST_PART_COLORS[p.key] || "#64748b";
return `<tr>
<td><span class="energy-swatch" style="background:${color}"></span>${escapeHtml(p.label)}</td>
<td class="num">${kwhLabel(p.kwh)}</td>
<td class="num">${euro(p.cost)}</td>
</tr>`;
}).join("");
const sumKwh = energy && energy.kwh != null ? kwhLabel(energy.kwh) : "—";
const hint = [src, rate].filter(Boolean).join(" · ");
return `<div class="energy-break">
<div class="energy-break-chart">${energyPieSvg(parts)}</div>
<div class="energy-break-chart">${costPieSvg(parts)}</div>
<div class="energy-break-table">
<div class="lib-head"><h3>Strom</h3><span class="tiny">${escapeHtml(src)}${rate ? " · " + rate : ""}</span></div>
<div class="lib-head"><h3>Kosten</h3><span class="tiny">${escapeHtml(hint)}</span></div>
<div class="table-wrap"><table>
<thead><tr><th>Anteil</th><th class="num">kWh</th><th class="num">€</th></tr></thead>
<tbody>${rows}
<tr class="energy-total"><td>Summe</td><td class="num">${kwhLabel(energy.kwh)}</td><td class="num">${euro(energy.cost)}</td></tr>
<tr class="energy-total"><td>Summe</td><td class="num">${sumKwh}</td><td class="num">${euro(totalEuro)}</td></tr>
</tbody>
</table></div>
</div>
@@ -151,7 +163,7 @@ function render(data) {
${run.message ? "<p class=\"msg\">" + escapeHtml(run.message) + "</p>" : ""}
${data.project ? "<p class=\"tiny\">Projekt: " + escapeHtml(data.project.name) + "</p>" : ""}
</div>
${energyBreakdownHtml(energy)}
${costBreakdownHtml(energy, run.filament_cost)}
</div>
<div class="card">
<h2>Spulen</h2>
+221 -68
View File
@@ -78,11 +78,10 @@
</div>
<div class="flow-step ok" data-pane="send" id="step-mode">
<h3><span class="step-num">3</span>Wann</h3>
<h3><span class="step-num">3</span>Start</h3>
<div class="segmented" id="mode" role="radiogroup">
<button type="button" data-mode="now" class="on">Jetzt<small>sofort starten</small></button>
<button type="button" data-mode="free">Wenn frei<small>auf diesem Drucker</small></button>
<button type="button" data-mode="next">Nächster freier<small>Grotti wählt</small></button>
<button type="button" data-mode="this" class="on">Dieser Drucker<small>frei → start, sonst Warteschlange</small></button>
<button type="button" data-mode="next">Nächster passender<small>Grotti wählt nach Sorte</small></button>
</div>
</div>
@@ -189,7 +188,7 @@ let ablaufDefaults = {
check: { max_delta_mm: 3.0, timeout_min: 2, x: 125, y: 125, ref_x: 25, ref_y: 125, z_clear: 20 },
};
let ablaufSteps = [];
const sel = { fileId: null, hosts: new Set(), mode: "now", tab: "send" };
const sel = { fileId: null, hosts: new Set(), mode: "this", tab: "send" };
let aceHostsKey = "";
/* ---------- 3D-Pfad ---------- */
@@ -202,6 +201,25 @@ function ensureViewer() {
return pathViewer;
}
let toolpathGen = 0;
function scheduleToolpath(file, progress, deferMs) {
const gen = ++toolpathGen;
const run = () => {
if (gen !== toolpathGen) return;
showToolpath(file, progress);
};
if (deferMs > 0) {
if (window.requestIdleCallback) {
requestIdleCallback(run, { timeout: Math.max(800, deferMs) });
} else {
setTimeout(run, deferMs);
}
return;
}
run();
}
async function showToolpath(file, progress) {
const card = document.getElementById("viewer-card");
const caption = document.getElementById("viewer-caption");
@@ -221,6 +239,7 @@ async function showToolpath(file, progress) {
meta.textContent = "Pfad wird gelesen …";
try {
const data = await api("/dateien/" + file.id + "/pfad?tp=8");
if (String(sel.fileId) !== String(file.id)) return;
viewer.setPath(data);
viewerFileId = file.id;
meta.textContent = (data.count || 0).toLocaleString("de-DE") + " Punkte";
@@ -262,7 +281,9 @@ function saveSendMemory() {
function loadSendMemory() {
try {
const data = JSON.parse(localStorage.getItem(SEND_KEY) || "{}");
if (["now", "free", "next"].includes(data.mode)) sel.mode = data.mode;
let mode = data.mode;
if (mode === "now" || mode === "free") mode = "this";
if (["this", "next"].includes(mode)) sel.mode = mode;
if (["send", "ablauf"].includes(data.tab)) sel.tab = data.tab;
if (Array.isArray(data.hosts)) sel.hosts = new Set(data.hosts);
} catch (error) { /* ignore */ }
@@ -309,10 +330,22 @@ function slotTitle(host, slot) {
return bits.join(" · ");
}
function paintIndexOf(filament, fallback) {
const raw = filament && filament.paint_index;
if (raw === 0 || raw === "0") return 0;
const n = Number(raw);
return Number.isFinite(n) && n >= 0 ? n : fallback;
}
function filamentLabel(filament, index) {
const kind = filament.type || "Filament";
const paint = paintIndexOf(filament, index);
const kind = filament.profile || filament.type || "Filament";
const color = filament.color ? " · " + filament.color : "";
return "Farbe " + (index + 1) + " · " + kind + color;
return "T" + paint + " · " + kind + color;
}
function canStartNow(p) {
return isFree(p) && !p.needs_clear;
}
function spoolsOnHost(host) {
@@ -326,6 +359,19 @@ function spoolWarnText() {
const maps = collectAceMaps();
const hosts = selectedHosts();
const fileTypes = ((file.filaments || []).map((f) => (f.type || "").toLowerCase()).filter(Boolean));
const fileProfiles = ((file.filaments || []).map((f) => (f.profile || "").toLowerCase()).filter(Boolean));
function profileOk(spoolRows) {
if (!fileProfiles.length) return true;
const names = spoolRows.map((s) => (s.name || "").toLowerCase()).filter(Boolean);
if (!names.length) return true;
return fileProfiles.every((want) => names.some((n) => {
if (n === want) return true;
if (!n.startsWith(want) || /[a-z0-9]/i.test(n.charAt(want.length) || "")) return false;
const rest = n.slice(want.length).replace(/^[\s·\-_|/]+/, "");
const first = (rest.split(/\s+/)[0] || "").toLowerCase();
return !["meta", "matte", "matt", "silk", "plus", "hf", "cf"].includes(first);
}));
}
for (const host of hosts) {
const name = (printers.find((p) => p.host === host) || {}).name || host;
const mapping = maps[host] || [];
@@ -334,18 +380,23 @@ function spoolWarnText() {
let left = 0;
let any = false;
const types = [];
const mappedSpools = [];
mapping.forEach((item) => {
const slot = Number(item.ams_index) + 1;
const spool = onHost.find((s) => Number(s.ace_slot) === slot);
if (spool) {
any = true;
left += Number(spool.remaining_g) || 0;
mappedSpools.push(spool);
if (spool.material_type) types.push(spool.material_type.toLowerCase());
}
});
if (!any) return name + ": keine Grotti-Spule auf den gewählten ACE-Plätzen — Abzug entfällt.";
if (left + 0.5 < need) return "ACE auf " + name + ": nur " + grams(left) + ", Datei braucht " + grams(need) + ".";
if (fileTypes.length && types.length && !fileTypes.some((t) => types.includes(t))) {
if (!profileOk(mappedSpools)) {
return name + ": Sorte passt nicht (Datei " + fileProfiles.join("/") + ").";
}
if (!fileProfiles.length && fileTypes.length && types.length && !fileTypes.some((t) => types.includes(t))) {
return name + ": Material passt nicht (Datei " + fileTypes.join("/") + ", ACE " + types.join("/") + ").";
}
continue;
@@ -359,8 +410,11 @@ function spoolWarnText() {
if (left + 0.5 < need) {
return name + ": nur " + grams(left) + ", Datei braucht " + grams(need) + ".";
}
if (!profileOk(external)) {
return name + ": Sorte passt nicht (Datei " + fileProfiles.join("/") + ").";
}
const types = external.map((s) => (s.material_type || "").toLowerCase()).filter(Boolean);
if (fileTypes.length && types.length && !fileTypes.some((t) => types.includes(t))) {
if (!fileProfiles.length && fileTypes.length && types.length && !fileTypes.some((t) => types.includes(t))) {
return name + ": Material passt nicht (Datei " + fileTypes.join("/") + ", Drucker " + types.join("/") + ").";
}
}
@@ -432,16 +486,17 @@ function renderAceMaps(force) {
title.className = "ace-printer-name";
title.textContent = printer.name;
block.appendChild(title);
paints.forEach((filament, paintIndex) => {
paints.forEach((filament, uiIndex) => {
const paint = paintIndexOf(filament, uiIndex);
const row = document.createElement("label");
row.className = "ace-row";
const swatch = document.createElement("span");
swatch.className = "ace-swatch";
if (filament.color) swatch.style.background = filament.color;
const text = document.createElement("span");
text.textContent = filamentLabel(filament, paintIndex);
text.textContent = filamentLabel(filament, uiIndex);
const select = document.createElement("select");
select.dataset.paint = String(paintIndex);
select.dataset.paint = String(paint);
select.innerHTML = '<option value="">Platz wählen</option>';
printer.slots.forEach((slot) => {
const option = document.createElement("option");
@@ -452,16 +507,30 @@ function renderAceMaps(force) {
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
const spoolName = ((spool && spool.name) || "").toLowerCase();
const wantProfile = (filament.profile || "").toLowerCase();
// Vorauswahl: Katalog-Sorte exakt (Meta ≠ Matte), sonst grober Typ
let profileHit = false;
if (wantProfile && spoolName) {
if (spoolName === wantProfile) profileHit = true;
else if (spoolName.startsWith(wantProfile) && !/[a-z0-9]/i.test(spoolName.charAt(wantProfile.length) || "")) {
const rest = spoolName.slice(wantProfile.length).replace(/^[\s·\-_|/]+/, "");
const first = (rest.split(/\s+/)[0] || "").toLowerCase();
if (!["meta", "matte", "matt", "silk", "plus", "hf", "cf"].includes(first)) profileHit = true;
}
}
if (!select.dataset.picked && profileHit) {
option.selected = true;
select.dataset.picked = "1";
} else if (!select.dataset.picked && !wantProfile && filament.type && spoolType
&& filament.type.toLowerCase() === String(spoolType).toLowerCase()) {
option.selected = true;
select.dataset.picked = "1";
}
select.appendChild(option);
});
if (!select.dataset.picked && printer.slots[paintIndex]) {
select.value = String(printer.slots[paintIndex].index);
if (!select.dataset.picked && printer.slots[uiIndex]) {
select.value = String(printer.slots[uiIndex].index);
}
select.addEventListener("change", updateSpoolWarn);
row.appendChild(swatch);
@@ -638,8 +707,9 @@ function fillFolderFilter() {
}
function moveSelectHtml(file) {
if (!hasWatchDir || file.source !== "watch") return "";
const current = folderKey(file);
if (!hasWatchDir) return "";
if (file.source !== "watch" && file.source !== "upload") return "";
const current = file.source === "upload" ? "" : folderKey(file);
const opts = [
`<option value="" selected disabled>Verschieben…</option>`,
`<option value="__root__">Wurzel</option>`,
@@ -701,8 +771,8 @@ function sliceOf(file) {
}
function materialText(file) {
const types = [...new Set(((file && file.filaments) || []).map((f) => f.type).filter(Boolean))];
return types.join("+");
const labels = [...new Set(((file && file.filaments) || []).map((f) => f.profile || f.type).filter(Boolean))];
return labels.join("+");
}
function layerHeightText(file) {
@@ -757,8 +827,7 @@ function thumbHtml(file) {
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>` : "";
const del = `<button type="button" class="icon-btn" data-delete="${file.id}" title="Datei löschen">${icon("trash")}</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>
@@ -882,17 +951,25 @@ function updateSendButton() {
let note = "";
if (!file) problem = "Erst eine Datei wählen.";
else if (file.kind === "3mf") problem = "Die 3MF hat keinen G-code. Mit G-code slicen.";
if (sel.mode === "now") {
if (sel.mode === "this") {
if (!problem && !chosen.length) problem = "Einen oder mehrere Drucker wählen.";
const busy = chosen.filter((p) => !isFree(p));
if (!problem && busy.length) note = namesText(busy) + " " + (busy.length === 1 ? "ist" : "sind") + " nicht frei. „Wenn frei“ reiht stattdessen ein.";
text = chosen.length ? "Jetzt starten auf " + namesText(chosen) : "Jetzt starten";
} else if (sel.mode === "free") {
text = chosen.length ? "Einreihen für " + namesText(chosen) : "Einreihen: nächster freier " + familyOfSelection();
note = chosen.length ? "Startet von selbst, sobald der Drucker bereit und die Platte frei ist." : "Ohne Drucker: der nächste freie passende übernimmt.";
const ready = chosen.filter(canStartNow);
const later = chosen.filter((p) => !canStartNow(p));
if (ready.length && !later.length) {
text = "Jetzt starten auf " + namesText(ready);
note = "";
} else if (!ready.length && later.length) {
text = "Einreihen für " + namesText(later);
note = "Startet, sobald der Drucker bereit und die Platte frei ist.";
} else if (ready.length && later.length) {
text = "Starten / einreihen auf " + namesText(chosen);
note = namesText(ready) + " sofort, " + namesText(later) + " in die Warteschlange.";
} else {
text = "Starten";
}
} else {
text = "Einreihen: nächster freier " + familyOfSelection();
note = "Grotti nimmt den ersten freien Drucker mit passender Maschine.";
text = "Einreihen: nächster passender " + familyOfSelection();
note = "Grotti wählt den nächsten freien Drucker mit passender Sorte (z. B. PLA Meta ≠ Matte).";
}
const mismatch = chosen.filter((p) => familyMismatch(p, file));
if (!problem && mismatch.length && !document.getElementById("send-override").checked) {
@@ -902,7 +979,7 @@ function updateSendButton() {
button.disabled = !!problem || !!sendPoll;
hint.textContent = problem || note;
hint.classList.toggle("bad", !!problem);
targetHint.textContent = sel.mode === "next" && sel.tab === "send" ? "Ziel wählt Grotti." : chosen.length > 1 ? "Je Drucker ein eigener Auftrag." : "";
targetHint.textContent = sel.mode === "next" && sel.tab === "send" ? "Ziel wählt Grotti nach Sorte." : chosen.length > 1 ? "Je Drucker ein eigener Auftrag." : "";
}
function updateAblaufButton() {
@@ -971,7 +1048,7 @@ function selectFile(id) {
}
document.querySelectorAll("#lib-list .lib-item").forEach((n) => n.classList.toggle("on", n.dataset.file === String(id)));
refreshFlow();
showToolpath(file, 0);
scheduleToolpath(file, 0, 0);
if (window.matchMedia("(max-width: 1080px)").matches) {
document.querySelector(".send-panel").scrollIntoView({ behavior: "smooth", block: "start" });
}
@@ -1015,19 +1092,72 @@ async function load() {
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) {
let file = selectedFile();
// Orca/Anycubic: Metadaten oft ohne paint_index → ACE zeigt T0/T1 statt T1/T10
if (file && file.id && filamentsNeedPaintIndex(file.filaments)) {
try {
const fresh = await api("/dateien/" + file.id + "/meta", { method: "POST" });
if (fresh && fresh.id) {
const idx = files.findIndex((row) => String(row.id) === String(fresh.id));
if (idx >= 0) files[idx] = fresh;
else files.unshift(fresh);
file = selectedFile();
renderLibrary();
refreshFlow();
}
} catch (error) { /* manuell: Ordner neu lesen */ }
}
const q = new URLSearchParams(location.search);
const deepLink = !!(q.get("file") || q.get("orca"));
// Deep-Link / Orca-App: ACE/Senden zuerst — 3D-Pfad friert sonst den Tab ein
if (file) scheduleToolpath(file, 0, deepLink ? 900 : 0);
if (deepLink) {
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" });
if (file) lastOrcaFocusId = String(file.id);
if (q.get("orca")) pullOrcaFocus();
}
}
let lastOrcaFocusId = null;
async function pullOrcaFocus() {
try {
const data = await api("/orca-fokus");
if (!data || data.file_id == null) return;
const id = String(data.file_id);
const age = Date.now() / 1000 - Number(data.ts || 0);
if (age > 180) return;
const already = id === String(sel.fileId) && selectedFile();
if (id === lastOrcaFocusId && already) return;
const switched = lastOrcaFocusId != null && id !== lastOrcaFocusId;
lastOrcaFocusId = id;
if (already) return;
let file = files.find((row) => String(row.id) === id);
if (!file) {
files = await api("/dateien");
renderLibrary();
file = files.find((row) => String(row.id) === id);
}
if (file) {
selectFile(file.id);
sel.tab = "send";
renderTabs();
if (switched) toast("Neue Slice-Datei ausgewählt.", "good");
}
} catch (error) { /* nächster Tick */ }
}
function filamentsNeedPaintIndex(filaments) {
const rows = filaments || [];
if (rows.length < 2) return false;
return rows.some((item) => !item || item.paint_index == null);
}
async function refreshPrinters() {
if (document.hidden) return;
try {
@@ -1105,32 +1235,36 @@ async function watchJobs(ids) {
await tick();
}
async function postSenden(hosts, whenFree) {
return api("/senden", {
method: "POST",
json: {
file_id: Number(sel.fileId),
hosts,
confirm: true,
override_machine: document.getElementById("send-override").checked,
bed_leveling: document.getElementById("send-bed-level").checked,
ace_maps: collectAceMaps(),
when_free: whenFree,
},
});
}
async function doSend() {
const button = document.getElementById("send-go");
const whenFree = sel.mode !== "now";
button.disabled = true;
document.getElementById("send-note").textContent = "";
try {
const payload = await api("/senden", {
method: "POST",
json: {
file_id: Number(sel.fileId),
hosts: selectedHosts(),
confirm: true,
override_machine: document.getElementById("send-override").checked,
bed_leveling: document.getElementById("send-bed-level").checked,
ace_maps: collectAceMaps(),
when_free: whenFree,
},
});
const projectId = document.getElementById("send-project")?.value;
if (projectId && sel.fileId) {
try {
await api("/dateien/" + sel.fileId, { method: "PATCH", json: { project_id: Number(projectId) } });
} catch (error) { /* Projektbindung optional */ }
}
const jobs = payload.jobs || [];
if (whenFree || payload.mode === "when_free") {
if (sel.mode === "next") {
const payload = await postSenden([], true);
const jobs = payload.jobs || [];
document.getElementById("send-progress").hidden = true;
toast((jobs.length > 1 ? jobs.length + " Aufträge" : "Auftrag") + " eingereiht. Steht unter „Wartet“.", "good");
updateSendButton();
@@ -1138,9 +1272,35 @@ async function doSend() {
refreshPrinters();
return;
}
showJobsProgress(jobs);
const chosen = selectedPrinters();
const ready = chosen.filter(canStartNow).map((p) => p.host);
const later = chosen.filter((p) => !canStartNow(p)).map((p) => p.host);
let queued = 0;
let nowJobs = [];
if (later.length) {
const payload = await postSenden(later, true);
queued += (payload.jobs || []).length;
}
if (ready.length) {
const payload = await postSenden(ready, false);
nowJobs = payload.jobs || [];
}
if (queued && !nowJobs.length) {
document.getElementById("send-progress").hidden = true;
toast((queued > 1 ? queued + " Aufträge" : "Auftrag") + " eingereiht. Steht unter „Wartet“.", "good");
updateSendButton();
if (typeof refreshPrintQueue === "function") refreshPrintQueue();
refreshPrinters();
return;
}
if (queued && nowJobs.length) {
toast(queued + " eingereiht, " + nowJobs.length + " startet jetzt.", "good");
if (typeof refreshPrintQueue === "function") refreshPrintQueue();
}
showJobsProgress(nowJobs);
if (typeof refreshSendQueue === "function") refreshSendQueue();
await watchJobs(jobs.map((job) => job.id));
await watchJobs(nowJobs.map((job) => job.id));
} catch (error) {
updateSendButton();
toast(error.message || String(error), "bad");
@@ -1445,7 +1605,7 @@ document.getElementById("targets").addEventListener("click", (event) => {
toast("Abläufe gehen nur auf Kobra 3.", "bad");
return;
}
if (sel.tab === "send" && sel.mode === "next") sel.mode = "free";
if (sel.tab === "send" && sel.mode === "next") sel.mode = "this";
if (sel.hosts.has(host)) sel.hosts.delete(host);
else sel.hosts.add(host);
saveSendMemory();
@@ -1479,19 +1639,11 @@ document.getElementById("send-bed-level").addEventListener("change", updateOptio
document.getElementById("send-override").addEventListener("change", () => { refreshFlow(); });
document.getElementById("send-project")?.addEventListener("change", updateOptionsSummary);
document.getElementById("send-go").addEventListener("click", (event) => {
const button = event.currentTarget;
const warn = spoolWarnText();
if (sel.mode === "now") {
armButton(button, warn ? "Warnung — trotzdem starten?" : "Wirklich starten? Nochmal klicken", doSend);
} else if (warn) {
armButton(button, "Warnung — trotzdem einreihen?", doSend);
} else {
doSend();
}
document.getElementById("send-go").addEventListener("click", () => {
doSend();
});
document.getElementById("ablauf-go").addEventListener("click", (event) => {
armButton(event.currentTarget, "Ablauf wirklich einreihen? Nochmal klicken", doAblauf);
document.getElementById("ablauf-go").addEventListener("click", () => {
doAblauf();
});
document.getElementById("ablauf-add-print").addEventListener("click", () => addStep(defaultPrintStep()));
@@ -1591,5 +1743,6 @@ loadSendMemory();
loadAblaufMemory();
load().catch((error) => toast(error.message || String(error), "bad"));
setInterval(refreshPrinters, 5000);
setInterval(pullOrcaFocus, 2000);
</script>
{% endblock %}
+4 -4
View File
@@ -13,15 +13,14 @@
<div class="flow-diagram">
<span class="step done">Datei</span><span class="step-sep"></span>
<span class="step done">Drucker</span><span class="step-sep"></span>
<span class="step now">Jetzt · Wenn frei · Nächster freier</span><span class="step-sep"></span>
<span class="step now">Dieser Drucker · Nächster passender</span><span class="step-sep"></span>
<span class="step">Druck</span><span class="step-sep"></span>
<span class="step">Platte räumen</span>
</div>
<ol>
<li><strong>Datei wählen.</strong> Unter <a href="/dateien">Dateien</a> links in der Bibliothek. Der Slice-Ordner wird beobachtet, Hochladen geht per Knopf oder Ziehen.</li>
<li><strong>Drucker wählen.</strong> Die Kacheln zeigen live, was jeder Drucker gerade tut. Passt die Maschine nicht zur Datei, ist die Kachel blass.</li>
<li><strong>Wann.</strong> <em>Jetzt</em> startet sofort. <em>Wenn frei</em> reiht für diesen Drucker ein. <em>Nächster freier</em> lässt Grotti den ersten freien passenden Drucker nehmen.</li>
<li><strong>Starten.</strong> „Jetzt“ will zwei Klicks. Der zweite ist die Bestätigung.</li>
<li><strong>Start.</strong> <em>Dieser Drucker</em>: frei → sofort, sonst Warteschlange. <em>Nächster passender</em>: Grotti wählt nach Maschine und Sorte (PLA Meta ≠ Matte).</li>
<li><strong>Während des Drucks</strong> zeigt die <a href="/">Wand</a> Fortschritt, Fertig-Uhrzeit und Temperaturen. Ein Klick auf die Karte öffnet das Cockpit mit Kamera (falls vorhanden), Steuerung, Bettnetz und 3D-Pfad. Die Seitenleiste zeigt immer, was läuft und was wartet.</li>
<li><strong>Danach</strong> ist die Platte belegt. Ein wartender Auftrag startet erst, wenn auf der Karte <em>Platte geräumt</em> gedrückt wurde, oder ein Ablauf sie selbst freischiebt.</li>
</ol>
@@ -49,7 +48,8 @@
<dl class="terms">
<dt>Auftrag</dt><dd>Ein Eintrag in der Warteschlange: eine Datei für einen Drucker oder für den nächsten freien.</dd>
<dt>Ablauf</dt><dd>Auftrag mit mehreren Schritten und Wiederholungen. Nur Kobra 3.</dd>
<dt>Wenn frei</dt><dd>Startet, sobald der Drucker bereit und die Platte geräumt ist.</dd>
<dt>Dieser Drucker</dt><dd>Frei und Platte klar → sofort; sonst Warteschlange für genau diesen Drucker.</dd>
<dt>Nächster passender</dt><dd>Grotti claimt den nächsten freien Drucker mit passender Katalog-Sorte und Restfilament.</dd>
<dt>Platte belegt</dt><dd>Nach jedem Druck gesetzt. Blockiert den nächsten Auftrag auf diesem Drucker.</dd>
<dt>Alert</dt><dd>Wichtiges Ereignis (Fertig, Fehler, Abbruch, offline). Steht in der Seitenleiste und geht auf Wunsch an Telegram.</dd>
<dt>Ereignis</dt><dd>Jeder Eintrag unter Aktivität. Ablauf-Schritte und Starts sind Ereignisse, aber keine Alerts.</dd>
+3 -3
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=66">
<link rel="stylesheet" href="{{ url_for('static', filename='grotti.css') }}?v=68">
<link rel="manifest" href="{{ url_for('static', filename='manifest.webmanifest') }}">
<meta name="theme-color" content="#0a0a0c" id="theme-color-meta">
<script>
@@ -81,7 +81,6 @@
<span class="nav-label">Live</span>
<a href="/" class="{{ 'on' if page == 'wand' else '' }}" title="Drucker"><svg class="ico"><use href="#i-printer"/></svg><span>Drucker</span></a>
<a href="/dateien" class="{{ 'on' if page == 'dateien' else '' }} nav-desk" title="Dateien"><svg class="ico"><use href="#i-file"/></svg><span>Dateien</span></a>
<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="/warteschlange" class="{{ 'on' if page == 'warteschlange' else '' }}" title="Warteschlange"><svg class="ico"><use href="#i-queue"/></svg><span>Warteschlange</span></a>
<a href="/auftraege" class="{{ 'on' if page == 'auftraege' else '' }} nav-desk" title="Aufträge"><svg class="ico"><use href="#i-folder"/></svg><span>Aufträge</span></a>
<span class="nav-label nav-desk">Rückschau</span>
@@ -89,6 +88,7 @@
<a href="/statistik" class="{{ 'on' if page == 'statistik' else '' }} nav-desk" title="Statistik"><svg class="ico"><use href="#i-chart"/></svg><span>Statistik</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>
<span class="nav-label nav-desk">Verwalten</span>
<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>
<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="/wartung" class="{{ 'on' if page == 'wartung' else '' }} nav-desk" title="Wartung"><svg class="ico"><use href="#i-wrench"/></svg><span>Wartung</span></a>
</nav>
@@ -138,8 +138,8 @@
</nav>
<dialog id="mobile-more-sheet" class="mobile-more-sheet">
<nav aria-label="Weitere Seiten">
<a href="/kameras">Kameras</a>
<a href="/auftraege">Aufträge</a>
<a href="/projekte">Projekte</a>
<a href="/protokoll">Protokoll</a>
<a href="/statistik">Statistik</a>
<a href="/aktivitaet">Aktivität</a>
+118 -5
View File
@@ -40,6 +40,11 @@
<button type="button" class="fchip" data-filter="loaded">Am Drucker</button>
<button type="button" class="fchip" data-filter="shelf">Regal</button>
<button type="button" class="fchip" data-filter="low">Wenig Rest</button>
<button type="button" class="fchip" data-filter="archive">Archiv</button>
</div>
<div class="mat-extra-filters">
<select id="spool-filter-type" title="Sorte"><option value="">Alle Sorten</option></select>
<select id="spool-filter-color" title="Farbe"><option value="">Alle Farben</option></select>
</div>
<div class="search-wrap">
<svg class="ico"><use href="#i-search"/></svg>
@@ -141,6 +146,9 @@
<div class="actions">
<button type="submit" class="btn-primary">Speichern</button>
<button type="button" class="ghost" id="spool-shelf" hidden>Ins Regal</button>
<button type="button" class="ghost" id="spool-empty" hidden title="Rest auf 0, Regal, archivieren">Leer</button>
<button type="button" class="ghost" id="spool-archive" hidden>Archivieren</button>
<button type="button" class="ghost" id="spool-restore" hidden>Wiederherstellen</button>
<button type="button" class="ghost" id="spool-label" hidden>Etikett</button>
<button type="button" class="warn" id="spool-delete" hidden><svg class="ico"><use href="#i-trash"/></svg>Löschen</button>
</div>
@@ -237,6 +245,8 @@ let picked = [];
let colorLabel = "";
let autoName = "";
let spoolFilter = "all";
let spoolTypeFilter = "";
let spoolColorFilter = "";
let spoolSearch = "";
let sortenSearch = "";
let view = "spools";
@@ -556,6 +566,9 @@ function fill(spool) {
document.getElementById("spool-delete").hidden = !spool;
document.getElementById("spool-label").hidden = !spool;
document.getElementById("spool-shelf").hidden = !spool || !(spool.printer_host);
document.getElementById("spool-empty").hidden = !spool || spool.archived;
document.getElementById("spool-archive").hidden = !spool || spool.archived;
document.getElementById("spool-restore").hidden = !spool || !spool.archived;
document.getElementById("color-box").open = !spool || !picked.length;
showHint();
renderPalette();
@@ -583,12 +596,30 @@ function fillSorten(material) {
renderSorten();
}
function isSpoolArchived(spool) {
return !!(spool && (spool.archived || spool.archived_at));
}
function isSpoolEmpty(spool) {
return (Number(spool.remaining_g) || 0) <= 0.5;
}
function shownSpools() {
const q = spoolSearch.trim().toLowerCase();
const type = spoolTypeFilter.trim().toLowerCase();
const color = spoolColorFilter.trim().toLowerCase();
return spools.filter((spool) => {
const archived = isSpoolArchived(spool) || isSpoolEmpty(spool);
if (spoolFilter === "archive") {
if (!archived) return false;
} else if (archived) {
return false;
}
if (spoolFilter === "loaded" && !spool.printer_host) return false;
if (spoolFilter === "shelf" && spool.printer_host) return false;
if (spoolFilter === "low" && (Number(spool.remaining_g) || 0) >= 100) return false;
if (type && (spool.material_type || "").toLowerCase() !== type) return false;
if (color && (spool.color || "").toLowerCase() !== color) return false;
if (!q) return true;
const hay = [
spool.name, spool.chargen_nr, spool.code, spool.material_type, spool.color,
@@ -598,6 +629,28 @@ function shownSpools() {
});
}
function fillSpoolMetaFilters() {
const typeSel = document.getElementById("spool-filter-type");
const colorSel = document.getElementById("spool-filter-color");
if (!typeSel || !colorSel) return;
const types = [...new Set(spools.map((s) => (s.material_type || "").trim()).filter(Boolean))]
.sort((a, b) => a.localeCompare(b, "de"));
const colors = [...new Set(spools.map((s) => (s.color || "").trim()).filter(Boolean))]
.sort((a, b) => a.localeCompare(b, "de"));
const curType = spoolTypeFilter;
const curColor = spoolColorFilter;
typeSel.innerHTML = '<option value="">Alle Sorten</option>' + types.map((t) =>
`<option value="${escapeHtml(t)}">${escapeHtml(t)}</option>`
).join("");
colorSel.innerHTML = '<option value="">Alle Farben</option>' + colors.map((c) =>
`<option value="${escapeHtml(c)}">${escapeHtml(c)}</option>`
).join("");
typeSel.value = types.includes(curType) ? curType : "";
colorSel.value = colors.includes(curColor) ? curColor : "";
spoolTypeFilter = typeSel.value;
spoolColorFilter = colorSel.value;
}
function shownSorten() {
const q = sortenSearch.trim().toLowerCase();
if (!q) return materials;
@@ -635,11 +688,15 @@ function renderSpools() {
btn.classList.toggle("on", (btn.dataset.filter || "all") === spoolFilter);
});
fillSelects();
fillSpoolMetaFilters();
const body = document.getElementById("spool-rows");
body.innerHTML = "";
const shown = shownSpools();
document.getElementById("spool-count").textContent = String(shown.length);
const activeCount = spools.filter((s) => !isSpoolArchived(s) && !isSpoolEmpty(s)).length;
document.getElementById("spool-count").textContent = String(
spoolFilter === "archive" ? shown.length : activeCount
);
const note = document.getElementById("spool-list-note");
if (!spools.length) {
note.textContent = "";
@@ -651,9 +708,11 @@ function renderSpools() {
body.innerHTML = '<tr><td class="empty" colspan="6"><div class="empty-state">' + icon("search") + '<p>Keine Spule passt.</p></div></td></tr>';
return;
}
note.textContent = shown.length === spools.length
? (shown.length === 1 ? "1 Spule" : shown.length + " Spulen")
: shown.length + " von " + spools.length;
note.textContent = spoolFilter === "archive"
? (shown.length === 1 ? "1 archivierte Spule" : shown.length + " archivierte Spulen")
: shown.length === activeCount
? (shown.length === 1 ? "1 Spule" : shown.length + " Spulen")
: shown.length + " von " + activeCount;
const ordered = [...shown].sort((a, b) => {
const ah = a.printer_host || "zzz";
@@ -671,7 +730,7 @@ function renderSpools() {
body.appendChild(head);
}
const tr = document.createElement("tr");
tr.className = "pick";
tr.className = "pick" + (isSpoolArchived(spool) || isSpoolEmpty(spool) ? " is-archived" : "");
if (spoolPanelOpen && String(spool.id) === document.getElementById("spool-id").value) tr.classList.add("on");
tr.innerHTML = "<td></td><td></td><td></td><td></td><td></td><td class=\"num\"></td>";
tr.children[0].textContent = spool.name;
@@ -876,6 +935,52 @@ document.getElementById("spool-shelf").addEventListener("click", async () => {
showNote("spool-note", error);
}
});
document.getElementById("spool-empty").addEventListener("click", () => {
const id = document.getElementById("spool-id").value;
if (!id) return;
armButton(document.getElementById("spool-empty"), "Als leer markieren?", async () => {
try {
const saved = await api("/spulen/" + id + "/leer", { method: "POST", json: {} });
toast("Spule als leer archiviert.");
await load();
fill(saved);
} catch (error) {
showNote("spool-note", error);
}
});
});
document.getElementById("spool-archive").addEventListener("click", async () => {
const id = document.getElementById("spool-id").value;
if (!id) return;
try {
const saved = await api("/spulen/" + id, {
method: "PATCH",
json: { archived: true, printer_host: "", ace_slot: null },
});
toast("Spule archiviert.");
await load();
fill(saved);
} catch (error) {
showNote("spool-note", error);
}
});
document.getElementById("spool-restore").addEventListener("click", async () => {
const id = document.getElementById("spool-id").value;
if (!id) return;
try {
const left = Number(document.getElementById("spool-left").value) || 0;
const saved = await api("/spulen/" + id, {
method: "PATCH",
json: { archived: false, remaining_g: left > 0.5 ? left : 1000 },
});
toast("Spule wiederhergestellt.");
spoolFilter = "all";
await load();
fill(saved);
} catch (error) {
showNote("spool-note", error);
}
});
document.getElementById("spool-label").addEventListener("click", () => {
const id = document.getElementById("spool-id").value;
if (id) window.open("/etikett/" + id, "_blank", "noopener");
@@ -890,6 +995,14 @@ document.getElementById("spool-delete").addEventListener("click", () => {
closeSpoolPanel();
});
});
document.getElementById("spool-filter-type")?.addEventListener("change", (event) => {
spoolTypeFilter = event.target.value || "";
renderSpools();
});
document.getElementById("spool-filter-color")?.addEventListener("change", (event) => {
spoolColorFilter = event.target.value || "";
renderSpools();
});
document.getElementById("sorten-new").addEventListener("click", () => {
fillSorten(null);
+311
View File
@@ -0,0 +1,311 @@
{% extends "layout.html" %}
{% block body %}
<div class="page-head">
<div>
<h1>Projekte</h1>
<p class="tiny">Arbeitsmappen mit Ordner unter dem Slice-Pfad — G-code und Konstruktionsdaten.</p>
</div>
<div class="actions">
<button type="button" class="btn-primary" id="proj-new"><svg class="ico"><use href="#i-plus"/></svg>Neues Projekt</button>
</div>
</div>
<div class="split">
<section class="card">
<div class="card-head lib-head">
<svg class="ico head-ico"><use href="#i-folder"/></svg>
<h2>Liste</h2>
<span class="count-pill" id="proj-count">0</span>
</div>
<div class="table-wrap">
<table>
<thead><tr><th>Name</th><th>Status</th><th>Ordner</th><th class="num">Dateien</th></tr></thead>
<tbody id="proj-rows"></tbody>
</table>
</div>
</section>
<section class="card" id="proj-panel">
<h2 id="proj-title">Neues Projekt</h2>
<form id="proj-form" novalidate>
<input id="proj-id" type="hidden">
<div class="field"><label for="proj-name">Name</label><input id="proj-name" required autocomplete="off"></div>
<div class="grid-2">
<div class="field">
<label for="proj-status">Status</label>
<select id="proj-status">
<option value="idee">Idee</option>
<option value="bereit">bereit</option>
<option value="druckt">druckt</option>
<option value="fertig">fertig</option>
</select>
</div>
<div class="field">
<label for="proj-folder">Ordner <span class="tip" title="Relativ unter dem Slice-Ordner, z. B. Projekte/2609_002 — kein D:\…"><svg class="ico"><use href="#i-info"/></svg></span></label>
<input id="proj-folder" placeholder="Projekte/…" autocomplete="off">
<p class="field-help">Leer = automatisch <code>Projekte/&lt;Name&gt;</code>. Nur Relativpfad, kein Laufwerk.</p>
</div>
</div>
<div class="field"><label for="proj-notes">Notiz</label><textarea id="proj-notes" rows="3"></textarea></div>
<div class="actions">
<button type="submit" class="btn-primary" id="proj-save">Speichern</button>
<a class="ghost" id="proj-files-link" href="/dateien" hidden>Dateien</a>
<button type="button" class="warn" id="proj-delete" hidden><svg class="ico"><use href="#i-trash"/></svg>Löschen</button>
</div>
</form>
<p id="proj-note" class="tiny"></p>
<div id="proj-detail" hidden>
<h3 class="section-gap">Druckdateien</h3>
<div id="proj-gcodes" class="proj-file-list"></div>
<h3 class="section-gap">Weitere Dateien</h3>
<p class="tiny">STEP, STL, PDF, SolidWorks usw. im Projektordner — nicht in der Druck-Bibliothek.</p>
<div class="actions" style="margin-bottom:0.5rem">
<label class="button ghost" for="proj-asset-file"><svg class="ico"><use href="#i-upload"/></svg>Hochladen</label>
<input id="proj-asset-file" type="file" multiple hidden>
</div>
<div id="proj-assets" class="proj-file-list"></div>
</div>
</section>
</div>
{% endblock %}
{% block script %}
<script>
const STATUS_LABEL = { idee: "Idee", bereit: "bereit", druckt: "druckt", fertig: "fertig" };
let projects = [];
let selectedId = null;
function fillBlank() {
selectedId = null;
document.getElementById("proj-id").value = "";
document.getElementById("proj-name").value = "";
document.getElementById("proj-status").value = "idee";
document.getElementById("proj-folder").value = "";
document.getElementById("proj-notes").value = "";
document.getElementById("proj-title").textContent = "Neues Projekt";
document.getElementById("proj-delete").hidden = true;
document.getElementById("proj-files-link").hidden = true;
document.getElementById("proj-detail").hidden = true;
document.getElementById("proj-note").textContent = "";
renderList();
}
function fill(project) {
selectedId = project.id;
document.getElementById("proj-id").value = project.id;
document.getElementById("proj-name").value = project.name || "";
document.getElementById("proj-status").value = project.status || "idee";
document.getElementById("proj-folder").value = project.folder || "";
document.getElementById("proj-notes").value = project.notes || "";
document.getElementById("proj-title").textContent = project.name || "Projekt";
document.getElementById("proj-delete").hidden = false;
const link = document.getElementById("proj-files-link");
link.hidden = false;
link.href = "/dateien";
document.getElementById("proj-detail").hidden = false;
document.getElementById("proj-note").textContent = "";
renderList();
renderDetail(project);
}
function renderList() {
const body = document.getElementById("proj-rows");
document.getElementById("proj-count").textContent = String(projects.length);
if (!projects.length) {
body.innerHTML = '<tr><td class="empty" colspan="4"><div class="empty-state">' + icon("folder") + "<p>Noch kein Projekt.</p></div></td></tr>";
return;
}
body.innerHTML = "";
projects.forEach((project) => {
const tr = document.createElement("tr");
tr.className = "pick" + (String(project.id) === String(selectedId) ? " on" : "");
tr.innerHTML = "<td></td><td></td><td></td><td class=\"num\"></td>";
tr.children[0].textContent = project.name;
tr.children[1].textContent = STATUS_LABEL[project.status] || project.status || "";
tr.children[2].textContent = project.folder || "—";
tr.children[2].title = project.folder || "";
tr.children[3].textContent = String(project.file_count ?? (project.files || []).length);
tr.addEventListener("click", () => openProject(project.id));
body.appendChild(tr);
});
}
function renderDetail(project) {
const gcodes = document.getElementById("proj-gcodes");
const files = project.files || [];
if (!files.length) {
gcodes.innerHTML = '<p class="tiny">Keine G-code/3MF-Dateien verknüpft. Dateien in den Projektordner legen oder unter Dateien verschieben.</p>';
} else {
gcodes.innerHTML = files.map((file) => {
const mats = (file.filaments || []).map((f) => f.type || f.material_type || f.name || "").filter(Boolean);
const mat = [...new Set(mats)].join(" / ");
const meta = [mat, file.weight_g ? Math.round(file.weight_g) + " g" : ""].filter(Boolean).join(" · ");
return `<a class="proj-file" href="/dateien">${escapeHtml(file.name)}<span class="tiny">${escapeHtml(meta)}</span></a>`;
}).join("");
}
loadAssets(project.id);
}
async function loadAssets(projectId) {
const box = document.getElementById("proj-assets");
try {
const rows = await api("/projekte/" + projectId + "/assets");
if (!rows.length) {
box.innerHTML = '<p class="tiny">Noch keine weiteren Dateien.</p>';
return;
}
box.innerHTML = rows.map((item) => {
const href = withToken("/api/v1/projekte/" + projectId + "/assets/" + encodeURIComponent(item.rel).replace(/%2F/g, "/"));
return `<div class="proj-asset">
<a href="${escapeHtml(href)}" download>${escapeHtml(item.rel)}</a>
<span class="tiny">${escapeHtml(sizeText(item.size_bytes))}</span>
<button type="button" class="icon-btn" data-asset-del="${escapeHtml(item.rel)}" title="Löschen">${icon("trash")}</button>
</div>`;
}).join("");
} catch (error) {
box.innerHTML = '<p class="tiny">' + escapeHtml(error.message || String(error)) + "</p>";
}
}
function sizeText(bytes) {
const n = Number(bytes) || 0;
if (n < 1024) return n + " B";
if (n < 1024 * 1024) return (n / 1024).toFixed(1).replace(".", ",") + " KB";
return (n / (1024 * 1024)).toFixed(1).replace(".", ",") + " MB";
}
async function openProject(id) {
try {
const row = await api("/projekte/" + id);
fill(row);
} catch (error) {
showNote("proj-note", error);
}
}
async function load() {
projects = await api("/projekte");
renderList();
if (selectedId) {
const still = projects.find((p) => String(p.id) === String(selectedId));
if (still) await openProject(selectedId);
else fillBlank();
}
}
let folderTouched = false;
document.getElementById("proj-new").addEventListener("click", () => {
folderTouched = false;
fillBlank();
document.getElementById("proj-name").focus();
});
document.getElementById("proj-folder").addEventListener("input", () => {
folderTouched = true;
});
document.getElementById("proj-name").addEventListener("input", () => {
if (folderTouched || document.getElementById("proj-id").value) return;
const name = document.getElementById("proj-name").value.trim();
const folderEl = document.getElementById("proj-folder");
if (!name) {
folderEl.value = "";
return;
}
const slug = name
.replace(/[^\w\- äöüÄÖÜß.]+/gi, "-")
.replace(/\s+/g, "-")
.replace(/-{2,}/g, "-")
.replace(/^[-.]+|[-.]+$/g, "")
.slice(0, 60) || "projekt";
folderEl.value = "Projekte/" + slug;
});
document.getElementById("proj-form").addEventListener("submit", async (event) => {
event.preventDefault();
const id = document.getElementById("proj-id").value;
const name = document.getElementById("proj-name").value.trim();
if (!name) {
toast("Name fehlt.", "bad");
document.getElementById("proj-name").focus();
return;
}
const folderRaw = document.getElementById("proj-folder").value.trim();
if (/^[a-zA-Z]:[\\/]/.test(folderRaw) || folderRaw.startsWith("\\\\") || folderRaw.includes(":")) {
toast("Ordner nur relativ unter dem Slice-Pfad — z. B. Projekte/2609_002, kein D:\\…", "bad");
document.getElementById("proj-folder").focus();
return;
}
const payload = {
name,
status: document.getElementById("proj-status").value,
notes: document.getElementById("proj-notes").value,
folder: folderRaw,
};
const saveBtn = document.getElementById("proj-save");
saveBtn.disabled = true;
try {
const saved = id
? await api("/projekte/" + id, { method: "PATCH", json: payload })
: await api("/projekte", { method: "POST", json: payload });
toast(id ? "Projekt gespeichert." : "Projekt angelegt.", "good");
folderTouched = true;
await load();
await openProject(saved.id);
} catch (error) {
showNote("proj-note", error);
} finally {
saveBtn.disabled = false;
}
});
document.getElementById("proj-delete").addEventListener("click", () => {
const id = document.getElementById("proj-id").value;
if (!id) return;
armButton(document.getElementById("proj-delete"), "Ordner inkl. Inhalt?", async () => {
try {
await api("/projekte/" + id + "?force=1", { method: "DELETE" });
toast("Projekt gelöscht.");
fillBlank();
await load();
} catch (error) {
showNote("proj-note", error);
}
});
});
document.getElementById("proj-asset-file").addEventListener("change", async (event) => {
const id = document.getElementById("proj-id").value;
const files = [...(event.target.files || [])];
event.target.value = "";
if (!id || !files.length) return;
try {
for (const file of files) {
const body = new FormData();
body.append("file", file);
await api("/projekte/" + id + "/assets", { method: "POST", body });
}
toast(files.length === 1 ? "Datei hochgeladen." : files.length + " Dateien hochgeladen.");
await loadAssets(id);
} catch (error) {
showNote("proj-note", error);
}
});
document.getElementById("proj-assets").addEventListener("click", (event) => {
const btn = event.target.closest("[data-asset-del]");
if (!btn) return;
const id = document.getElementById("proj-id").value;
const rel = btn.dataset.assetDel;
if (!id || !rel) return;
armButton(btn, "löschen?", async () => {
await api("/projekte/" + id + "/assets/" + encodeURIComponent(rel).replace(/%2F/g, "/"), { method: "DELETE" });
toast("Datei gelöscht.");
await loadAssets(id);
});
});
load().catch((error) => showNote("proj-note", error));
</script>
{% endblock %}
+19 -3
View File
@@ -23,7 +23,7 @@
</div>
<p id="run-note"></p>
<div class="card table-wrap">
<table>
<table class="proto-table">
<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>
@@ -65,6 +65,16 @@ function filteredRuns() {
});
}
function shortRunName(filename) {
let name = (filename || "").split("/").pop() || "";
if (!name) return "";
name = name.replace(/^\d{8}-\d{6}-/, "");
name = name.replace(/^\d{4}-\d{4}-/, "");
name = name.replace(/\.gcode$/i, "");
name = name.replace(/_\d+(?:\.\d+)?_(?:\d+h)?(?:\d+m)?(?:\d+s)?$/i, "");
return name || (filename || "").split("/").pop() || "";
}
function render() {
fillPrinterFilter();
const body = document.getElementById("run-rows");
@@ -83,12 +93,15 @@ function render() {
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[0].className = "proto-printer";
const fileCell = tr.children[1];
fileCell.className = "proto-file";
const fullName = (run.filename || "").split("/").pop() || ("#" + run.id);
const link = document.createElement("a");
link.href = "/protokoll/" + run.id;
link.className = "cell-file";
link.title = (run.filename || "").split("/").pop() || ("#" + run.id);
link.textContent = (run.filename || "").split("/").pop() || ("#" + run.id);
link.title = fullName;
link.textContent = shortRunName(run.filename) || fullName;
fileCell.appendChild(link);
if (run.chargen_nr) {
const code = document.createElement("div");
@@ -167,7 +180,10 @@ function render() {
} else {
const spool = spools.find((item) => item.id === run.spool_id);
tr.children[7].textContent = spool ? spool.name : "abgezogen";
tr.children[7].title = spool ? spool.name : "";
tr.children[7].className = "proto-spool";
}
if (!tr.children[7].className) tr.children[7].className = "proto-spool";
if (run.file_id) {
const again = document.createElement("button");
again.type = "button";
+37 -32
View File
@@ -9,7 +9,7 @@
<a class="ghost" id="csv-link" href="/api/v1/statistik.csv">CSV</a>
</div>
</div>
<div class="toolbar page-tools">
<div class="toolbar page-tools stat-tools">
<div class="segmented period-presets fchips" id="period" role="group" aria-label="Zeitraum">
<button type="button" data-period="all" class="fchip on">Alles</button>
<button type="button" data-period="day" class="fchip">Tag</button>
@@ -19,20 +19,24 @@
<button type="button" data-period="last_week" class="fchip">Letzte Woche</button>
<button type="button" data-period="last_month" class="fchip">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">
<div class="tools-end"><button type="button" id="reload" class="ghost"><svg class="ico"><use href="#i-refresh"/></svg>Aktualisieren</button></div>
<div class="stat-filters">
<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="8" autocomplete="off">
<input type="text" id="brand" placeholder="Marke" size="8" autocomplete="off">
<input type="text" id="color" placeholder="Farbe" size="8" autocomplete="off">
</div>
</div>
<p id="stat-note"></p>
<div id="stat" class="statistik"></div>
{% endblock %}
{% block script %}
<script>
function kpi(label, value, hint, ico) {
return `<div class="kpi"><div class="kpi-label">${ico ? icon(ico) : ""}${escapeHtml(label)}</div><div class="kpi-value">${value}</div>${hint ? '<div class="kpi-sub">' + escapeHtml(hint) + "</div>" : ""}</div>`;
function kpi(label, value, hint, ico, extraClass) {
const empty = !value || value === "—" || value === "0 %";
const cls = ["kpi", extraClass || "", empty ? "kpi-empty" : ""].filter(Boolean).join(" ");
return `<div class="${cls}"><div class="kpi-label">${ico ? icon(ico) : ""}${escapeHtml(label)}</div><div class="kpi-value">${value}</div>${hint ? '<div class="kpi-sub">' + escapeHtml(hint) + "</div>" : ""}</div>`;
}
function isoDate(date) {
@@ -165,10 +169,11 @@ function render(data) {
<tr>
<td>${escapeHtml(m.material_type || "—")}</td>
<td>${escapeHtml(m.brand || "")}</td>
<td>${escapeHtml(m.color || "")}</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>';
</tr>`).join("") || '<tr><td class="empty" colspan="6">Kein Spulenabzug im Zeitraum.</td></tr>';
const chartPrinters = (data.by_printer || []).map((p) => ({
...p,
@@ -180,16 +185,18 @@ function render(data) {
}));
document.getElementById("stat").innerHTML = `
<div class="kpi-row">
<div class="kpi-row kpi-primary">
${kpi("Läufe", String(jobs), "", "layers")}
${kpi("Druckzeit", hours(o.duration_s), "", "clock")}
${kpi("Verbrauch", grams(o.weight_g), "", "weight")}
${kpi("Kosten", euro(o.cost), "", "euro")}
${kpi("Erfolg", success + " %", (o.completed || 0) + " fertig · " + (o.failed || 0) + " abgebrochen", "circle-check")}
${kpi("Ausschuss", scrap + " %", (o.scrap || 0) + " mit Urteil Ausschuss", "ban")}
${kpi("Stück-Yield", o.yield_percent != null ? Math.round(o.yield_percent) + " %" : "—", (o.pieces_ok || 0) + " gut · " + (o.pieces_bad || 0) + " schlecht", "percent")}
${kpi("€ / Teil", o.cost_per_part != null ? euro(o.cost_per_part) : "—", "Material + Strom ÷ gut", "tag")}
${kpi("Schätztreue", ratio, data.estimate_count ? data.estimate_count + " Läufe mit Soll-Zeit" : "keine Soll-Zeit", "gauge")}
</div>
<div class="kpi-row kpi-secondary">
${kpi("Ausschuss", scrap + " %", (o.scrap || 0) + " mit Urteil Ausschuss", "ban", "kpi-sm")}
${kpi("Stück-Yield", o.yield_percent != null ? Math.round(o.yield_percent) + " %" : "—", (o.pieces_ok || 0) + " gut · " + (o.pieces_bad || 0) + " schlecht", "percent", "kpi-sm")}
${kpi("€ / Teil", o.cost_per_part != null ? euro(o.cost_per_part) : "—", "Material + Strom ÷ gut", "tag", "kpi-sm")}
${kpi("Schätztreue", ratio, data.estimate_count ? data.estimate_count + " Läufe mit Soll-Zeit" : "keine Soll-Zeit", "gauge", "kpi-sm")}
</div>
<div class="split">
<div class="card">
@@ -198,7 +205,7 @@ function render(data) {
</div>
<div class="card">
<h2>Verbrauch je Material</h2>
${barChart(chartMaterials, "weight_g", (m) => [m.material_type, m.brand].filter(Boolean).join(" · ") || "—")}
${barChart(chartMaterials, "weight_g", (m) => [m.material_type, m.brand, m.color].filter(Boolean).join(" · ") || "—")}
</div>
</div>
<div class="card">
@@ -218,7 +225,7 @@ function render(data) {
<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>
<thead><tr><th>Sorte</th><th>Marke</th><th>Farbe</th><th>Gramm</th><th>Kosten</th><th>Läufe</th></tr></thead>
<tbody>${materials}</tbody>
</table></div>
</div>`;
@@ -231,17 +238,25 @@ async function load() {
const host = document.getElementById("host").value;
const material = document.getElementById("material").value.trim();
const brand = document.getElementById("brand").value.trim();
const color = document.getElementById("color").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);
if (color) params.set("color", color);
const q = params.toString();
document.getElementById("csv-link").href = "/api/v1/statistik.csv" + (q ? "?" + q : "");
const data = await api("/statistik" + (q ? "?" + q : ""));
render(data);
}
let filterTimer = null;
function scheduleLoad() {
clearTimeout(filterTimer);
filterTimer = setTimeout(() => load().catch((error) => showNote("stat-note", error)), 280);
}
function applyPeriod(key) {
const range = periodRange(key);
document.getElementById("since").value = range.since;
@@ -256,28 +271,18 @@ document.getElementById("period").addEventListener("click", (event) => {
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));
scheduleLoad();
});
});
document.getElementById("host").addEventListener("change", () => {
load().catch((error) => showNote("stat-note", error));
});
["material", "brand"].forEach((id) => {
document.getElementById("host").addEventListener("change", scheduleLoad);
["material", "brand", "color"].forEach((id) => {
const node = document.getElementById(id);
if (!node) return;
node.addEventListener("change", () => load().catch((error) => showNote("stat-note", error)));
node.addEventListener("keydown", (event) => {
if (event.key === "Enter") {
event.preventDefault();
load().catch((error) => showNote("stat-note", error));
}
});
node.addEventListener("input", scheduleLoad);
node.addEventListener("change", scheduleLoad);
});
load().catch((error) => showNote("stat-note", error));
</script>
+121 -32
View File
@@ -340,6 +340,14 @@ function flowHtml(printer) {
return bits.join("");
}
function filamentPauseHint(printer) {
if (!printer) return "";
if (printer.filament_empty) return printer.pause_reason || printer.message || "Filament leer";
const text = String(printer.pause_reason || printer.message || "");
if (/filament|spule|material.?leer|gerissen|runout|10107/i.test(text)) return text;
return "";
}
function stripsHtml(printer, state) {
const bits = [];
const busy = state === "printing" || state === "paused" || state === "heating";
@@ -348,6 +356,14 @@ function stripsHtml(printer, state) {
if (state === "error") {
bits.push(`<div class="strip bad">${icon("alert")}<span class="grow">${escapeHtml(printer.message || "Drucker meldet einen Fehler.")}</span><button type="button" class="warn" data-cmd="clear_error">Fehler löschen</button></div>`);
}
const filamentHint = state === "paused" ? filamentPauseHint(printer) : "";
if (filamentHint) {
bits.push(`<div class="strip warn filament-swap">
<div class="strip-head">${icon("alert")}<span class="grow"><b>Neue Rolle in Material dem Drucker zuweisen</b></span></div>
<span class="strip-msg">${escapeHtml(filamentHint)} — sonst zählt der Verbrauch weiter zur alten Spule. Ort = dieser Drucker, speichern, dann Weiter.</span>
<div class="strip-actions"><a class="ghost" href="/material">Zu Material</a></div>
</div>`);
}
const failed = !queueBusy && printer.queue_failed;
if (failed) {
const step = failed.step_label_current || "Schritt " + ((Number(failed.step_index) || 0) + 1);
@@ -417,7 +433,11 @@ function aceSlotLabel(slot) {
function aceSteerHtml(printer) {
const dryers = printer.dryers || [];
if (!dryers.length) return "";
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 (!dryers.length && !loaded.length) return "";
const bits = [];
dryers.forEach((dryer) => {
const id = Number(dryer.id);
@@ -443,10 +463,6 @@ function aceSteerHtml(printer) {
</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) => {
@@ -472,16 +488,22 @@ function steerPanelHtml(printer) {
const fan = typeof printer.fan === "number" ? Math.round(printer.fan * 100) : 0;
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>
<button type="button" data-steer-go="temps">Temp</button>
<button type="button" class="ghost" data-steer-go="heaters_off" title="Heizung und Lüfter aus">Aus</button>
<div class="steer-group">
<div class="steer-group-label">Heizung</div>
<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>
<button type="button" data-steer-go="temps">Temp</button>
<button type="button" class="ghost" data-steer-go="heaters_off" title="Heizung und Lüfter aus">Aus</button>
</div>
</div>
<div class="steer-row">
<label>Speed % <input type="number" data-steer="speed" data-steer-action="speed" min="10" max="200" value="${escapeHtml(String(sp))}"></label>
<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 class="steer-group">
<div class="steer-group-label">Druck</div>
<div class="steer-row">
<label>Speed % <input type="number" data-steer="speed" data-steer-action="speed" min="10" max="200" value="${escapeHtml(String(sp))}"></label>
<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>
</div>
${aceSteerHtml(printer)}
<p class="tiny">Enter im Feld setzt nur diesen Wert.</p>
@@ -564,19 +586,24 @@ function factsHtml(printer) {
`;
}
function factChip(label, value, ico) {
if (!value && value !== 0) return "";
return `<span class="fact-chip">${ico || ""}<span class="fact-chip-label">${escapeHtml(label)}</span><b>${escapeHtml(String(value))}</b></span>`;
}
function techFactsHtml(printer) {
const part = fanPct(printer, "part") || fanPct(printer);
const chamberFan = fanPct(printer, "chamber");
const auxFan = fanPct(printer, "aux");
const speed = speedPct(printer);
return `
${part ? "<span class=\"fact-ico\">" + icon("fan") + " Teil <b>" + part + "</b></span>" : ""}
${chamberFan ? "<span class=\"fact-ico\">" + icon("fan-chamber") + " Bauraum <b>" + chamberFan + "</b></span>" : ""}
${auxFan ? "<span class=\"fact-ico\">" + icon("fan-aux") + " Aux <b>" + auxFan + "</b></span>" : ""}
${speed ? "<span>Speed <b>" + speed + "</b></span>" : ""}
${anycubicTaskLine(printer)}
<span>Host <b>${escapeHtml(printer.host)}</b></span>
`;
return [
part ? factChip("Teil", part, icon("fan")) : "",
chamberFan ? factChip("Bauraum", chamberFan, icon("fan-chamber")) : "",
auxFan ? factChip("Aux", auxFan, icon("fan-aux")) : "",
speed ? factChip("Speed", speed) : "",
anycubicTaskLine(printer),
factChip("Host", printer.host),
].filter(Boolean).join("");
}
function anycubicTaskLine(printer) {
@@ -584,13 +611,11 @@ function anycubicTaskLine(printer) {
if (!task || typeof task !== "object") return "";
const state = String(task.state || "").trim();
const tid = task.task_id != null && String(task.task_id).trim() ? String(task.task_id) : "";
const file = task.filename ? String(task.filename) : "";
if (!state && !tid && !file) return "";
if (!state && !tid) return "";
const bits = [];
if (state) bits.push(state);
if (tid) bits.push("task " + tid);
if (file) bits.push(file);
return "<span>Anycubic <b>" + escapeHtml(bits.join(" · ")) + "</b></span>";
if (tid) bits.push("#" + tid);
return factChip("Anycubic", bits.join(" · "));
}
function jobLineVisible(printer, state, filename) {
@@ -626,12 +651,15 @@ function buildCard(printer) {
article.dataset.needsClear = needsClear(printer) ? "1" : "";
article.innerHTML = `
<div class="machine-head">
<div class="machine-title">
<h2></h2>
${badge}
<span data-role="chip"></span>
<div class="machine-head-main">
<div class="machine-title">
<h2></h2>
${badge}
<span data-role="chip"></span>
</div>
<div data-role="hero"></div>
</div>
<div data-role="hero"></div>
<img class="wall-cam-thumb" data-role="wall-cam" alt="" hidden>
</div>
<div class="machine-body">
<div data-role="bar"></div>
@@ -655,6 +683,59 @@ function buildCard(printer) {
return article;
}
/** Wand-Thumb immer Snapshot — nie Stream, unabhängig von der Browser-Einstellung. */
function wallCamSnapshotUrl(printer) {
if (!printer || !printer.camera || printer.has_camera === false) return "";
return withToken(printer.camera) + "&t=" + Math.floor(Date.now() / 3000);
}
function patchWallCam(article, printer) {
const img = article.querySelector('[data-role="wall-cam"]');
const head = article.querySelector(".machine-head");
if (!img) return;
const next = wallCamSnapshotUrl(printer);
if (!next) {
img.hidden = true;
img.removeAttribute("src");
img.dataset.srcKey = "";
img.dataset.pending = "";
if (head) head.classList.remove("has-cam");
return;
}
img.alt = "Kamera " + (printer.name || printer.host || "");
if (img.dataset.srcKey === next) {
img.hidden = false;
if (head) head.classList.add("has-cam");
return;
}
img.dataset.pending = next;
const probe = new Image();
probe.onload = () => {
if (img.dataset.pending !== next) return;
img.dataset.srcKey = next;
img.dataset.pending = "";
img.src = next;
img.hidden = false;
if (head) head.classList.add("has-cam");
};
probe.onerror = () => {
if (img.dataset.pending !== next) return;
img.dataset.pending = "";
img.hidden = true;
img.removeAttribute("src");
img.dataset.srcKey = "";
if (head) head.classList.remove("has-cam");
};
probe.src = next;
}
function refreshWallCams() {
for (const [host, article] of cardNodes.entries()) {
const printer = printerCache.get(host);
if (printer) patchWallCam(article, printer);
}
}
function steerHasFocus(root) {
const active = document.activeElement;
return !!(active && root.contains(active) && active.matches("input, select, textarea, button"));
@@ -772,6 +853,7 @@ function patchCard(article, printer) {
setHtml(role("flow"), flowHtml(printer));
setHtml(role("strips"), stripsHtml(printer, state));
setHtml(role("controls"), actionsHtml(state, printer));
patchWallCam(article, printer);
}
function refreshDeskCamera(printer) {
@@ -1618,6 +1700,12 @@ async function handleWallClick(event) {
}
button.disabled = true;
try {
if (button.dataset.cmd === "fortsetzen") {
const card = printerCache.get(host);
if (card && filamentPauseHint(card)) {
toast("Neue Spule unter Material dem Drucker zuweisen — sonst bleibt der Abzug bei der alten Rolle.");
}
}
await api("/drucker/" + encodeURIComponent(host) + "/" + button.dataset.cmd, { method: "POST", json: {} });
showNote("note", null);
refresh();
@@ -1665,6 +1753,7 @@ deskEl.addEventListener("keydown", async (event) => {
refresh().catch((error) => showNote("note", error));
createPoller(() => refresh().catch((error) => showNote("note", error)), 12000).start();
setInterval(refreshWallCams, 3000);
setInterval(() => {
api("/dateien").then((rows) => { libraryFiles = rows || []; }).catch(() => {});
api("/spulen").then((rows) => { wallSpools = rows || []; }).catch(() => {});