Senden und Abbruch laufen über den Treiber, Orca-Doppelzeilen und tote Ideen-API fallen weg, Drucker folgen der Datei, Etikettenformat liegt in den Einstellungen.
372 lines
15 KiB
HTML
372 lines
15 KiB
HTML
{% extends "layout.html" %}
|
|
{% block body %}
|
|
<div class="page-head">
|
|
<div>
|
|
<h1>Protokoll</h1>
|
|
<p class="tiny">Abgeschlossene und laufende Drucke mit Dauer, Material und Kosten.</p>
|
|
<details class="tiny proto-help">
|
|
<summary>Filament-Pause / doppelte Zeilen</summary>
|
|
<p>Nach Runout kann der Drucker kurz zwei History-Zeilen erzeugen. „Läufe holen“ legt sie zusammen; sonst „Verbinden“.</p>
|
|
</details>
|
|
</div>
|
|
<div class="page-head-side">
|
|
<button type="button" id="sync"><svg class="ico"><use href="#i-refresh"/></svg>Läufe holen</button>
|
|
</div>
|
|
</div>
|
|
<div class="toolbar page-tools">
|
|
<select id="run-filter-printer" title="Drucker">
|
|
<option value="">Alle Drucker</option>
|
|
</select>
|
|
<select id="run-filter-verdict" title="Urteil">
|
|
<option value="">Alle Urteile</option>
|
|
<option value="offen">offen</option>
|
|
<option value="gut">gut</option>
|
|
<option value="ausschuss">Ausschuss</option>
|
|
</select>
|
|
<label class="check"><input type="checkbox" id="run-filter-spool"> Spule fehlt</label>
|
|
</div>
|
|
<p id="run-note"></p>
|
|
<div class="card table-wrap">
|
|
<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>
|
|
<div class="pager" id="run-pager" hidden></div>
|
|
</div>
|
|
{% endblock %}
|
|
{% block script %}
|
|
<script>
|
|
const states = {
|
|
completed: "fertig", cancelled: "abgebrochen", error: "Fehler",
|
|
in_progress: "druckt", interrupted: "unterbrochen",
|
|
};
|
|
let runs = [];
|
|
let spools = [];
|
|
let runPage = 1;
|
|
|
|
function fillPrinterFilter() {
|
|
const select = document.getElementById("run-filter-printer");
|
|
const current = select.value;
|
|
const names = [...new Set(runs.map((run) => run.printer_name).filter(Boolean))].sort((a, b) => a.localeCompare(b, "de"));
|
|
select.innerHTML = '<option value="">Alle Drucker</option>';
|
|
names.forEach((name) => {
|
|
const option = document.createElement("option");
|
|
option.value = name;
|
|
option.textContent = name;
|
|
select.appendChild(option);
|
|
});
|
|
if ([...select.options].some((o) => o.value === current)) select.value = current;
|
|
}
|
|
|
|
function filteredRuns() {
|
|
const printer = document.getElementById("run-filter-printer").value;
|
|
const verdict = document.getElementById("run-filter-verdict").value;
|
|
const needSpool = document.getElementById("run-filter-spool").checked;
|
|
return runs.filter((run) => {
|
|
if (printer && run.printer_name !== printer) return false;
|
|
if (verdict === "offen" && run.verdict) return false;
|
|
if (verdict && verdict !== "offen" && run.verdict !== verdict) return false;
|
|
if (needSpool && !(run.needs_spool || (!run.deducted && run.weight_g > 0))) return false;
|
|
return true;
|
|
});
|
|
}
|
|
|
|
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() || "";
|
|
}
|
|
|
|
let lastRunsKey = "";
|
|
|
|
function runsKey(rows) {
|
|
return (rows || []).map((run) => [
|
|
run.id, run.state, run.duration_s, run.pause_s, run.weight_used_g,
|
|
run.verdict, run.spool_id, run.merge_with_id, run.chargen_nr,
|
|
].join(":")).join("|");
|
|
}
|
|
|
|
function render(force) {
|
|
fillPrinterFilter();
|
|
const body = document.getElementById("run-rows");
|
|
const shown = filteredRuns();
|
|
const key = runsKey(shown) + "|" + runPage + "|"
|
|
+ (document.getElementById("run-filter-printer")?.value || "")
|
|
+ "|" + (document.getElementById("run-filter-verdict")?.value || "")
|
|
+ "|" + (document.getElementById("run-filter-spool")?.value || "");
|
|
if (!force && key === lastRunsKey && body.children.length) return;
|
|
lastRunsKey = key;
|
|
body.innerHTML = "";
|
|
if (!runs.length) {
|
|
body.innerHTML = '<tr><td class="empty" colspan="9"><div class="empty-state">' + icon("list") + '<p>Noch kein Lauf.</p><p class="tiny">„Läufe holen“ liest die Drucker aus.</p></div></td></tr>';
|
|
renderPager(document.getElementById("run-pager"), null, () => {});
|
|
return;
|
|
}
|
|
if (!shown.length) {
|
|
body.innerHTML = '<tr><td class="empty" colspan="9"><div class="empty-state">' + icon("filter") + '<p>Kein Lauf passt zum Filter.</p></div></td></tr>';
|
|
renderPager(document.getElementById("run-pager"), null, () => {});
|
|
return;
|
|
}
|
|
const paged = pageSlice(shown, runPage, DEFAULT_PAGE_SIZE);
|
|
runPage = paged.page;
|
|
paged.items.forEach((run) => {
|
|
const tr = document.createElement("tr");
|
|
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 = fullName;
|
|
link.textContent = shortRunName(run.filename) || fullName;
|
|
fileCell.appendChild(link);
|
|
if (run.file_id) {
|
|
const fileLink = document.createElement("a");
|
|
fileLink.href = "/dateien?file=" + encodeURIComponent(run.file_id);
|
|
fileLink.className = "tiny";
|
|
fileLink.textContent = "Datei";
|
|
fileLink.style.marginLeft = "0.35rem";
|
|
fileCell.appendChild(fileLink);
|
|
}
|
|
if (run.project_id) {
|
|
const projLink = document.createElement("a");
|
|
projLink.href = "/projekte#" + encodeURIComponent(run.project_id);
|
|
projLink.className = "tiny";
|
|
projLink.textContent = "Projekt";
|
|
projLink.style.marginLeft = "0.35rem";
|
|
fileCell.appendChild(projLink);
|
|
}
|
|
if (run.chargen_nr) {
|
|
const code = document.createElement("div");
|
|
code.className = "mono-badge";
|
|
code.textContent = run.chargen_nr;
|
|
fileCell.appendChild(code);
|
|
}
|
|
if ((run.spool_names || []).length > 1 || (run.segment_count || 0) > 1) {
|
|
const swap = document.createElement("div");
|
|
swap.className = "tiny warn-text";
|
|
swap.textContent = "Rollenwechsel: " + (run.spool_names || []).join(" → ");
|
|
fileCell.appendChild(swap);
|
|
}
|
|
if (run.merge_with_id) {
|
|
const hint = document.createElement("div");
|
|
hint.className = "tiny warn-text";
|
|
hint.textContent = "Vermutlich Fortsetzung von/zu #" + run.merge_with_id;
|
|
fileCell.appendChild(hint);
|
|
}
|
|
const stateKey = run.state === "in_progress" || run.state === "printing" ? "printing"
|
|
: run.state === "paused" ? "paused"
|
|
: run.state === "completed" ? "complete"
|
|
: run.state === "error" ? "error"
|
|
: run.state === "cancelled" || run.state === "interrupted" ? "cancelled"
|
|
: "idle";
|
|
let stateLabel = states[run.state] || run.state;
|
|
if (run.state === "cancelled" && run.merge_with_id) stateLabel = "Teilabbruch (Runout?)";
|
|
tr.children[2].innerHTML = chipHtml(stateKey, stateLabel);
|
|
let durationText = hours(run.duration_s);
|
|
if (run.pause_s && run.pause_s > 60) {
|
|
durationText += " (Pause " + hours(run.pause_s) + ")";
|
|
}
|
|
tr.children[3].textContent = durationText;
|
|
tr.children[3].className = "num";
|
|
const used = Number(run.weight_used_g) || Number(run.weight_g) || 0;
|
|
const planned = Number(run.weight_planned_g) || Number(run.weight_g) || 0;
|
|
tr.children[4].textContent = used ? grams(used) : "";
|
|
if (used && planned && Math.abs(used - planned) > 0.5) {
|
|
tr.children[4].title = "Soll " + grams(planned);
|
|
}
|
|
if (run.weight_source === "estimate" || (run.weight_source === "measured" && false)) {
|
|
tr.children[4].textContent += run.weight_source === "estimate" ? " ≈" : "";
|
|
}
|
|
const cost = Number(run.filament_cost || 0) + Number(run.energy_cost || 0);
|
|
tr.children[5].textContent = cost ? euro(cost) : "";
|
|
const verdictLabel = document.createElement("label");
|
|
verdictLabel.className = "sr-only";
|
|
verdictLabel.textContent = "Urteil";
|
|
const verdict = document.createElement("select");
|
|
verdict.setAttribute("aria-label", "Urteil");
|
|
[["", "offen"], ["gut", "gut"], ["ausschuss", "Ausschuss"]].forEach(([value, label]) => {
|
|
const option = document.createElement("option");
|
|
option.value = value;
|
|
option.textContent = label;
|
|
verdict.appendChild(option);
|
|
});
|
|
verdict.value = run.verdict || "";
|
|
verdict.addEventListener("change", async () => {
|
|
try {
|
|
await api("/laeufe/" + run.id, { method: "PATCH", json: { verdict: verdict.value } });
|
|
toast("Urteil gespeichert.");
|
|
} catch (error) {
|
|
showNote("run-note", error);
|
|
}
|
|
});
|
|
tr.children[6].appendChild(verdictLabel);
|
|
tr.children[6].appendChild(verdict);
|
|
if (run.needs_spool || !run.deducted) {
|
|
const select = document.createElement("select");
|
|
const blank = document.createElement("option");
|
|
blank.value = "";
|
|
blank.textContent = run.needs_spool ? "Spule wählen" : "keine";
|
|
select.appendChild(blank);
|
|
const mine = spools.filter((s) => s.printer_host === run.printer_host);
|
|
const others = spools.filter((s) => s.printer_host !== run.printer_host);
|
|
const addOpt = (spool, prefix) => {
|
|
const option = document.createElement("option");
|
|
option.value = spool.id;
|
|
option.textContent = (prefix ? prefix + " " : "") + spool.name;
|
|
select.appendChild(option);
|
|
};
|
|
mine.forEach((s) => addOpt(s, ""));
|
|
if (mine.length && others.length) {
|
|
const sep = document.createElement("option");
|
|
sep.disabled = true;
|
|
sep.textContent = "— andere —";
|
|
select.appendChild(sep);
|
|
}
|
|
others.forEach((s) => addOpt(s, s.printer_host ? "·" : "Regal ·"));
|
|
if (run.spool_id) select.value = String(run.spool_id);
|
|
select.addEventListener("change", async () => {
|
|
if (!select.value) return;
|
|
try {
|
|
await api("/laeufe/" + run.id, { method: "PATCH", json: { spool_id: Number(select.value) } });
|
|
load();
|
|
} catch (error) {
|
|
showNote("run-note", error);
|
|
}
|
|
});
|
|
tr.children[7].appendChild(select);
|
|
} else if ((run.spool_names || []).length > 1) {
|
|
tr.children[7].className = "proto-spool";
|
|
tr.children[7].textContent = run.spool_names.join(" → ");
|
|
tr.children[7].title = run.spool_names.join(" → ");
|
|
} else {
|
|
const spool = spools.find((item) => item.id === run.spool_id);
|
|
tr.children[7].className = "proto-spool";
|
|
if (spool) {
|
|
tr.children[7].innerHTML = linkTo("spule", spool.id, spool.name);
|
|
tr.children[7].title = spool.name;
|
|
} else if ((run.spool_names || []).length) {
|
|
tr.children[7].textContent = run.spool_names[0];
|
|
} else {
|
|
tr.children[7].textContent = "abgezogen";
|
|
}
|
|
}
|
|
if (!tr.children[7].className) tr.children[7].className = "proto-spool";
|
|
const actions = tr.children[8];
|
|
actions.className = "proto-actions";
|
|
if (run.merge_with_id) {
|
|
const mergeBtn = document.createElement("button");
|
|
mergeBtn.type = "button";
|
|
mergeBtn.className = "ghost tiny-btn";
|
|
mergeBtn.textContent = "Verbinden";
|
|
mergeBtn.title = "Mit Lauf #" + run.merge_with_id + " verbinden";
|
|
mergeBtn.addEventListener("click", () => {
|
|
armButton(mergeBtn, "Mit #" + run.merge_with_id + " verbinden?", async () => {
|
|
try {
|
|
await api("/laeufe/" + run.id + "/verbinden", {
|
|
method: "POST",
|
|
json: { other_id: run.merge_with_id, confirm: true },
|
|
});
|
|
toast("Läufe verbunden.");
|
|
await load();
|
|
} catch (error) {
|
|
toast(error.message, "bad");
|
|
}
|
|
});
|
|
});
|
|
actions.appendChild(mergeBtn);
|
|
}
|
|
const running = ["in_progress", "printing", "paused"].includes(run.state);
|
|
if (run.file_id && !running) {
|
|
const again = document.createElement("button");
|
|
again.type = "button";
|
|
again.className = "ghost tiny-btn";
|
|
again.textContent = "Nochmal";
|
|
again.title = "Denselben Druck in „wenn frei“ einreihen";
|
|
again.addEventListener("click", () => {
|
|
armButton(again, "Nochmal einreihen?", async () => {
|
|
again.disabled = true;
|
|
try {
|
|
let aceMap = [];
|
|
let bedLevel = false;
|
|
try {
|
|
const last = await api(
|
|
"/senden-zuletzt?host=" + encodeURIComponent(run.printer_host || "")
|
|
+ "&file_id=" + encodeURIComponent(run.file_id)
|
|
);
|
|
if (last) {
|
|
aceMap = last.ace_map || [];
|
|
bedLevel = !!last.bed_leveling;
|
|
}
|
|
} catch (error) { /* ohne Map weiter */ }
|
|
const payload = await api("/senden", {
|
|
method: "POST",
|
|
json: {
|
|
file_id: run.file_id,
|
|
hosts: run.printer_host ? [run.printer_host] : [],
|
|
spool_id: run.spool_id || null,
|
|
confirm: true,
|
|
when_free: true,
|
|
ace_maps: run.printer_host && aceMap.length
|
|
? { [run.printer_host]: aceMap }
|
|
: {},
|
|
bed_leveling: bedLevel,
|
|
},
|
|
});
|
|
toast((payload.count || (payload.jobs || []).length || 1) + " in Warteschlange „wenn frei“.");
|
|
if (typeof refreshPrintQueue === "function") refreshPrintQueue();
|
|
} catch (error) {
|
|
toast(error.message, "bad");
|
|
} finally {
|
|
again.disabled = false;
|
|
}
|
|
});
|
|
});
|
|
actions.appendChild(again);
|
|
}
|
|
body.appendChild(tr);
|
|
});
|
|
renderPager(document.getElementById("run-pager"), paged, (p) => {
|
|
runPage = p;
|
|
render();
|
|
});
|
|
}
|
|
|
|
async function load(resetPage) {
|
|
[runs, spools] = await Promise.all([api("/laeufe?limit=500"), api("/spulen")]);
|
|
if (resetPage !== false) runPage = 1;
|
|
render();
|
|
}
|
|
window.onDruckerLive = () => { load(false).catch(() => {}); };
|
|
window.onQueueChanged = () => { load(false).catch(() => {}); };
|
|
|
|
["run-filter-printer", "run-filter-verdict", "run-filter-spool"].forEach((id) => {
|
|
document.getElementById(id).addEventListener("change", () => { runPage = 1; render(); });
|
|
document.getElementById(id).addEventListener("input", () => { runPage = 1; render(); });
|
|
});
|
|
|
|
document.getElementById("sync").addEventListener("click", async () => {
|
|
const button = document.getElementById("sync");
|
|
button.disabled = true;
|
|
try {
|
|
const result = await api("/laeufe/abgleich", { method: "POST" });
|
|
document.getElementById("run-note").textContent = result.gelesen + " Läufe, " + result.abgezogen + " abgezogen.";
|
|
document.getElementById("run-note").className = "ok";
|
|
await load();
|
|
} catch (error) {
|
|
showNote("run-note", error);
|
|
} finally {
|
|
button.disabled = false;
|
|
}
|
|
});
|
|
load().catch((error) => showNote("run-note", error));
|
|
</script>
|
|
{% endblock %}
|