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:
1 parent
efd0801cd1
commit
9e22cebb81
48 files changed
+2259
-422
No files matched your search
+12
-32
@@ -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
@@ -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" });
|
||||
|
||||
@@ -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. 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 %}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user