Grotti 2.0.0: eigenes Repo, Umbenennung von Farm.
This commit is contained in:
commit
b2e6443c68
69 files changed
+18719
No files matched your search
@@ -0,0 +1,17 @@
|
||||
{% macro doc_toc(current) %}
|
||||
<nav class="doc-toc" aria-label="Doku">
|
||||
<p>Handbuch</p>
|
||||
<a href="/doku#grotti" data-sec="grotti" class="{{ 'on' if current == 'start' else '' }}">Was Grotti ist</a>
|
||||
<a href="/doku#druck" data-sec="druck">Ein Druck von A bis Z</a>
|
||||
<a href="/doku#ablauf" data-sec="ablauf">Ablauf (Kobra 3)</a>
|
||||
<a href="/doku#seiten" data-sec="seiten">Seiten</a>
|
||||
<a href="/doku#haengt" data-sec="haengt">Wenn etwas hängt</a>
|
||||
<a href="/doku#begriffe" data-sec="begriffe">Begriffe</a>
|
||||
<p>Versionen</p>
|
||||
<a href="/doku/changelog" class="{{ 'on' if current == 'changelog' else '' }}">Changelog · v{{ version }}</a>
|
||||
<p>Technik</p>
|
||||
{% for name in docs %}
|
||||
<a href="/doku/{{ name }}" class="{{ 'on' if current == name else '' }}">{{ name | replace('-', ' ') }}</a>
|
||||
{% endfor %}
|
||||
</nav>
|
||||
{% endmacro %}
|
||||
@@ -0,0 +1,183 @@
|
||||
{% extends "layout.html" %}
|
||||
{% block body %}
|
||||
<div class="page-head">
|
||||
<div>
|
||||
<h1>Aktivität</h1>
|
||||
<p class="tiny">Ereignisse von Grotti und den Druckern. Neueste oben.</p>
|
||||
</div>
|
||||
<div class="page-head-side">
|
||||
<button type="button" class="ghost" id="tl-refresh"><svg class="ico"><use href="#i-refresh"/></svg>Neu laden</button>
|
||||
<button type="button" id="tl-read"><svg class="ico"><use href="#i-check"/></svg>Alle gelesen</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="filter-bar" id="f-range" role="group" aria-label="Zeitraum">
|
||||
<button type="button" class="fchip on" data-range="day">Heute</button>
|
||||
<button type="button" class="fchip" data-range="week">7 Tage</button>
|
||||
<button type="button" class="fchip" data-range="all">Alle</button>
|
||||
</div>
|
||||
<div class="filter-bar" id="f-kind" role="group" aria-label="Art"></div>
|
||||
<div class="filter-bar" id="f-host" role="group" aria-label="Drucker"></div>
|
||||
|
||||
<p id="tl-note" class="tiny"></p>
|
||||
<div class="timeline" id="timeline">
|
||||
<div class="skeleton" style="height:4rem"></div>
|
||||
<div class="skeleton" style="height:4rem"></div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block script %}
|
||||
<script>
|
||||
const KINDS = [
|
||||
["queue", "Warteschlange"],
|
||||
["ablauf", "Ablauf"],
|
||||
["start", "Start / Pause"],
|
||||
["fertig", "Fertig"],
|
||||
["fehler", "Fehler"],
|
||||
["abbruch", "Abbruch"],
|
||||
["offline", "Offline"],
|
||||
["spule", "Spule"],
|
||||
];
|
||||
const KIND_LABEL = Object.fromEntries(KINDS);
|
||||
const params = new URLSearchParams(location.search);
|
||||
const filter = {
|
||||
range: params.get("range") || (params.get("host") ? "week" : "day"),
|
||||
kinds: new Set((params.get("kind") || "").split(",").filter(Boolean)),
|
||||
host: params.get("host") || "",
|
||||
};
|
||||
let printers = [];
|
||||
|
||||
function syncUrl() {
|
||||
const q = new URLSearchParams();
|
||||
if (filter.range !== "day") q.set("range", filter.range);
|
||||
if (filter.kinds.size) q.set("kind", [...filter.kinds].join(","));
|
||||
if (filter.host) q.set("host", filter.host);
|
||||
const s = q.toString();
|
||||
history.replaceState(null, "", s ? "?" + s : location.pathname);
|
||||
}
|
||||
|
||||
function renderFilters() {
|
||||
document.querySelectorAll("#f-range .fchip").forEach((b) => b.classList.toggle("on", b.dataset.range === filter.range));
|
||||
document.getElementById("f-kind").innerHTML =
|
||||
`<button type="button" class="fchip ${filter.kinds.size ? "" : "on"}" data-kind="">Alle Arten</button>` +
|
||||
KINDS.map(([k, label]) =>
|
||||
`<button type="button" class="fchip ${filter.kinds.has(k) ? "on" : ""}" data-kind="${k}"><span class="tl-dot ${k}"></span>${label}</button>`
|
||||
).join("");
|
||||
document.getElementById("f-host").innerHTML =
|
||||
`<button type="button" class="fchip ${filter.host ? "" : "on"}" data-host="">Alle Drucker</button>` +
|
||||
printers.map((p) =>
|
||||
`<button type="button" class="fchip ${filter.host === p.host ? "on" : ""}" data-host="${escapeHtml(p.host)}">${escapeHtml(p.name)}</button>`
|
||||
).join("");
|
||||
}
|
||||
|
||||
function sinceIso() {
|
||||
if (filter.range === "all") return "";
|
||||
const d = new Date();
|
||||
if (filter.range === "day") d.setHours(0, 0, 0, 0);
|
||||
else d.setDate(d.getDate() - 7);
|
||||
return d.toISOString();
|
||||
}
|
||||
|
||||
function dayLabel(date) {
|
||||
const today = new Date(); today.setHours(0, 0, 0, 0);
|
||||
const that = new Date(date); that.setHours(0, 0, 0, 0);
|
||||
const diff = Math.round((today - that) / 86400000);
|
||||
if (diff === 0) return "Heute";
|
||||
if (diff === 1) return "Gestern";
|
||||
return that.toLocaleDateString("de-DE", { weekday: "long", day: "numeric", month: "long" });
|
||||
}
|
||||
|
||||
function itemHtml(ev) {
|
||||
const t = new Date(ev.created_at);
|
||||
const time = t.toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" });
|
||||
let msg = ev.message || "";
|
||||
if (ev.printer && msg.startsWith(ev.printer)) msg = msg.slice(ev.printer.length).replace(/^[\s:·—-]+/, "");
|
||||
const who = ev.printer
|
||||
? `<a href="/#${encodeURIComponent(ev.host)}"><b>${escapeHtml(ev.printer)}</b></a>`
|
||||
: `<b>Grotti</b>`;
|
||||
const file = ev.filename && !msg.includes(ev.filename)
|
||||
? `<div class="tl-file">${icon("file", "ico-sm")} ${escapeHtml(ev.filename)}</div>` : "";
|
||||
return `<li class="tl-item ${ev.unread ? "unread" : ""}">
|
||||
<span class="tl-time" title="${escapeHtml(t.toLocaleString("de-DE"))}">${time}</span>
|
||||
<span class="tl-dot ${escapeHtml(ev.kind)}"></span>
|
||||
<div class="tl-body">
|
||||
<div class="tl-head">${who}<span class="tl-kind">${escapeHtml(KIND_LABEL[ev.kind] || ev.kind)}</span></div>
|
||||
<div class="tl-msg">${escapeHtml(msg)}</div>${file}
|
||||
</div>
|
||||
</li>`;
|
||||
}
|
||||
|
||||
function render(items) {
|
||||
const box = document.getElementById("timeline");
|
||||
const note = document.getElementById("tl-note");
|
||||
if (!items.length) {
|
||||
box.innerHTML = `<div class="card empty-state">${icon("activity")}<p>Keine Einträge${filter.range === "day" ? " heute" : ""}.</p>${
|
||||
filter.range !== "all" ? `<button type="button" class="ghost" data-range-jump="all">Alle anzeigen</button>` : ""}</div>`;
|
||||
note.textContent = "";
|
||||
return;
|
||||
}
|
||||
const groups = [];
|
||||
for (const ev of items) {
|
||||
const label = dayLabel(ev.created_at);
|
||||
if (!groups.length || groups[groups.length - 1].label !== label) groups.push({ label, items: [] });
|
||||
groups[groups.length - 1].items.push(ev);
|
||||
}
|
||||
box.innerHTML = groups.map((g) =>
|
||||
`<section class="tl-day"><h3>${escapeHtml(g.label)} · ${g.items.length}</h3><ol class="tl-list">${g.items.map(itemHtml).join("")}</ol></section>`
|
||||
).join("");
|
||||
note.textContent = items.length >= 300 ? "Die letzten 300 Einträge. Zeitraum oder Filter enger stellen für ältere." : "";
|
||||
}
|
||||
|
||||
async function load() {
|
||||
const q = new URLSearchParams({ limit: "300" });
|
||||
const since = sinceIso();
|
||||
if (since) q.set("since", since);
|
||||
if (filter.kinds.size) q.set("kind", [...filter.kinds].join(","));
|
||||
if (filter.host) q.set("host", filter.host);
|
||||
try {
|
||||
const data = await api("/ereignisse?" + q.toString());
|
||||
render(data.items || []);
|
||||
} catch (err) {
|
||||
document.getElementById("timeline").innerHTML = `<div class="strip bad">${icon("alert")}<span>${escapeHtml(err.message)}</span></div>`;
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("click", (e) => {
|
||||
const range = e.target.closest("[data-range], [data-range-jump]");
|
||||
if (range) {
|
||||
filter.range = range.dataset.range || range.dataset.rangeJump;
|
||||
} else if (e.target.closest("#f-kind [data-kind]")) {
|
||||
const k = e.target.closest("[data-kind]").dataset.kind;
|
||||
if (!k) filter.kinds.clear();
|
||||
else if (filter.kinds.has(k)) filter.kinds.delete(k);
|
||||
else filter.kinds.add(k);
|
||||
} else if (e.target.closest("#f-host [data-host]")) {
|
||||
filter.host = e.target.closest("[data-host]").dataset.host;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
syncUrl();
|
||||
renderFilters();
|
||||
load();
|
||||
});
|
||||
|
||||
document.getElementById("tl-refresh").addEventListener("click", load);
|
||||
document.getElementById("tl-read").addEventListener("click", async () => {
|
||||
await api("/ereignisse/gelesen", { method: "POST", json: {} });
|
||||
toast("Alles als gelesen markiert.", "good");
|
||||
if (typeof refreshEvents === "function") refreshEvents();
|
||||
load();
|
||||
});
|
||||
window.onEventsRead = load;
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
printers = await api("/drucker?live=0");
|
||||
} catch (_) {
|
||||
printers = [];
|
||||
}
|
||||
renderFilters();
|
||||
load();
|
||||
setInterval(load, 15000);
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,100 @@
|
||||
{% extends "layout.html" %}
|
||||
{% from "_doc_toc.html" import doc_toc with context %}
|
||||
{% block body %}
|
||||
<div class="doc-layout">
|
||||
{{ doc_toc('start') }}
|
||||
<article class="prose">
|
||||
<h1 id="grotti">Grotti <span class="badge">v{{ version }}</span></h1>
|
||||
<p class="lede">Grotti steuert die Drucker im Haus von einer Seite aus: sehen, was läuft, Dateien schicken, Aufträge einreihen und nachlesen, was passiert ist.</p>
|
||||
<p>Sie läuft auf diesem Rechner und spricht jeden Drucker über Moonraker an (Rinkhals auf Kobra 3 und Kobra S1). Daten liegen in PostgreSQL. Alles, was die Seiten zeigen, gibt es auch unter <code>/api/v1</code>.</p>
|
||||
<blockquote>Sicherheit: Ein Druck startet nur nach ausdrücklicher Bestätigung. Es gibt keine freie G-code-Konsole und kein <code>FIRMWARE_RESTART</code>.</blockquote>
|
||||
|
||||
<h2 id="druck">Ein Druck von A bis Z</h2>
|
||||
<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">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>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, 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>
|
||||
|
||||
<h2 id="ablauf">Ablauf (Kobra 3)</h2>
|
||||
<p>Ein Ablauf ist eine Kette, die der Kobra 3 alleine abarbeitet und beliebig oft wiederholt:</p>
|
||||
<div class="flow-diagram">
|
||||
<span class="step">Drucken</span><span class="step-sep"></span>
|
||||
<span class="step">Abkühlen</span><span class="step-sep"></span>
|
||||
<span class="step">Schieben</span><span class="step-sep"></span>
|
||||
<span class="step">Tasten</span><span class="step-sep"></span>
|
||||
<span class="step">nochmal</span>
|
||||
</div>
|
||||
<ul>
|
||||
<li><strong>Abkühlen</strong> wartet, bis Bett und Düse unter der Grenze sind. Danach löst sich PLA meist von selbst.</li>
|
||||
<li><strong>Schieben</strong> fährt mit dem Extruder knapp über dem Bett von hinten nach vorn und schiebt das Teil in die Kiste.</li>
|
||||
<li><strong>Tasten</strong> tippt mit aktivem LeviQ-Netz (ohne <code>BED_MESH_CLEAR</code>, ohne <code>G28 Z</code>). Tastpunkt einstellbar. Maß: <code>Result is z=</code> über der lokalen Platte. Über 1 mm stoppt den Ablauf.</li>
|
||||
</ul>
|
||||
<p>Einreihen unter <a href="/dateien?tab=ablauf">Dateien → Ablauf</a>. Der aktuelle Schritt steht auf der Druckerkarte und in der Seitenleiste. Abbrechen geht an beiden Stellen. Jeder Schritt landet unter <a href="/aktivitaet">Aktivität</a>.</p>
|
||||
|
||||
<h2 id="seiten">Seiten</h2>
|
||||
<dl class="terms">
|
||||
<dt><a href="/">Drucker</a></dt><dd>Die Wand. Eine Karte je Drucker mit Zustand, Fertig-Uhrzeit, Temperaturen und Ablauf-Schritten. Klick öffnet das Cockpit mit Kamera, Steuerung, Bettnetz und 3D-Pfad.</dd>
|
||||
<dt><a href="/dateien">Dateien</a></dt><dd>Bibliothek und Senden. Spule und ACE-Plätze unter Optionen. Warnt, wenn das Filament nicht reicht.</dd>
|
||||
<dt><a href="/kameras">Kameras</a></dt><dd>Alle Kamerabilder nebeneinander.</dd>
|
||||
<dt><a href="/aktivitaet">Aktivität</a></dt><dd>Zeitstrahl: Starts, Schritte, Fertig, Fehler, Abbrüche, offline. Filter nach Drucker, Art und Zeitraum.</dd>
|
||||
<dt><a href="/protokoll">Protokoll</a></dt><dd>Abgeschlossene Läufe mit Dauer, Gramm, Kosten und Urteil.</dd>
|
||||
<dt><a href="/material">Material</a></dt><dd>Sorten, Spulen, Farben, ACE-Plätze, Etiketten.</dd>
|
||||
<dt><a href="/projekte">Projekte</a></dt><dd>Aufträge mit Notiz und Kalkulation.</dd>
|
||||
<dt><a href="/einstellungen">Einstellungen</a></dt><dd>Drucker, Telegram-Alerts, Energie.</dd>
|
||||
</dl>
|
||||
|
||||
<h2 id="haengt">Wenn etwas hängt</h2>
|
||||
<dl class="terms">
|
||||
<dt>Auftrag wartet und startet nicht</dt><dd>Auf der Karte steht <em>Platte belegt</em>. Platte räumen, dann <em>Platte geräumt</em> drücken.</dd>
|
||||
<dt>Drucker im Fehlerzustand</dt><dd>Auf der Karte <em>Fehler löschen</em>. Grotti räumt hängende Fehler vor einem Start selbst, wenn kein Druck aktiv ist.</dd>
|
||||
<dt>Ablauf gestoppt</dt><dd>Die Seitenleiste zeigt den Grund. Ursache beheben, Ablauf neu einreihen. Unter <a href="/aktivitaet">Aktivität</a> steht, welcher Schritt zuletzt lief.</dd>
|
||||
<dt>Drucker offline</dt><dd>Strom und WLAN prüfen. Grotti meldet, wenn er wieder da ist.</dd>
|
||||
<dt>ACE startet nicht</dt><dd>Unter Optionen die ACE-Zuordnung prüfen. Ohne Zuordnung kommt oft „cannot parse“.</dd>
|
||||
</dl>
|
||||
|
||||
<h2 id="begriffe">Begriffe</h2>
|
||||
<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>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>
|
||||
</dl>
|
||||
|
||||
<h2>Technik</h2>
|
||||
<p>Die Notizen zu Aufbau und Schnittstellen:</p>
|
||||
<ul>
|
||||
{% for name in docs %}<li><a href="/doku/{{ name }}">{{ name | replace('-', ' ') }}</a></li>{% endfor %}
|
||||
</ul>
|
||||
<p>Was sich je Version geändert hat, steht im <a href="/doku/changelog">Changelog</a>.</p>
|
||||
</article>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block script %}
|
||||
<script>
|
||||
(() => {
|
||||
const links = [...document.querySelectorAll(".doc-toc a[data-sec]")];
|
||||
const sections = links.map((a) => document.getElementById(a.dataset.sec)).filter(Boolean);
|
||||
if (!sections.length || !("IntersectionObserver" in window)) return;
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (!entry.isIntersecting) return;
|
||||
links.forEach((a) => a.classList.toggle("on", a.dataset.sec === entry.target.id));
|
||||
});
|
||||
}, { rootMargin: "0px 0px -70% 0px" });
|
||||
sections.forEach((s) => observer.observe(s));
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,11 @@
|
||||
{% extends "layout.html" %}
|
||||
{% from "_doc_toc.html" import doc_toc with context %}
|
||||
{% block body %}
|
||||
<div class="doc-layout">
|
||||
{{ doc_toc(current) }}
|
||||
<article class="prose">
|
||||
<p class="tiny"><a href="/doku">Doku</a> · {{ title }}</p>
|
||||
{{ content }}
|
||||
</article>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,349 @@
|
||||
{% extends "layout.html" %}
|
||||
{% block body %}
|
||||
<div class="page-head">
|
||||
<div>
|
||||
<h1>Einstellungen</h1>
|
||||
<p class="tiny">Drucker, Benachrichtigungen und Energiekosten.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="card">
|
||||
<h2>Dieser Browser</h2>
|
||||
<p class="hint">Nur auf diesem Gerät / in diesem Browser. Anderer PC oder Inkognito bleibt unberührt.</p>
|
||||
<div class="field" style="max-width:16rem">
|
||||
<label for="display-mode">Anzeige</label>
|
||||
<select id="display-mode">
|
||||
<option value="desktop">Desktop</option>
|
||||
<option value="shelf">Regal (Touch)</option>
|
||||
</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>
|
||||
<p id="display-note" class="ok" hidden>Gespeichert — gilt sofort.</p>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>Automatik</h2>
|
||||
<label class="check"><input type="checkbox" id="auto-queue"> Warteschlange „wenn frei“ und Abläufe automatisch starten</label>
|
||||
<p class="hint">Aus = keine neuen Starts. Laufende Abläufe und gesperrte Drucker bleiben unberührt.</p>
|
||||
<div class="actions"><button type="button" id="auto-save">Speichern</button></div>
|
||||
<p id="auto-note"></p>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<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>
|
||||
<div class="field" style="max-width:28rem">
|
||||
<label for="fqdn">FQDN</label>
|
||||
<input id="fqdn" placeholder="https://grotti.example.de oder grotti.lan" autocomplete="off">
|
||||
</div>
|
||||
<p class="hint">Ohne Schema wird <code>https://</code> ergänzt. Mit Port: <code>http://10.10.1.5:8770</code>.</p>
|
||||
<div class="actions"><button type="button" id="fqdn-save">Speichern</button></div>
|
||||
<p id="fqdn-note"></p>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>Drucker</h2>
|
||||
<p class="hint">Gesperrt = kein neuer „wenn frei“-Start auf diesem Drucker.</p>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead><tr><th>Name</th><th>Host</th><th>Familie</th><th>Gesperrt</th><th></th></tr></thead>
|
||||
<tbody id="printer-rows"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<form id="printer-add" class="grid-2" style="margin-top:1rem">
|
||||
<div class="field">
|
||||
<label for="add-name">Name</label>
|
||||
<input id="add-name" required placeholder="z. B. Kobra 3 Werkstatt">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="add-host">Host / IP</label>
|
||||
<input id="add-host" required placeholder="10.10.1.60">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="add-family">Familie</label>
|
||||
<select id="add-family">
|
||||
<option value="">automatisch</option>
|
||||
<option value="kobra_3">Kobra 3</option>
|
||||
<option value="kobra_s1">Kobra S1</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="actions" style="align-self:end"><button type="submit">Hinzufügen</button></div>
|
||||
</form>
|
||||
<p id="printer-note"></p>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>Telegram</h2>
|
||||
<label class="check"><input type="checkbox" id="tg-enabled"> Benachrichtigungen aktiv</label>
|
||||
<div class="grid-2" style="margin-top:0.75rem">
|
||||
<div class="field">
|
||||
<label for="tg-token">Bot-Token</label>
|
||||
<input id="tg-token" type="password" autocomplete="off" placeholder="123456:ABC…">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="tg-chat">Chat-ID</label>
|
||||
<input id="tg-chat" placeholder="z. B. -100… oder User-ID">
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid-2" style="margin-top:0.75rem">
|
||||
<label class="check"><input type="checkbox" id="al-fertig"> Fertig</label>
|
||||
<label class="check"><input type="checkbox" id="al-fehler"> Fehler und Abbrüche</label>
|
||||
<label class="check"><input type="checkbox" id="al-offline"> Offline</label>
|
||||
<label class="check"><input type="checkbox" id="al-queue"> Queue (Fehler, Ablauf gestoppt)</label>
|
||||
<label class="check"><input type="checkbox" id="al-ablauf"> Ablauf-Schritte</label>
|
||||
<label class="check"><input type="checkbox" id="al-start"> Druckstart und Pause</label>
|
||||
</div>
|
||||
<p class="hint">Ereignisse stehen immer unter <a href="/aktivitaet">Aktivität</a>. Die Häkchen steuern nur Telegram.</p>
|
||||
<div class="actions">
|
||||
<button type="button" id="tg-save">Speichern</button>
|
||||
<button type="button" class="ghost" id="tg-test">Test senden</button>
|
||||
</div>
|
||||
<p id="tg-note"></p>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>Energie</h2>
|
||||
<p class="hint">Für Kosten im Protokoll. Filament zählt erst mit zugeordneter 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-kwh">€ / kWh</label><input id="en-kwh" type="number" step="0.01"></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>
|
||||
<div class="actions"><button type="button" id="en-save">Speichern</button></div>
|
||||
<p id="en-note"></p>
|
||||
</section>
|
||||
{% endblock %}
|
||||
{% block script %}
|
||||
<script>
|
||||
const families = { kobra_3: "Kobra 3", kobra_s1: "Kobra S1", "": "—" };
|
||||
|
||||
/* showNote aus api.js: Inline-Hinweis + Toast */
|
||||
|
||||
function renderPrinters(rows) {
|
||||
const body = document.getElementById("printer-rows");
|
||||
body.innerHTML = "";
|
||||
if (!rows.length) {
|
||||
body.innerHTML = '<tr><td class="empty" colspan="5">Keine Drucker.</td></tr>';
|
||||
return;
|
||||
}
|
||||
rows.forEach((row) => {
|
||||
const tr = document.createElement("tr");
|
||||
tr.innerHTML = `<td><input data-f="name"></td><td><input data-f="host"></td><td><select data-f="family">
|
||||
<option value="kobra_3">Kobra 3</option>
|
||||
<option value="kobra_s1">Kobra S1</option>
|
||||
<option value="">—</option>
|
||||
</select></td><td><label class="check"><input type="checkbox" data-f="locked"></label></td><td></td>`;
|
||||
tr.querySelector('[data-f="name"]').value = row.name || "";
|
||||
tr.querySelector('[data-f="host"]').value = row.host || "";
|
||||
tr.querySelector('[data-f="family"]').value = row.family || "";
|
||||
tr.querySelector('[data-f="locked"]').checked = !!row.locked;
|
||||
const save = document.createElement("button");
|
||||
save.type = "button";
|
||||
save.textContent = "Speichern";
|
||||
save.addEventListener("click", async () => {
|
||||
try {
|
||||
await api("/drucker/id/" + row.id, {
|
||||
method: "PATCH",
|
||||
json: {
|
||||
name: tr.querySelector('[data-f="name"]').value,
|
||||
host: tr.querySelector('[data-f="host"]').value,
|
||||
family: tr.querySelector('[data-f="family"]').value,
|
||||
locked: tr.querySelector('[data-f="locked"]').checked,
|
||||
},
|
||||
});
|
||||
showNote("printer-note", null, "Gespeichert: " + tr.querySelector('[data-f="name"]').value);
|
||||
load();
|
||||
} catch (error) {
|
||||
showNote("printer-note", error);
|
||||
}
|
||||
});
|
||||
const del = document.createElement("button");
|
||||
del.type = "button";
|
||||
del.className = "ghost warn";
|
||||
del.textContent = "Entfernen";
|
||||
del.addEventListener("click", () => {
|
||||
armButton(del, "Wirklich entfernen?", async () => {
|
||||
try {
|
||||
await api("/drucker/id/" + row.id, { method: "DELETE" });
|
||||
showNote("printer-note", null, "Drucker entfernt.");
|
||||
load();
|
||||
} catch (error) {
|
||||
showNote("printer-note", error);
|
||||
}
|
||||
});
|
||||
});
|
||||
tr.children[4].appendChild(save);
|
||||
tr.children[4].appendChild(del);
|
||||
body.appendChild(tr);
|
||||
});
|
||||
}
|
||||
|
||||
async function load() {
|
||||
const [settings, printers] = await Promise.all([
|
||||
api("/einstellungen"),
|
||||
api("/drucker?live=0"),
|
||||
]);
|
||||
renderPrinters(printers);
|
||||
document.getElementById("auto-queue").checked = settings.auto_queue !== false;
|
||||
document.getElementById("fqdn").value = settings.fqdn || "";
|
||||
document.getElementById("tg-enabled").checked = !!(settings.telegram && settings.telegram.enabled);
|
||||
document.getElementById("tg-token").value = "";
|
||||
document.getElementById("tg-token").placeholder = settings.telegram && settings.telegram.bot_token_set
|
||||
? ("gesetzt: " + (settings.telegram.bot_token || "••••"))
|
||||
: "Bot-Token";
|
||||
document.getElementById("tg-chat").value = (settings.telegram && settings.telegram.chat_id) || "";
|
||||
const alerts = settings.alerts || {};
|
||||
document.getElementById("al-fertig").checked = alerts.fertig !== false;
|
||||
document.getElementById("al-fehler").checked = alerts.fehler !== false;
|
||||
document.getElementById("al-offline").checked = alerts.offline !== false;
|
||||
document.getElementById("al-queue").checked = alerts.queue !== false;
|
||||
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-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;
|
||||
}
|
||||
|
||||
document.getElementById("auto-save").addEventListener("click", async () => {
|
||||
try {
|
||||
await api("/einstellungen", {
|
||||
method: "PATCH",
|
||||
json: { auto_queue: document.getElementById("auto-queue").checked },
|
||||
});
|
||||
showNote(
|
||||
"auto-note",
|
||||
null,
|
||||
document.getElementById("auto-queue").checked ? "Automatik an." : "Automatik aus — nichts Neues startet."
|
||||
);
|
||||
} catch (error) {
|
||||
showNote("auto-note", error);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById("fqdn-save").addEventListener("click", async () => {
|
||||
try {
|
||||
const out = await api("/einstellungen", {
|
||||
method: "PATCH",
|
||||
json: { fqdn: document.getElementById("fqdn").value.trim() },
|
||||
});
|
||||
document.getElementById("fqdn").value = out.fqdn || "";
|
||||
showNote(
|
||||
"fqdn-note",
|
||||
null,
|
||||
out.fqdn
|
||||
? "Gespeichert — Etiketten-QR: " + out.fqdn + "/material?spool=…"
|
||||
: "Leer — Etiketten nutzen den Request-Host."
|
||||
);
|
||||
} catch (error) {
|
||||
showNote("fqdn-note", error);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById("printer-add").addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
try {
|
||||
await api("/drucker", {
|
||||
method: "POST",
|
||||
json: {
|
||||
name: document.getElementById("add-name").value,
|
||||
host: document.getElementById("add-host").value,
|
||||
family: document.getElementById("add-family").value,
|
||||
},
|
||||
});
|
||||
document.getElementById("printer-add").reset();
|
||||
showNote("printer-note", null, "Drucker angelegt.");
|
||||
load();
|
||||
} catch (error) {
|
||||
showNote("printer-note", error);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById("tg-save").addEventListener("click", async () => {
|
||||
const token = document.getElementById("tg-token").value.trim();
|
||||
const payload = {
|
||||
telegram: {
|
||||
enabled: document.getElementById("tg-enabled").checked,
|
||||
chat_id: document.getElementById("tg-chat").value.trim(),
|
||||
},
|
||||
alerts: {
|
||||
fertig: document.getElementById("al-fertig").checked,
|
||||
fehler: document.getElementById("al-fehler").checked,
|
||||
offline: document.getElementById("al-offline").checked,
|
||||
queue: document.getElementById("al-queue").checked,
|
||||
ablauf: document.getElementById("al-ablauf").checked,
|
||||
start: document.getElementById("al-start").checked,
|
||||
},
|
||||
};
|
||||
if (token) payload.telegram.bot_token = token;
|
||||
try {
|
||||
await api("/einstellungen", { method: "PATCH", json: payload });
|
||||
showNote("tg-note", null, "Telegram gespeichert.");
|
||||
load();
|
||||
} catch (error) {
|
||||
showNote("tg-note", error);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById("tg-test").addEventListener("click", async () => {
|
||||
try {
|
||||
await api("/telegram/test", { method: "POST", json: {} });
|
||||
showNote("tg-note", null, "Testnachricht gesendet.");
|
||||
} catch (error) {
|
||||
showNote("tg-note", error);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById("en-save").addEventListener("click", async () => {
|
||||
try {
|
||||
await api("/einstellungen", {
|
||||
method: "PATCH",
|
||||
json: {
|
||||
energy: {
|
||||
watts: Number(document.getElementById("en-watts").value),
|
||||
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),
|
||||
waste_percent: Number(document.getElementById("en-waste").value),
|
||||
utilization: Number(document.getElementById("en-util").value),
|
||||
},
|
||||
},
|
||||
});
|
||||
showNote("en-note", null, "Energie gespeichert.");
|
||||
} catch (error) {
|
||||
showNote("en-note", error);
|
||||
}
|
||||
});
|
||||
|
||||
(function initDisplayMode() {
|
||||
const sel = document.getElementById("display-mode");
|
||||
if (!sel) return;
|
||||
try {
|
||||
sel.value = localStorage.getItem("grotti-display") === "shelf" ? "shelf" : "desktop";
|
||||
} catch (error) {
|
||||
sel.value = "desktop";
|
||||
}
|
||||
sel.addEventListener("change", () => {
|
||||
if (typeof setDisplayMode === "function") setDisplayMode(sel.value);
|
||||
else {
|
||||
try { localStorage.setItem("grotti-display", sel.value === "shelf" ? "shelf" : "desktop"); } catch (error) {}
|
||||
document.documentElement.classList.toggle("shelf", sel.value === "shelf");
|
||||
document.body.classList.toggle("shelf", sel.value === "shelf");
|
||||
}
|
||||
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 %}
|
||||
@@ -0,0 +1,113 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{ title }}</title>
|
||||
<style>
|
||||
:root { color-scheme: light; }
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
font: 14px/1.35 "Segoe UI", system-ui, sans-serif;
|
||||
color: #111;
|
||||
background: #f3f3f3;
|
||||
}
|
||||
.toolbar {
|
||||
display: flex;
|
||||
gap: 0.6rem;
|
||||
padding: 0.8rem 1rem;
|
||||
background: #1a1a1a;
|
||||
color: #eee;
|
||||
}
|
||||
.toolbar a, .toolbar button {
|
||||
color: #111;
|
||||
background: #f97316;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
padding: 0.4rem 0.85rem;
|
||||
font: inherit;
|
||||
font-weight: 650;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.toolbar a.ghost { background: transparent; color: #eee; border: 1px solid #555; }
|
||||
.sheet {
|
||||
width: 70mm;
|
||||
min-height: 40mm;
|
||||
margin: 1.2rem auto;
|
||||
padding: 3.5mm;
|
||||
background: #fff;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 22mm;
|
||||
gap: 3mm;
|
||||
align-items: center;
|
||||
}
|
||||
.meta h1 {
|
||||
margin: 0 0 2mm;
|
||||
font-size: 4.2mm;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
.meta p { margin: 0 0 1.2mm; font-size: 3.1mm; color: #333; }
|
||||
.meta .code { font-weight: 700; font-variant-numeric: tabular-nums; }
|
||||
.dots { display: inline-flex; vertical-align: middle; margin-right: 1.5mm; }
|
||||
.dot {
|
||||
width: 3.2mm;
|
||||
height: 3.2mm;
|
||||
border-radius: 50%;
|
||||
border: 0.2mm solid rgba(0,0,0,0.25);
|
||||
margin-left: -1mm;
|
||||
}
|
||||
.dot:first-child { margin-left: 0; }
|
||||
#qr { width: 20mm; height: 20mm; }
|
||||
@media print {
|
||||
body { background: #fff; }
|
||||
.toolbar { display: none; }
|
||||
.sheet {
|
||||
margin: 0;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="toolbar">
|
||||
<button type="button" onclick="window.print()">Drucken</button>
|
||||
<a class="ghost" href="/material?spool={{ spool.id }}">Zurück</a>
|
||||
</div>
|
||||
<div class="sheet">
|
||||
<div class="meta">
|
||||
<h1>{{ spool.name }}</h1>
|
||||
<p class="code">{{ spool.chargen_nr or spool.code }}</p>
|
||||
<p>
|
||||
{% if spool.colors %}
|
||||
<span class="dots">
|
||||
{% for color in spool.colors %}
|
||||
<span class="dot" style="background:{{ color.hex }}"></span>
|
||||
{% endfor %}
|
||||
</span>
|
||||
{% endif %}
|
||||
{{ spool.color or spool.material_type }}
|
||||
</p>
|
||||
<p>{{ spool.material_type }}{% if spool.brand %} · {{ spool.brand }}{% endif %}</p>
|
||||
<p>{{ '%.0f'|format(spool.remaining_g) }} g{% if spool.ace_slot %} · ACE {{ spool.ace_slot }}{% endif %}</p>
|
||||
{% if spool.hersteller_charge %}
|
||||
<p>Hersteller: {{ spool.hersteller_charge }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
<canvas id="qr" width="120" height="120"></canvas>
|
||||
</div>
|
||||
<script src="https://cdn.jsdelivr.net/npm/[email protected]/build/qrcode.min.js"></script>
|
||||
<script>
|
||||
QRCode.toCanvas(document.getElementById("qr"), {{ qr_url|tojson }}, {
|
||||
width: 120,
|
||||
margin: 1,
|
||||
color: { dark: "#111111", light: "#ffffff" }
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,112 @@
|
||||
{% extends "layout.html" %}
|
||||
{% block body %}
|
||||
<div class="page-head">
|
||||
<div>
|
||||
<h1>Ideen</h1>
|
||||
<p class="tiny">Backlog und Notizen für später.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="split">
|
||||
<section class="card table-wrap">
|
||||
<table>
|
||||
<thead><tr><th>Titel</th><th>Stand</th></tr></thead>
|
||||
<tbody id="idea-rows"></tbody>
|
||||
</table>
|
||||
</section>
|
||||
<section class="card">
|
||||
<h2 id="idea-title">Neue Idee</h2>
|
||||
<form id="idea-form">
|
||||
<input id="idea-id" type="hidden">
|
||||
<div class="field"><label for="idea-name">Titel</label><input id="idea-name" required></div>
|
||||
<div class="field"><label for="idea-body">Notiz</label><textarea id="idea-body"></textarea></div>
|
||||
<div class="field"><label for="idea-status">Stand</label>
|
||||
<select id="idea-status">
|
||||
<option value="offen">offen</option>
|
||||
<option value="spaeter">später</option>
|
||||
<option value="erledigt">erledigt</option>
|
||||
</select></div>
|
||||
<div class="actions">
|
||||
<button type="submit">Merken</button>
|
||||
<button type="button" class="ghost" id="idea-new">Neu</button>
|
||||
<button type="button" class="warn" id="idea-delete"><svg class="ico"><use href="#i-trash"/></svg>Löschen</button>
|
||||
</div>
|
||||
</form>
|
||||
<p id="idea-note"></p>
|
||||
</section>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block script %}
|
||||
<script>
|
||||
let ideas = [];
|
||||
const STATUS = { offen: "offen", spaeter: "später", erledigt: "erledigt" };
|
||||
|
||||
function fill(idea) {
|
||||
document.getElementById("idea-id").value = idea ? idea.id : "";
|
||||
document.getElementById("idea-name").value = idea ? idea.title : "";
|
||||
document.getElementById("idea-body").value = idea ? idea.body : "";
|
||||
document.getElementById("idea-status").value = idea ? idea.status : "offen";
|
||||
document.getElementById("idea-title").textContent = idea ? idea.title : "Neue Idee";
|
||||
document.getElementById("idea-delete").hidden = !idea;
|
||||
}
|
||||
|
||||
function render() {
|
||||
const body = document.getElementById("idea-rows");
|
||||
body.innerHTML = "";
|
||||
if (!ideas.length) {
|
||||
body.innerHTML = '<tr><td class="empty" colspan="2">Noch nichts notiert.</td></tr>';
|
||||
return;
|
||||
}
|
||||
ideas.forEach((idea) => {
|
||||
const tr = document.createElement("tr");
|
||||
tr.className = "pick";
|
||||
tr.innerHTML = "<td></td><td></td>";
|
||||
tr.children[0].textContent = idea.title;
|
||||
tr.children[1].textContent = STATUS[idea.status] || idea.status;
|
||||
tr.addEventListener("click", () => fill(idea));
|
||||
body.appendChild(tr);
|
||||
});
|
||||
}
|
||||
|
||||
async function load() {
|
||||
ideas = await api("/ideen");
|
||||
render();
|
||||
}
|
||||
|
||||
document.getElementById("idea-form").addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
const id = document.getElementById("idea-id").value;
|
||||
const payload = {
|
||||
title: document.getElementById("idea-name").value,
|
||||
body: document.getElementById("idea-body").value,
|
||||
status: document.getElementById("idea-status").value,
|
||||
};
|
||||
try {
|
||||
const saved = id
|
||||
? await api("/ideen/" + id, { method: "PATCH", json: payload })
|
||||
: await api("/ideen", { method: "POST", json: payload });
|
||||
document.getElementById("idea-note").textContent = "Gemerkt.";
|
||||
document.getElementById("idea-note").className = "ok";
|
||||
await load();
|
||||
fill(saved);
|
||||
} catch (error) {
|
||||
showNote("idea-note", error);
|
||||
}
|
||||
});
|
||||
document.getElementById("idea-new").addEventListener("click", () => fill(null));
|
||||
document.getElementById("idea-delete").addEventListener("click", (event) => {
|
||||
const id = document.getElementById("idea-id").value;
|
||||
if (!id) return;
|
||||
armButton(event.currentTarget, "Idee löschen?", async () => {
|
||||
try {
|
||||
await api("/ideen/" + id, { method: "DELETE" });
|
||||
toast("Idee gelöscht.");
|
||||
await load();
|
||||
fill(null);
|
||||
} catch (error) {
|
||||
showNote("idea-note", error);
|
||||
}
|
||||
});
|
||||
});
|
||||
load().then(() => fill(null)).catch((error) => showNote("idea-note", error));
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,102 @@
|
||||
{% extends "layout.html" %}
|
||||
{% block body %}
|
||||
<div class="page-head">
|
||||
<div>
|
||||
<h1>Kameras</h1>
|
||||
<p class="tiny">Livebilder aller Drucker.</p>
|
||||
</div>
|
||||
<span id="when"></span>
|
||||
</div>
|
||||
<div class="camera-grid" id="grid"></div>
|
||||
{% endblock %}
|
||||
{% block script %}
|
||||
<script>
|
||||
const labels = {
|
||||
printing: "druckt", paused: "pausiert", complete: "fertig", standby: "bereit",
|
||||
heating: "heizt", error: "Fehler", cancelled: "abgebrochen", offline: "offline"
|
||||
};
|
||||
const tiles = new Map();
|
||||
|
||||
function shownState(printer) {
|
||||
const state = printer.state || "offline";
|
||||
const hot = (printer.nozzle_target || 0) > 40 || (printer.bed_target || 0) > 30;
|
||||
if ((state === "standby" || state === "complete") && hot) return "heating";
|
||||
return state;
|
||||
}
|
||||
|
||||
function metaLine(printer) {
|
||||
const state = shownState(printer);
|
||||
const busy = state === "printing" || state === "paused";
|
||||
const bits = [labels[state] || state];
|
||||
if (busy) {
|
||||
const pct = Math.round((printer.progress || 0) * 100);
|
||||
bits.push(pct + " %");
|
||||
const eta = finishClock(printer.remain);
|
||||
if (eta) bits.push("fertig " + eta);
|
||||
else if (hours(printer.remain)) bits.push("Rest " + hours(printer.remain));
|
||||
}
|
||||
const job = (printer.filename || "").replace(/\.gcode$/i, "").replace(/^(\d+-)+/, "");
|
||||
if (busy && job) bits.push(job);
|
||||
return bits.join(" · ");
|
||||
}
|
||||
|
||||
function ensureTile(printer) {
|
||||
let tile = tiles.get(printer.host);
|
||||
if (tile) return tile;
|
||||
tile = document.createElement("figure");
|
||||
tile.className = "camera-tile";
|
||||
tile.dataset.host = printer.host;
|
||||
tile.innerHTML = `
|
||||
<figcaption>
|
||||
<span class="cam-name"></span>
|
||||
<span class="host"></span>
|
||||
</figcaption>
|
||||
<p class="cam-meta tiny"></p>
|
||||
<img alt="" data-role="cam">
|
||||
<a class="cam-url" href="#"></a>`;
|
||||
tiles.set(printer.host, tile);
|
||||
document.getElementById("grid").appendChild(tile);
|
||||
return tile;
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
const printers = await api("/drucker?live=1");
|
||||
document.getElementById("when").textContent = new Date().toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" });
|
||||
const grid = document.getElementById("grid");
|
||||
if (!printers.length) {
|
||||
tiles.clear();
|
||||
grid.innerHTML = '<p class="empty">Keine Drucker eingetragen.</p>';
|
||||
return;
|
||||
}
|
||||
if (grid.querySelector(".empty")) grid.innerHTML = "";
|
||||
const seen = new Set();
|
||||
for (const printer of printers) {
|
||||
seen.add(printer.host);
|
||||
const tile = ensureTile(printer);
|
||||
const state = shownState(printer);
|
||||
tile.className = "camera-tile " + state;
|
||||
tile.querySelector(".cam-name").textContent = printer.name;
|
||||
tile.querySelector(".host").textContent = printer.host;
|
||||
tile.querySelector(".cam-meta").textContent = metaLine(printer);
|
||||
const path = "/api/v1/kamera?host=" + encodeURIComponent(printer.host);
|
||||
const shown = "/api/v1/kamera?host=" + printer.host;
|
||||
const img = tile.querySelector('[data-role="cam"]');
|
||||
img.alt = "Kamera " + printer.name;
|
||||
img.src = withToken(path) + "&t=" + Math.floor(Date.now() / 2000);
|
||||
img.classList.remove("broken");
|
||||
img.onerror = () => img.classList.add("broken");
|
||||
const link = tile.querySelector(".cam-url");
|
||||
link.href = withToken(path);
|
||||
link.textContent = shown;
|
||||
}
|
||||
for (const [host, tile] of [...tiles.entries()]) {
|
||||
if (!seen.has(host)) {
|
||||
tile.remove();
|
||||
tiles.delete(host);
|
||||
}
|
||||
}
|
||||
}
|
||||
refresh().catch((error) => console.error(error));
|
||||
setInterval(() => refresh().catch(() => {}), 5000);
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,431 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="token" content="{{ token }}">
|
||||
<title>{{ title }} · Grotti</title>
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='grotti.css') }}?v=36">
|
||||
<script>
|
||||
(function () {
|
||||
try {
|
||||
if (!localStorage.getItem("grotti-display") && localStorage.getItem("farm-display")) {
|
||||
localStorage.setItem("grotti-display", localStorage.getItem("farm-display"));
|
||||
}
|
||||
} catch (e) {}
|
||||
try {
|
||||
var q = new URLSearchParams(location.search);
|
||||
if (q.has("shelf")) {
|
||||
var on = q.get("shelf") === "1" || q.get("shelf") === "true";
|
||||
localStorage.setItem("grotti-display", on ? "shelf" : "desktop");
|
||||
q.delete("shelf");
|
||||
var next = location.pathname + (q.toString() ? "?" + q : "") + location.hash;
|
||||
history.replaceState(null, "", next);
|
||||
}
|
||||
} catch (e) {}
|
||||
try {
|
||||
if (localStorage.getItem("grotti-display") === "shelf") document.documentElement.classList.add("shelf");
|
||||
var nav = localStorage.getItem("grotti-nav-collapsed");
|
||||
if (nav === "1" || (nav == null && localStorage.getItem("grotti-display") === "shelf")) {
|
||||
document.documentElement.classList.add("nav-collapsed");
|
||||
}
|
||||
} catch (e) {}
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
<body class="app-shell">
|
||||
<svg width="0" height="0" style="position:absolute" aria-hidden="true">
|
||||
<symbol id="i-printer" viewBox="0 0 24 24"><path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"/><path d="M6 9V3h12v6"/><rect x="6" y="14" width="12" height="8" rx="1"/></symbol>
|
||||
<symbol id="i-camera" viewBox="0 0 24 24"><path d="M14.5 4h-5L7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-3l-2.5-3z"/><circle cx="12" cy="13" r="3"/></symbol>
|
||||
<symbol id="i-file" viewBox="0 0 24 24"><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/></symbol>
|
||||
<symbol id="i-spool" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><circle cx="12" cy="12" r="3"/><path d="M12 3v3M12 18v3M3 12h3M18 12h3"/></symbol>
|
||||
<symbol id="i-folder" viewBox="0 0 24 24"><path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2z"/></symbol>
|
||||
<symbol id="i-folder-open" viewBox="0 0 24 24"><path d="m6 14 1.5-2.9A2 2 0 0 1 9.24 10H20a2 2 0 0 1 1.94 2.5l-1.54 6a2 2 0 0 1-1.95 1.5H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H18a2 2 0 0 1 2 2v2"/></symbol>
|
||||
<symbol id="i-list" viewBox="0 0 24 24"><path d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01"/></symbol>
|
||||
<symbol id="i-activity" viewBox="0 0 24 24"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></symbol>
|
||||
<symbol id="i-clock" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></symbol>
|
||||
<symbol id="i-check" viewBox="0 0 24 24"><path d="M20 6 9 17l-5-5"/></symbol>
|
||||
<symbol id="i-alert" viewBox="0 0 24 24"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"/><path d="M12 9v4M12 17h.01"/></symbol>
|
||||
<symbol id="i-flame" viewBox="0 0 24 24"><path d="M8.5 14.5A2.5 2.5 0 0 0 11 12c0-1.38-.5-2-1-3-1.072-2.143-.224-4.054 2-6 .5 2.5 2 4.9 4 6.5 2 1.6 3 3.5 3 5.5a7 7 0 1 1-14 0c0-1.153.433-2.294 1-3a2.5 2.5 0 0 0 2.5 2.5z"/></symbol>
|
||||
<symbol id="i-pause" viewBox="0 0 24 24"><rect x="14" y="4" width="4" height="16" rx="1"/><rect x="6" y="4" width="4" height="16" rx="1"/></symbol>
|
||||
<symbol id="i-play" viewBox="0 0 24 24"><path d="M5 3v18l15-9z"/></symbol>
|
||||
<symbol id="i-stop" viewBox="0 0 24 24"><rect x="5" y="5" width="14" height="14" rx="2"/></symbol>
|
||||
<symbol id="i-layers" viewBox="0 0 24 24"><path d="m12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83z"/><path d="m22 17.65-9.17 4.16a2 2 0 0 1-1.66 0L2 17.65"/><path d="m22 12.65-9.17 4.16a2 2 0 0 1-1.66 0L2 12.65"/></symbol>
|
||||
<symbol id="i-x" viewBox="0 0 24 24"><path d="M18 6 6 18M6 6l12 12"/></symbol>
|
||||
<symbol id="i-up" viewBox="0 0 24 24"><path d="m18 15-6-6-6 6"/></symbol>
|
||||
<symbol id="i-down" viewBox="0 0 24 24"><path d="m6 9 6 6 6-6"/></symbol>
|
||||
<symbol id="i-upload" viewBox="0 0 24 24"><path d="M12 15V3"/><path d="m7 8 5-5 5 5"/><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/></symbol>
|
||||
<symbol id="i-refresh" viewBox="0 0 24 24"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/><path d="M8 16H3v5"/></symbol>
|
||||
<symbol id="i-queue" viewBox="0 0 24 24"><path d="M3 5h12"/><path d="M3 12h9"/><path d="M3 19h6"/><path d="m16 11 5 3-5 3z"/></symbol>
|
||||
<symbol id="i-send" viewBox="0 0 24 24"><path d="m22 2-7 20-4-9-9-4z"/><path d="M22 2 11 13"/></symbol>
|
||||
<symbol id="i-book" viewBox="0 0 24 24"><path d="M4 19.5v-15A2.5 2.5 0 0 1 6.5 2H19a1 1 0 0 1 1 1v18a1 1 0 0 1-1 1H6.5A2.5 2.5 0 0 1 4 19.5"/><path d="M8 7h8M8 11h8"/></symbol>
|
||||
<symbol id="i-cube" viewBox="0 0 24 24"><path d="m21 8-9-5-9 5v8l9 5 9-5z"/><path d="m3.3 7.2 8.7 4.8 8.7-4.8"/><path d="M12 22V12"/></symbol>
|
||||
<symbol id="i-bell" viewBox="0 0 24 24"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/></symbol>
|
||||
<symbol id="i-sweep" viewBox="0 0 24 24"><path d="M3 21h18"/><path d="M9 8v8"/><path d="M15 8v8"/><path d="M5 12h14"/><path d="M7 16h10"/><path d="M12 3v5"/></symbol>
|
||||
<symbol id="i-thermo" viewBox="0 0 24 24"><path d="M14 4v10.54a4 4 0 1 1-4 0V4a2 2 0 0 1 4 0"/><path d="M12 14v3"/></symbol>
|
||||
<symbol id="i-settings" viewBox="0 0 24 24"><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/></symbol>
|
||||
<symbol id="i-bulb" viewBox="0 0 24 24"><path d="M15 14c.2-1 .7-1.7 1.5-2.5 1-.9 1.5-2.2 1.5-3.5A6 6 0 0 0 6 8c0 1 .2 2.2 1.5 3.5.7.7 1.3 1.5 1.5 2.5"/><path d="M9 18h6M10 22h4"/></symbol>
|
||||
<symbol id="i-search" viewBox="0 0 24 24"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></symbol>
|
||||
<symbol id="i-filter" viewBox="0 0 24 24"><path d="M22 3H2l8 9.46V19l4 2v-8.54z"/></symbol>
|
||||
<symbol id="i-info" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4M12 8h.01"/></symbol>
|
||||
<symbol id="i-scan" viewBox="0 0 24 24"><path d="M3 7V5a2 2 0 0 1 2-2h2"/><path d="M17 3h2a2 2 0 0 1 2 2v2"/><path d="M21 17v2a2 2 0 0 1-2 2h-2"/><path d="M7 21H5a2 2 0 0 1-2-2v-2"/><rect x="7" y="7" width="10" height="10" rx="1"/></symbol>
|
||||
<symbol id="i-trash" viewBox="0 0 24 24"><path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M10 11v6M14 11v6"/></symbol>
|
||||
<symbol id="i-inbox" viewBox="0 0 24 24"><path d="M22 12h-6l-2 3h-4l-2-3H2"/><path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/></symbol>
|
||||
<symbol id="i-image" viewBox="0 0 24 24"><rect width="18" height="18" x="3" y="3" rx="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/></symbol>
|
||||
<symbol id="i-grid" viewBox="0 0 24 24"><rect width="7" height="7" x="3" y="3" rx="1"/><rect width="7" height="7" x="14" y="3" rx="1"/><rect width="7" height="7" x="14" y="14" rx="1"/><rect width="7" height="7" x="3" y="14" rx="1"/></symbol>
|
||||
<symbol id="i-menu" viewBox="0 0 24 24"><path d="M4 6h16M4 12h16M4 18h16"/></symbol>
|
||||
<symbol id="i-panel" viewBox="0 0 24 24"><rect x="3" y="3" width="7" height="18" rx="1"/><path d="M14 8h7M14 12h7M14 16h7"/></symbol>
|
||||
<symbol id="i-nozzle" viewBox="0 0 24 24"><path d="M8 3h8v4l-2 3v5l-2 6-2-6v-5L8 7z"/><path d="M9 7h6"/></symbol>
|
||||
<symbol id="i-bed" viewBox="0 0 24 24"><path d="M4 16h16"/><path d="M5 16v3M19 16v3"/><path d="M6 12h12v4H6z"/><path d="M8 8h8"/></symbol>
|
||||
<symbol id="i-chamber" viewBox="0 0 24 24"><rect x="4" y="3" width="16" height="18" rx="2"/><path d="M8 8h8M8 12h8M8 16h5"/></symbol>
|
||||
<symbol id="i-fan" viewBox="0 0 24 24"><circle cx="12" cy="12" r="2"/><path d="M12 4c2 2 2 5 0 6-2-1-2-4 0-6zM12 14c2 1 2 4 0 6-2-2-2-5 0-6zM4 12c2-2 5-2 6 0-1 2-4 2-6 0zM14 12c1-2 4-2 6 0-2 2-5 2-6 0z"/></symbol>
|
||||
<symbol id="i-fan-chamber" viewBox="0 0 24 24"><circle cx="12" cy="12" r="1.6"/><path d="M12 5.5c1.4 1.4 1.4 3.5 0 4.2-1.4-.7-1.4-2.8 0-4.2zM12 14.3c1.4.7 1.4 2.8 0 4.2-1.4-1.4-1.4-3.5 0-4.2zM5.5 12c1.4-1.4 3.5-1.4 4.2 0-.7 1.4-2.8 1.4-4.2 0zM14.3 12c.7-1.4 2.8-1.4 4.2 0-1.4 1.4-3.5 1.4-4.2 0z"/><rect x="3.5" y="3.5" width="17" height="17" rx="2" fill="none"/></symbol>
|
||||
<symbol id="i-fan-aux" viewBox="0 0 24 24"><circle cx="12" cy="12" r="2"/><path d="M12 3v3M12 18v3M3 12h3M18 12h3M5.6 5.6l2.1 2.1M16.3 16.3l2.1 2.1M18.4 5.6l-2.1 2.1M7.7 16.3l-2.1 2.1"/></symbol>
|
||||
</svg>
|
||||
<aside class="sidebar">
|
||||
<div class="brand-row">
|
||||
<a class="brand" href="/">Grotti</a>
|
||||
<button type="button" class="icon-btn nav-toggle" id="nav-toggle" title="Menü einklappen" aria-label="Menü einklappen" aria-pressed="false"><svg class="ico"><use href="#i-panel"/></svg></button>
|
||||
</div>
|
||||
<nav class="nav-main" aria-label="Hauptmenü">
|
||||
<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-send"/></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="/aktivitaet" class="{{ 'on' if page == 'aktivitaet' else '' }} nav-desk" title="Aktivität"><svg class="ico"><use href="#i-activity"/></svg><span>Aktivität</span></a>
|
||||
<a href="/warteschlange" class="{{ 'on' if page == 'warteschlange' else '' }}" title="Warteschlange"><svg class="ico"><use href="#i-queue"/></svg><span>Warteschlange</span></a>
|
||||
<a href="/protokoll" class="{{ 'on' if page == 'protokoll' else '' }} nav-desk" title="Protokoll"><svg class="ico"><use href="#i-list"/></svg><span>Protokoll</span></a>
|
||||
<a href="/material" class="{{ 'on' if page == 'material' else '' }} nav-desk" title="Material"><svg class="ico"><use href="#i-spool"/></svg><span>Material</span></a>
|
||||
<a href="/projekte" class="{{ 'on' if page == 'projekte' else '' }} nav-desk" title="Projekte"><svg class="ico"><use href="#i-folder"/></svg><span>Projekte</span></a>
|
||||
</nav>
|
||||
<div class="live-panel" id="live-panel">
|
||||
<details class="queue-box" data-box="run" id="run-box" open hidden>
|
||||
<summary class="queue-title"><svg class="ico caret"><use href="#i-down"/></svg><span class="grow">Läuft jetzt</span><span class="count hot" id="run-count">0</span></summary>
|
||||
<ul class="queue-list" id="run-list"></ul>
|
||||
</details>
|
||||
<details class="queue-box" data-box="wait" id="wait-box" open>
|
||||
<summary class="queue-title"><svg class="ico caret"><use href="#i-down"/></svg><span class="grow">Wartet</span><span class="count" id="wait-count">0</span></summary>
|
||||
<ul class="queue-list" id="wait-list"></ul>
|
||||
<div class="queue-empty" id="wait-empty"><svg class="ico"><use href="#i-inbox"/></svg><span>Leer</span></div>
|
||||
</details>
|
||||
<details class="queue-box" data-box="todo" id="todo-box" open hidden>
|
||||
<summary class="queue-title"><svg class="ico caret"><use href="#i-down"/></svg><span class="grow">Aufgaben</span><span class="count todo" id="todo-count">0</span></summary>
|
||||
<ul class="queue-list" id="todo-list"></ul>
|
||||
</details>
|
||||
<details class="queue-box" data-box="send" id="send-box" open hidden>
|
||||
<summary class="queue-title"><svg class="ico caret"><use href="#i-down"/></svg><span class="grow">Senden</span><span class="count" id="send-count">0</span></summary>
|
||||
<ul class="queue-list" id="send-list"></ul>
|
||||
</details>
|
||||
<details class="queue-box" data-box="alerts" id="alert-box" open hidden>
|
||||
<summary class="queue-title"><svg class="ico caret"><use href="#i-down"/></svg><span class="grow">Alerts</span><span class="count bad" id="alert-count">0</span></summary>
|
||||
<ul class="queue-list" id="alert-list"></ul>
|
||||
<div class="queue-foot"><button type="button" class="ghost tiny-btn" id="events-clear">gelesen</button> <a href="/aktivitaet">alle →</a></div>
|
||||
</details>
|
||||
</div>
|
||||
<nav class="nav-secondary" aria-label="Weitere Seiten">
|
||||
<a href="/einstellungen" class="{{ 'on' if page == 'einstellungen' else '' }}" title="Einstellungen"><svg class="ico"><use href="#i-settings"/></svg><span>Einstellungen</span></a>
|
||||
<a href="/ideen" class="{{ 'on' if page == 'ideen' else '' }}" title="Ideen"><svg class="ico"><use href="#i-bulb"/></svg><span>Ideen</span></a>
|
||||
<a href="/doku" class="{{ 'on' if page == 'doku' else '' }}" title="Doku"><svg class="ico"><use href="#i-book"/></svg><span>Doku</span></a>
|
||||
</nav>
|
||||
<div class="side-foot">
|
||||
<span class="side-copy">© by <a href="mailto:[email protected]">[email protected]</a></span>
|
||||
<a href="/doku/changelog" title="Changelog">v{{ version }}</a>
|
||||
</div>
|
||||
</aside>
|
||||
<div class="app-main">
|
||||
<main>
|
||||
{% block body %}{% endblock %}
|
||||
</main>
|
||||
</div>
|
||||
<nav class="mobile-nav" aria-label="Schnellmenü">
|
||||
<a href="/" class="{{ 'on' if page == 'wand' else '' }}"><svg class="ico"><use href="#i-printer"/></svg><span>Drucker</span></a>
|
||||
<a href="/dateien" class="{{ 'on' if page == 'dateien' else '' }} nav-desk"><svg class="ico"><use href="#i-send"/></svg><span>Dateien</span></a>
|
||||
<a href="/kameras" class="{{ 'on' if page == 'kameras' else '' }} nav-shelf"><svg class="ico"><use href="#i-camera"/></svg><span>Kameras</span></a>
|
||||
<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=23"></script>
|
||||
<script>
|
||||
(function applyShellClasses() {
|
||||
const shelf = document.documentElement.classList.contains("shelf");
|
||||
const collapsed = document.documentElement.classList.contains("nav-collapsed");
|
||||
document.body.classList.toggle("shelf", shelf);
|
||||
document.body.classList.toggle("nav-collapsed", collapsed);
|
||||
const toggle = document.getElementById("nav-toggle");
|
||||
if (toggle) {
|
||||
toggle.setAttribute("aria-pressed", collapsed ? "true" : "false");
|
||||
toggle.title = collapsed ? "Menü ausklappen" : "Menü einklappen";
|
||||
}
|
||||
})();
|
||||
|
||||
const LIVE_KEY = "grotti-live-closed";
|
||||
const NAV_KEY = "grotti-nav-collapsed";
|
||||
const DISPLAY_KEY = "grotti-display";
|
||||
|
||||
function setNavCollapsed(on) {
|
||||
document.documentElement.classList.toggle("nav-collapsed", on);
|
||||
document.body.classList.toggle("nav-collapsed", on);
|
||||
try { localStorage.setItem(NAV_KEY, on ? "1" : "0"); } catch (error) {}
|
||||
const toggle = document.getElementById("nav-toggle");
|
||||
if (toggle) {
|
||||
toggle.setAttribute("aria-pressed", on ? "true" : "false");
|
||||
toggle.title = on ? "Menü ausklappen" : "Menü einklappen";
|
||||
}
|
||||
}
|
||||
|
||||
function setDisplayMode(mode) {
|
||||
const shelf = mode === "shelf";
|
||||
document.documentElement.classList.toggle("shelf", shelf);
|
||||
document.body.classList.toggle("shelf", shelf);
|
||||
try { localStorage.setItem(DISPLAY_KEY, shelf ? "shelf" : "desktop"); } catch (error) {}
|
||||
if (shelf && localStorage.getItem(NAV_KEY) == null) setNavCollapsed(true);
|
||||
}
|
||||
|
||||
document.getElementById("nav-toggle")?.addEventListener("click", () => {
|
||||
setNavCollapsed(!document.body.classList.contains("nav-collapsed"));
|
||||
});
|
||||
|
||||
(function restoreBoxes() {
|
||||
let closed = [];
|
||||
try { closed = JSON.parse(localStorage.getItem(LIVE_KEY) || "[]"); } catch (error) { closed = []; }
|
||||
if (window.matchMedia("(max-width: 760px)").matches && !localStorage.getItem(LIVE_KEY)) closed = ["wait", "send"];
|
||||
document.querySelectorAll("#live-panel details[data-box]").forEach((box) => {
|
||||
if (closed.includes(box.dataset.box)) box.open = false;
|
||||
box.addEventListener("toggle", () => {
|
||||
const now = [...document.querySelectorAll("#live-panel details[data-box]")].filter((d) => !d.open).map((d) => d.dataset.box);
|
||||
localStorage.setItem(LIVE_KEY, JSON.stringify(now));
|
||||
});
|
||||
});
|
||||
})();
|
||||
|
||||
function queueTarget(job) {
|
||||
return job.printer_name || job.host || (job.family_label ? "nächster " + job.family_label : "nächster freier");
|
||||
}
|
||||
|
||||
async function cancelQueueItem(job, button) {
|
||||
const running = job.status === "running";
|
||||
const armed = running
|
||||
? (job.is_ablauf ? "Ablauf stoppen?" : "Auftrag stoppen?")
|
||||
: "Entfernen?";
|
||||
const go = async () => {
|
||||
if (button) button.disabled = true;
|
||||
try {
|
||||
await api("/druck-queue/" + job.id, { method: "PATCH", json: { status: "cancelled" } });
|
||||
toast(running ? "Abgebrochen." : "Aus der Warteschlange genommen.");
|
||||
} catch (error) {
|
||||
toast(error.message, "bad");
|
||||
}
|
||||
refreshPrintQueue();
|
||||
if (typeof window.onQueueChanged === "function") window.onQueueChanged();
|
||||
if (button) button.disabled = false;
|
||||
};
|
||||
if (button) armButton(button, armed, go);
|
||||
else go();
|
||||
}
|
||||
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>
|
||||
</li>`;
|
||||
}
|
||||
|
||||
function waitItemHtml(job, index, total) {
|
||||
const isError = job.status === "error";
|
||||
const label = job.is_ablauf ? "Ablauf · " + jobLabel(job.filename) : jobLabel(job.filename);
|
||||
const rep = job.is_ablauf && (job.repeat_total || 1) > 1 ? job.repeat_total + "×" : "";
|
||||
return `<li class="queue-item ${isError ? "error" : ""}">
|
||||
<div class="queue-row">
|
||||
<span class="grow"><span class="queue-name" title="${escapeHtml(job.filename)}">${escapeHtml(label)}</span></span>
|
||||
<span class="queue-tools">
|
||||
${!isError && index > 0 ? `<button type="button" class="icon-btn" data-prio-q="${job.id}" data-dir="-1" title="Höher">${icon("up")}</button>` : ""}
|
||||
${!isError && index < total - 1 ? `<button type="button" class="icon-btn" data-prio-q="${job.id}" data-dir="1" title="Tiefer">${icon("down")}</button>` : ""}
|
||||
${isError && job.is_ablauf ? `<button type="button" class="icon-btn" data-resume-q="${job.id}" title="Ab Schritt ${(Number(job.step_index) || 0) + 1} (${escapeHtml(job.step_label_current || "")}) weitermachen">${icon("play")}</button>` : ""}
|
||||
<button type="button" class="icon-btn danger" ${isError ? `data-dismiss-q="${job.id}" title="Ausblenden"` : `data-cancel-q="${job.id}" title="Entfernen"`}>${icon("x")}</button>
|
||||
</span>
|
||||
</div>
|
||||
<span class="queue-sub">${isError ? "fehlgeschlagen · " : "→ "}${escapeHtml(queueTarget(job))}${rep ? " · " + rep : ""}</span>
|
||||
${isError ? `<span class="queue-msg">${escapeHtml(job.message || "")}</span>` : ""}
|
||||
</li>`;
|
||||
}
|
||||
|
||||
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>`;
|
||||
}
|
||||
|
||||
let queueCache = [];
|
||||
async function refreshPrintQueue() {
|
||||
let jobs;
|
||||
let active = [];
|
||||
try {
|
||||
[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 queuedHosts = new Set(running.map((job) => job.host));
|
||||
const direct = (active || []).filter((p) => !queuedHosts.has(p.host));
|
||||
const waiting = queueCache.filter((job) => job.status === "waiting");
|
||||
const errors = queueCache
|
||||
.filter((job) => job.status === "error")
|
||||
.sort((a, b) => String(b.updated_at).localeCompare(String(a.updated_at)))
|
||||
.slice(0, 2);
|
||||
|
||||
const runBox = document.getElementById("run-box");
|
||||
runBox.hidden = !(running.length || direct.length);
|
||||
document.getElementById("run-count").textContent = running.length + direct.length;
|
||||
document.getElementById("run-list").innerHTML = running.map(runItemHtml).join("") + direct.map(directItemHtml).join("");
|
||||
|
||||
const waitList = document.getElementById("wait-list");
|
||||
document.getElementById("wait-count").textContent = waiting.length;
|
||||
document.getElementById("wait-count").className = "count" + (errors.length ? " bad" : waiting.length ? " hot" : "");
|
||||
document.getElementById("wait-empty").hidden = !!(waiting.length || errors.length);
|
||||
document.getElementById("wait-box").classList.toggle("is-empty", !(waiting.length || errors.length));
|
||||
waitList.innerHTML = errors.map((job) => waitItemHtml(job, 0, 1)).join("")
|
||||
+ waiting.map((job, i) => waitItemHtml(job, i, waiting.length)).join("");
|
||||
|
||||
const panel = document.getElementById("live-panel");
|
||||
panel.querySelectorAll("[data-cancel-q]").forEach((btn) => {
|
||||
btn.onclick = () => {
|
||||
const job = queueCache.find((item) => item.id === Number(btn.dataset.cancelQ));
|
||||
if (job) cancelQueueItem(job, btn);
|
||||
};
|
||||
});
|
||||
panel.querySelectorAll("[data-resume-q]").forEach((btn) => {
|
||||
btn.onclick = () => {
|
||||
const job = queueCache.find((item) => item.id === Number(btn.dataset.resumeQ));
|
||||
if (!job) return;
|
||||
const step = job.step_label_current || ("Schritt " + ((job.step_index || 0) + 1));
|
||||
armButton(btn, "Weiter ab „" + step + "“?", async () => {
|
||||
btn.disabled = true;
|
||||
try {
|
||||
await api("/druck-queue/" + job.id, { method: "PATCH", json: { status: "running", resume: true } });
|
||||
toast("Ablauf geht weiter.");
|
||||
} catch (error) { toast(error.message, "bad"); }
|
||||
refreshPrintQueue();
|
||||
if (typeof window.onQueueChanged === "function") window.onQueueChanged();
|
||||
btn.disabled = false;
|
||||
});
|
||||
};
|
||||
});
|
||||
panel.querySelectorAll("[data-dismiss-q]").forEach((btn) => {
|
||||
btn.onclick = () => armButton(btn, "Ausblenden?", async () => {
|
||||
try { await api("/druck-queue/" + btn.dataset.dismissQ, { method: "PATCH", json: { status: "dismissed" } }); } catch (error) { toast(error.message, "bad"); }
|
||||
refreshPrintQueue();
|
||||
});
|
||||
});
|
||||
panel.querySelectorAll("[data-prio-q]").forEach((btn) => {
|
||||
btn.onclick = async () => {
|
||||
const id = Number(btn.dataset.prioQ);
|
||||
const dir = Number(btn.dataset.dir);
|
||||
const idx = waiting.findIndex((job) => job.id === id);
|
||||
const other = waiting[idx + dir];
|
||||
if (!other) return;
|
||||
try {
|
||||
await api("/druck-queue/" + id, { method: "PATCH", json: { priority: Number(other.priority) + dir } });
|
||||
} catch (error) { toast(error.message, "bad"); }
|
||||
refreshPrintQueue();
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function refreshSendQueue() {
|
||||
let jobs;
|
||||
try { jobs = await api("/sende-auftraege"); } catch (error) { return; }
|
||||
const active = (jobs || []).filter((job) => job.status === "pending" || job.status === "running");
|
||||
const recent = active.length ? active : (jobs || []).slice(0, 2).filter((job) => job.status === "error");
|
||||
const box = document.getElementById("send-box");
|
||||
box.hidden = !recent.length;
|
||||
document.getElementById("send-count").textContent = active.length;
|
||||
document.getElementById("send-list").innerHTML = recent.map((job) => {
|
||||
const pct = Math.max(0, Math.min(100, Number(job.progress) || 0));
|
||||
const cls = job.status === "error" ? "bad" : job.status === "done" ? "good" : "";
|
||||
return `<li class="queue-item ${job.status === "error" ? "error" : ""}">
|
||||
<span class="queue-name">${escapeHtml(job.printer || "")}</span>
|
||||
<span class="queue-msg">${escapeHtml(job.message || "")}</span>
|
||||
<span class="send-progress-track slim"><span class="send-progress-bar ${cls}" style="width:${pct}%"></span></span>
|
||||
</li>`;
|
||||
}).join("");
|
||||
}
|
||||
|
||||
async function refreshEvents() {
|
||||
let data;
|
||||
try { data = await api("/ereignisse?unread=1&limit=5"); } catch (error) { return; }
|
||||
const items = data.items || [];
|
||||
const unread = Number(data.unread != null ? data.unread : items.length);
|
||||
const box = document.getElementById("alert-box");
|
||||
box.hidden = !items.length;
|
||||
document.getElementById("alert-count").textContent = unread;
|
||||
document.getElementById("alert-list").innerHTML = items.map((item) => `<li class="queue-item">
|
||||
<div class="queue-row"><span class="grow"><span class="queue-name">${escapeHtml(item.printer || item.kind)}</span></span><span class="queue-sub">${escapeHtml(clockOf(item.created_at))}</span></div>
|
||||
<span class="queue-msg">${escapeHtml(item.message)}</span>
|
||||
</li>`).join("");
|
||||
}
|
||||
|
||||
async function refreshTasks() {
|
||||
let items;
|
||||
try { items = await api("/aufgaben"); } catch (error) { return; }
|
||||
items = items || [];
|
||||
document.getElementById("todo-box").hidden = !items.length;
|
||||
document.getElementById("todo-count").textContent = items.length;
|
||||
const list = document.getElementById("todo-list");
|
||||
list.innerHTML = items.map((p) => `<li class="queue-item">
|
||||
<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-tools"><button type="button" class="icon-btn done-btn" data-cleared="${escapeHtml(p.host)}" title="Platte geräumt (zweimal klicken)" aria-label="Platte geräumt">${icon("check")}</button></span>
|
||||
</div>
|
||||
<span class="queue-msg">Platte räumen${p.state === "complete" ? " · Druck fertig" : ""}</span>
|
||||
${p.filename ? `<span class="queue-sub" title="${escapeHtml(p.filename)}">${escapeHtml(jobLabel(p.filename))}</span>` : ""}
|
||||
</li>`).join("");
|
||||
list.querySelectorAll("[data-cleared]").forEach((btn) => {
|
||||
btn.onclick = () => armButton(btn, "leer?", async () => {
|
||||
btn.disabled = true;
|
||||
try {
|
||||
await api("/drucker/" + encodeURIComponent(btn.dataset.cleared) + "/abraeumen", { method: "POST", json: { confirm: true } });
|
||||
toast("Platte frei.");
|
||||
} catch (error) { toast(error.message, "bad"); }
|
||||
refreshTasks();
|
||||
if (typeof window.onQueueChanged === "function") window.onQueueChanged();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
document.getElementById("events-clear").addEventListener("click", async (event) => {
|
||||
event.preventDefault();
|
||||
try { await api("/ereignisse/gelesen", { method: "POST", json: {} }); } catch (error) { /* still */ }
|
||||
refreshEvents();
|
||||
if (typeof window.onEventsRead === "function") window.onEventsRead();
|
||||
});
|
||||
|
||||
window.refreshPrintQueue = refreshPrintQueue;
|
||||
window.refreshSendQueue = refreshSendQueue;
|
||||
refreshSendQueue();
|
||||
refreshPrintQueue();
|
||||
refreshEvents();
|
||||
refreshTasks();
|
||||
window.refreshTasks = refreshTasks;
|
||||
setInterval(refreshTasks, 8000);
|
||||
setInterval(refreshSendQueue, 2000);
|
||||
setInterval(refreshPrintQueue, 4000);
|
||||
setInterval(refreshEvents, 8000);
|
||||
</script>
|
||||
{% block script %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,838 @@
|
||||
{% extends "layout.html" %}
|
||||
{% block body %}
|
||||
<div class="page-head">
|
||||
<div>
|
||||
<h1>Material</h1>
|
||||
<p class="tiny">Spulen am Drucker und im Regal. Sorten sind die Profile dazu.</p>
|
||||
</div>
|
||||
<div class="page-head-side">
|
||||
<label class="scan-inline" for="scan-code">
|
||||
<svg class="ico" aria-hidden="true"><use href="#i-scan"/></svg>
|
||||
<input id="scan-code" autocomplete="off" placeholder="Code oder Charge · Enter" title="USB-Scanner oder Charge tippen">
|
||||
</label>
|
||||
<p id="scan-note" class="scan-note" hidden></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tabs mat-tabs" role="tablist">
|
||||
<button type="button" class="on" data-view="spools" role="tab"><svg class="ico"><use href="#i-spool"/></svg>Spulen</button>
|
||||
<button type="button" data-view="sorten" role="tab"><svg class="ico"><use href="#i-layers"/></svg>Sorten</button>
|
||||
</div>
|
||||
|
||||
<div id="view-spools">
|
||||
<div class="page-tools mat-tools">
|
||||
<div class="filter-bar" id="spool-filter" role="group" aria-label="Ort">
|
||||
<button type="button" class="fchip on" data-filter="all">Alle</button>
|
||||
<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>
|
||||
</div>
|
||||
<input id="spool-search" type="search" placeholder="Spule suchen…" autocomplete="off">
|
||||
<button type="button" id="spool-new"><svg class="ico"><use href="#i-spool"/></svg>Neue Spule</button>
|
||||
</div>
|
||||
<p id="spool-list-note" class="tiny"></p>
|
||||
|
||||
<div class="split mat-split is-idle" id="spool-layout">
|
||||
<section class="card table-wrap mat-list">
|
||||
<div class="lib-head">
|
||||
<h2>Spulen</h2>
|
||||
<span class="count" id="spool-count">0</span>
|
||||
</div>
|
||||
<table>
|
||||
<thead><tr><th>Spule</th><th>Charge</th><th>Sorte</th><th>Farbe</th><th>Ort</th><th class="num">Rest</th></tr></thead>
|
||||
<tbody id="spool-rows"></tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section class="card mat-form" id="spool-panel" hidden>
|
||||
<div class="mat-form-head">
|
||||
<h2 id="spool-title">Neue Spule</h2>
|
||||
<button type="button" class="icon-btn" id="spool-close" title="Schließen" aria-label="Schließen"></button>
|
||||
</div>
|
||||
<form id="spool-form">
|
||||
<input id="spool-id" type="hidden">
|
||||
<div class="field">
|
||||
<label for="spool-material">Sorte <span class="tip" title="Profil mit Dichte, Preis und Volumen. Verwalten unter Reiter Sorten."><svg class="ico"><use href="#i-info"/></svg></span></label>
|
||||
<select id="spool-material"></select>
|
||||
<p id="spool-hint" class="field-help"></p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="spool-name">Name</label>
|
||||
<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>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="spool-slot">ACE-Platz <span class="tip" title="ACE 1 = Plätze 1–4, ACE 2 = 5–8. Leer = externe Spule am Drucker. Ohne Drucker = Regal."><svg class="ico"><use href="#i-info"/></svg></span></label>
|
||||
<select id="spool-slot">
|
||||
<option value="">nicht im ACE</option>
|
||||
<option value="1">ACE 1 · Platz 1</option>
|
||||
<option value="2">ACE 1 · Platz 2</option>
|
||||
<option value="3">ACE 1 · Platz 3</option>
|
||||
<option value="4">ACE 1 · Platz 4</option>
|
||||
<option value="5">ACE 2 · Platz 1</option>
|
||||
<option value="6">ACE 2 · Platz 2</option>
|
||||
<option value="7">ACE 2 · Platz 3</option>
|
||||
<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>
|
||||
<div class="actions">
|
||||
<button type="submit">Merken</button>
|
||||
<button type="button" class="ghost" id="spool-shelf" hidden>Ins Regal</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>
|
||||
</form>
|
||||
<p id="spool-note"></p>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="view-sorten" hidden>
|
||||
<div class="page-tools mat-tools">
|
||||
<input id="sorten-search" type="search" placeholder="Sorte suchen…" autocomplete="off">
|
||||
<button type="button" id="sorten-new"><svg class="ico"><use href="#i-layers"/></svg>Neue Sorte</button>
|
||||
</div>
|
||||
<p id="sorten-list-note" class="tiny"></p>
|
||||
|
||||
<div class="split mat-split is-idle" id="sorten-layout">
|
||||
<section class="card table-wrap mat-list">
|
||||
<div class="lib-head">
|
||||
<h2>Sorten</h2>
|
||||
<span class="count" id="sorten-count">0</span>
|
||||
</div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Sorte</th>
|
||||
<th>Typ</th>
|
||||
<th>Drucker</th>
|
||||
<th class="num">Dichte</th>
|
||||
<th class="num">mm³/s</th>
|
||||
<th class="num">€/kg</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="sorten-rows"></tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section class="card mat-form" id="sorten-panel" hidden>
|
||||
<div class="mat-form-head">
|
||||
<h2 id="sorten-title">Neue Sorte</h2>
|
||||
<button type="button" class="icon-btn" id="sorten-close" title="Schließen"></button>
|
||||
</div>
|
||||
<form id="sorten-form">
|
||||
<input id="sorten-id" type="hidden">
|
||||
<div class="field">
|
||||
<label for="sorten-name">Name</label>
|
||||
<input id="sorten-name" required placeholder="z. B. Sunlu PLA+">
|
||||
</div>
|
||||
<div class="grid-2">
|
||||
<div class="field"><label for="sorten-brand">Marke</label><input id="sorten-brand"></div>
|
||||
<div class="field"><label for="sorten-type">Typ</label><input id="sorten-type" placeholder="PLA"></div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="sorten-machines">Maschinen</label>
|
||||
<select id="sorten-machines">
|
||||
<option value="beide">beide</option>
|
||||
<option value="kobra_3">nur Kobra 3</option>
|
||||
<option value="kobra_s1">nur Kobra S1</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="grid-2">
|
||||
<div class="field"><label for="sorten-density">Dichte g/cm³</label><input id="sorten-density" type="number" min="0.1" step="0.01" value="1.24"></div>
|
||||
<div class="field"><label for="sorten-price">€ pro kg</label><input id="sorten-price" type="number" min="0" step="0.01" value="0"></div>
|
||||
<div class="field"><label for="sorten-vol">Volumen mm³/s</label><input id="sorten-vol" type="number" min="0.1" step="0.1" value="12"></div>
|
||||
<div class="field"><label for="sorten-vol-s1">Volumen S1 <span class="tip" title="Leer = wie oben. Nur setzen, wenn S1 anders ist."><svg class="ico"><use href="#i-info"/></svg></span></label><input id="sorten-vol-s1" type="number" min="0.1" step="0.1" placeholder="wie oben"></div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="sorten-note">Notiz</label>
|
||||
<input id="sorten-note" placeholder="optional">
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button type="submit">Merken</button>
|
||||
<button type="button" class="ghost" id="sorten-use">Spule damit anlegen</button>
|
||||
<button type="button" class="warn" id="sorten-delete" hidden><svg class="ico"><use href="#i-trash"/></svg>Löschen</button>
|
||||
</div>
|
||||
</form>
|
||||
<p id="sorten-note-msg"></p>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block script %}
|
||||
<script>
|
||||
let spools = [];
|
||||
let printers = [];
|
||||
let materials = [];
|
||||
let palette = { solids: [], multi: [] };
|
||||
let picked = [];
|
||||
let colorLabel = "";
|
||||
let autoName = "";
|
||||
let spoolFilter = "all";
|
||||
let spoolSearch = "";
|
||||
let sortenSearch = "";
|
||||
let view = "spools";
|
||||
let spoolPanelOpen = false;
|
||||
let sortenPanelOpen = false;
|
||||
|
||||
document.getElementById("spool-close").innerHTML = icon("x");
|
||||
document.getElementById("sorten-close").innerHTML = icon("x");
|
||||
|
||||
function dots(colors) {
|
||||
const wrap = document.createElement("span");
|
||||
wrap.className = "dots";
|
||||
(colors || []).forEach((color) => {
|
||||
const dot = document.createElement("span");
|
||||
dot.className = "dot";
|
||||
dot.style.background = color.hex;
|
||||
dot.title = color.name;
|
||||
wrap.appendChild(dot);
|
||||
});
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function slotLabel(slot) {
|
||||
if (!slot) return "";
|
||||
if (slot <= 4) return "ACE 1 · " + slot;
|
||||
return "ACE 2 · " + (slot - 4);
|
||||
}
|
||||
|
||||
function printerName(host) {
|
||||
return (printers.find((p) => p.host === host) || {}).name || host || "";
|
||||
}
|
||||
|
||||
function ortLabel(spool) {
|
||||
if (!spool.printer_host) return "Regal";
|
||||
const name = printerName(spool.printer_host);
|
||||
if (spool.ace_slot) return name + " · " + slotLabel(spool.ace_slot);
|
||||
return name + " · extern";
|
||||
}
|
||||
|
||||
function selectedMaterial() {
|
||||
const id = document.getElementById("spool-material").value;
|
||||
return materials.find((row) => String(row.id) === id) || null;
|
||||
}
|
||||
|
||||
function volumeOf(material) {
|
||||
const host = document.getElementById("spool-printer").value;
|
||||
const printer = printers.find((row) => row.host === host);
|
||||
if (printer && printer.family === "kobra_s1" && material.volumetric_s1 != null) return material.volumetric_s1;
|
||||
return material.volumetric;
|
||||
}
|
||||
|
||||
function showHint() {
|
||||
const material = selectedMaterial();
|
||||
const node = document.getElementById("spool-hint");
|
||||
if (!material) {
|
||||
node.textContent = "";
|
||||
return;
|
||||
}
|
||||
const host = document.getElementById("spool-printer").value;
|
||||
const printer = printers.find((row) => row.host === host);
|
||||
const parts = [];
|
||||
if (printer && material.machines !== "beide" && material.machines !== printer.family) {
|
||||
parts.push("Diese Sorte läuft bei dir " + material.machines_label + ".");
|
||||
} else if (material.machines !== "beide") {
|
||||
parts.push(material.machines_label.charAt(0).toUpperCase() + material.machines_label.slice(1) + ".");
|
||||
}
|
||||
if (material.note) parts.push(material.note);
|
||||
node.textContent = parts.join(" ");
|
||||
}
|
||||
|
||||
function applyMaterial(material, rename) {
|
||||
if (!material) return;
|
||||
document.getElementById("spool-material").value = String(material.id);
|
||||
document.getElementById("spool-brand").value = material.brand;
|
||||
document.getElementById("spool-type").value = material.material_type;
|
||||
document.getElementById("spool-density").value = material.density;
|
||||
document.getElementById("spool-price").value = material.price_per_kg;
|
||||
document.getElementById("spool-vol").value = volumeOf(material);
|
||||
const name = document.getElementById("spool-name");
|
||||
if (rename && (!name.value || name.value === autoName)) {
|
||||
autoName = material.name;
|
||||
name.value = material.name;
|
||||
}
|
||||
showHint();
|
||||
}
|
||||
|
||||
function colorSummary() {
|
||||
if (!picked.length) return "noch keine";
|
||||
return colorLabel || picked.map((c) => c.name).join(" / ");
|
||||
}
|
||||
|
||||
function renderPicked() {
|
||||
const node = document.getElementById("color-picked");
|
||||
node.innerHTML = "";
|
||||
const sum = document.getElementById("color-sum");
|
||||
if (!picked.length) {
|
||||
node.textContent = "Farbe tippen — einfarbig oder Mehrton.";
|
||||
sum.textContent = "noch keine";
|
||||
return;
|
||||
}
|
||||
node.appendChild(dots(picked));
|
||||
const label = document.createElement("span");
|
||||
label.textContent = colorLabel || picked.map((color) => color.name).join(" / ");
|
||||
node.appendChild(label);
|
||||
sum.textContent = colorSummary();
|
||||
}
|
||||
|
||||
function renderPalette() {
|
||||
const mix = document.getElementById("color-mix").checked;
|
||||
const solids = document.getElementById("color-solids");
|
||||
const multi = document.getElementById("color-multi");
|
||||
solids.innerHTML = "";
|
||||
multi.innerHTML = "";
|
||||
palette.solids.forEach((item) => {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "swatch";
|
||||
const on = mix
|
||||
? picked.some((color) => color.hex === item.colors[0].hex)
|
||||
: picked.length === 1 && picked[0].hex === item.colors[0].hex && colorLabel === item.name;
|
||||
if (on) button.classList.add("on");
|
||||
button.appendChild(dots(item.colors));
|
||||
button.appendChild(document.createTextNode(item.name));
|
||||
button.addEventListener("click", () => chooseSolid(item));
|
||||
solids.appendChild(button);
|
||||
});
|
||||
palette.multi.forEach((item) => {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "swatch";
|
||||
if (colorLabel === item.name) button.classList.add("on");
|
||||
button.appendChild(dots(item.colors));
|
||||
button.appendChild(document.createTextNode(item.name));
|
||||
button.addEventListener("click", () => chooseMulti(item));
|
||||
multi.appendChild(button);
|
||||
});
|
||||
renderPicked();
|
||||
}
|
||||
|
||||
function chooseSolid(item) {
|
||||
const color = item.colors[0];
|
||||
if (document.getElementById("color-mix").checked) {
|
||||
const index = picked.findIndex((row) => row.hex === color.hex);
|
||||
if (index >= 0) picked.splice(index, 1);
|
||||
else if (picked.length < 8) picked.push(color);
|
||||
colorLabel = picked.map((row) => row.name).join(" / ");
|
||||
} else {
|
||||
picked = [color];
|
||||
colorLabel = item.name;
|
||||
}
|
||||
renderPalette();
|
||||
}
|
||||
|
||||
function chooseMulti(item) {
|
||||
picked = item.colors.map((color) => ({ name: color.name, hex: color.hex }));
|
||||
colorLabel = item.name;
|
||||
document.getElementById("color-mix").checked = false;
|
||||
renderPalette();
|
||||
}
|
||||
|
||||
function setView(next) {
|
||||
view = next;
|
||||
document.querySelectorAll(".mat-tabs [data-view]").forEach((btn) => {
|
||||
btn.classList.toggle("on", btn.dataset.view === view);
|
||||
});
|
||||
document.getElementById("view-spools").hidden = view !== "spools";
|
||||
document.getElementById("view-sorten").hidden = view !== "sorten";
|
||||
}
|
||||
|
||||
function openSpoolPanel() {
|
||||
spoolPanelOpen = true;
|
||||
document.getElementById("spool-panel").hidden = false;
|
||||
document.getElementById("spool-layout").classList.remove("is-idle");
|
||||
}
|
||||
|
||||
function closeSpoolPanel() {
|
||||
spoolPanelOpen = false;
|
||||
document.getElementById("spool-panel").hidden = true;
|
||||
document.getElementById("spool-layout").classList.add("is-idle");
|
||||
document.getElementById("spool-id").value = "";
|
||||
document.getElementById("spool-note").textContent = "";
|
||||
renderSpools();
|
||||
}
|
||||
|
||||
function openSortenPanel() {
|
||||
sortenPanelOpen = true;
|
||||
document.getElementById("sorten-panel").hidden = false;
|
||||
document.getElementById("sorten-layout").classList.remove("is-idle");
|
||||
}
|
||||
|
||||
function closeSortenPanel() {
|
||||
sortenPanelOpen = false;
|
||||
document.getElementById("sorten-panel").hidden = true;
|
||||
document.getElementById("sorten-layout").classList.add("is-idle");
|
||||
document.getElementById("sorten-id").value = "";
|
||||
document.getElementById("sorten-note-msg").textContent = "";
|
||||
renderSorten();
|
||||
}
|
||||
|
||||
function payload() {
|
||||
return {
|
||||
name: document.getElementById("spool-name").value,
|
||||
brand: document.getElementById("spool-brand").value,
|
||||
material_type: document.getElementById("spool-type").value,
|
||||
material_id: document.getElementById("spool-material").value || null,
|
||||
hersteller_charge: document.getElementById("spool-hersteller").value,
|
||||
color: colorLabel,
|
||||
colors: picked,
|
||||
printer_host: document.getElementById("spool-printer").value,
|
||||
ace_slot: document.getElementById("spool-slot").value || null,
|
||||
price_per_kg: Number(document.getElementById("spool-price").value),
|
||||
remaining_g: Number(document.getElementById("spool-left").value),
|
||||
density: Number(document.getElementById("spool-density").value),
|
||||
volumetric: Number(document.getElementById("spool-vol").value),
|
||||
};
|
||||
}
|
||||
|
||||
function fill(spool) {
|
||||
openSpoolPanel();
|
||||
document.getElementById("spool-id").value = spool ? spool.id : "";
|
||||
document.getElementById("spool-name").value = spool ? spool.name : "";
|
||||
document.getElementById("spool-brand").value = spool ? spool.brand : "";
|
||||
document.getElementById("spool-type").value = spool ? spool.material_type : "";
|
||||
document.getElementById("spool-chargen").value = spool ? (spool.chargen_nr || spool.code || "") : "";
|
||||
document.getElementById("spool-hersteller").value = spool ? (spool.hersteller_charge || "") : "";
|
||||
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-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) : "";
|
||||
picked = spool && spool.colors ? spool.colors.map((color) => ({ name: color.name, hex: color.hex })) : [];
|
||||
colorLabel = spool ? spool.color : "";
|
||||
document.getElementById("color-mix").checked = false;
|
||||
autoName = "";
|
||||
document.getElementById("spool-title").textContent = spool ? spool.name : "Neue Spule";
|
||||
document.getElementById("spool-delete").hidden = !spool;
|
||||
document.getElementById("spool-label").hidden = !spool;
|
||||
document.getElementById("spool-shelf").hidden = !spool || !(spool.printer_host);
|
||||
document.getElementById("color-box").open = !spool || !picked.length;
|
||||
showHint();
|
||||
renderPalette();
|
||||
renderSpools();
|
||||
document.getElementById("spool-panel").scrollIntoView({ block: "nearest", behavior: "smooth" });
|
||||
}
|
||||
|
||||
function fillSorten(material) {
|
||||
openSortenPanel();
|
||||
document.getElementById("sorten-id").value = material ? material.id : "";
|
||||
document.getElementById("sorten-name").value = material ? material.name : "";
|
||||
document.getElementById("sorten-brand").value = material ? material.brand : "";
|
||||
document.getElementById("sorten-type").value = material ? material.material_type : "PLA";
|
||||
document.getElementById("sorten-machines").value = material ? material.machines : "beide";
|
||||
document.getElementById("sorten-density").value = material ? material.density : 1.24;
|
||||
document.getElementById("sorten-price").value = material ? material.price_per_kg : 0;
|
||||
document.getElementById("sorten-vol").value = material ? material.volumetric : 12;
|
||||
document.getElementById("sorten-vol-s1").value =
|
||||
material && material.volumetric_s1 != null ? material.volumetric_s1 : "";
|
||||
document.getElementById("sorten-note").value = material ? (material.note || "") : "";
|
||||
document.getElementById("sorten-title").textContent = material ? material.name : "Neue Sorte";
|
||||
document.getElementById("sorten-delete").hidden = !material;
|
||||
document.getElementById("sorten-use").hidden = !material;
|
||||
renderSorten();
|
||||
}
|
||||
|
||||
function shownSpools() {
|
||||
const q = spoolSearch.trim().toLowerCase();
|
||||
return spools.filter((spool) => {
|
||||
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 (!q) return true;
|
||||
const hay = [
|
||||
spool.name, spool.chargen_nr, spool.code, spool.material_type, spool.color,
|
||||
spool.brand, ortLabel(spool),
|
||||
].join(" ").toLowerCase();
|
||||
return hay.includes(q);
|
||||
});
|
||||
}
|
||||
|
||||
function shownSorten() {
|
||||
const q = sortenSearch.trim().toLowerCase();
|
||||
if (!q) return materials;
|
||||
return materials.filter((m) =>
|
||||
[m.name, m.brand, m.material_type, m.machines_label, m.note].join(" ").toLowerCase().includes(q)
|
||||
);
|
||||
}
|
||||
|
||||
function fillSelects() {
|
||||
const select = document.getElementById("spool-printer");
|
||||
const current = select.value;
|
||||
select.innerHTML = '<option value="">Regal</option>';
|
||||
printers.forEach((printer) => {
|
||||
const option = document.createElement("option");
|
||||
option.value = printer.host;
|
||||
option.textContent = printer.name;
|
||||
select.appendChild(option);
|
||||
});
|
||||
select.value = current;
|
||||
|
||||
const materialSelect = document.getElementById("spool-material");
|
||||
const currentMaterial = materialSelect.value;
|
||||
materialSelect.innerHTML = '<option value="">Sorte wählen</option>';
|
||||
materials.forEach((material) => {
|
||||
const option = document.createElement("option");
|
||||
option.value = material.id;
|
||||
option.textContent = material.name;
|
||||
materialSelect.appendChild(option);
|
||||
});
|
||||
materialSelect.value = currentMaterial;
|
||||
}
|
||||
|
||||
function renderSpools() {
|
||||
document.querySelectorAll("#spool-filter .fchip").forEach((btn) => {
|
||||
btn.classList.toggle("on", (btn.dataset.filter || "all") === spoolFilter);
|
||||
});
|
||||
fillSelects();
|
||||
|
||||
const body = document.getElementById("spool-rows");
|
||||
body.innerHTML = "";
|
||||
const shown = shownSpools();
|
||||
document.getElementById("spool-count").textContent = String(shown.length);
|
||||
const note = document.getElementById("spool-list-note");
|
||||
if (!spools.length) {
|
||||
note.textContent = "";
|
||||
body.innerHTML = '<tr><td class="empty" colspan="6"><div class="empty-state">' + icon("spool") + '<p>Noch keine Spule.</p><p class="tiny">„Neue Spule“ anlegen oder Code scannen.</p></div></td></tr>';
|
||||
return;
|
||||
}
|
||||
if (!shown.length) {
|
||||
note.textContent = "";
|
||||
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;
|
||||
|
||||
const ordered = [...shown].sort((a, b) => {
|
||||
const ah = a.printer_host || "zzz";
|
||||
const bh = b.printer_host || "zzz";
|
||||
return ah.localeCompare(bh, "de") || (Number(a.ace_slot) || 0) - (Number(b.ace_slot) || 0) || a.name.localeCompare(b.name, "de");
|
||||
});
|
||||
let lastPlace = null;
|
||||
ordered.forEach((spool) => {
|
||||
const group = spool.printer_host ? printerName(spool.printer_host) : "Regal";
|
||||
if (group !== lastPlace) {
|
||||
lastPlace = group;
|
||||
const head = document.createElement("tr");
|
||||
head.className = "mat-group";
|
||||
head.innerHTML = `<td colspan="6">${escapeHtml(group)}</td>`;
|
||||
body.appendChild(head);
|
||||
}
|
||||
const tr = document.createElement("tr");
|
||||
tr.className = "pick";
|
||||
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;
|
||||
tr.children[1].textContent = spool.chargen_nr || spool.code || "";
|
||||
tr.children[1].classList.add("nowrap");
|
||||
tr.children[2].textContent = spool.material_type;
|
||||
const colorCell = tr.children[3];
|
||||
if (spool.colors && spool.colors.length) colorCell.appendChild(dots(spool.colors));
|
||||
const caption = document.createElement("span");
|
||||
caption.className = "mat-color-cap";
|
||||
caption.textContent = spool.color ? " " + spool.color : "";
|
||||
colorCell.appendChild(caption);
|
||||
tr.children[4].textContent = ortLabel(spool);
|
||||
tr.children[4].classList.add("nowrap");
|
||||
tr.children[4].title = ortLabel(spool);
|
||||
const rest = Number(spool.remaining_g) || 0;
|
||||
tr.children[5].textContent = grams(rest);
|
||||
if (rest < 100) tr.children[5].classList.add("msg");
|
||||
tr.addEventListener("click", () => fill(spool));
|
||||
body.appendChild(tr);
|
||||
});
|
||||
}
|
||||
|
||||
function renderSorten() {
|
||||
fillSelects();
|
||||
const body = document.getElementById("sorten-rows");
|
||||
body.innerHTML = "";
|
||||
const shown = shownSorten();
|
||||
document.getElementById("sorten-count").textContent = String(shown.length);
|
||||
const note = document.getElementById("sorten-list-note");
|
||||
if (!materials.length) {
|
||||
note.textContent = "";
|
||||
body.innerHTML = '<tr><td class="empty" colspan="6"><div class="empty-state">' + icon("layers") + '<p>Noch keine Sorte.</p></div></td></tr>';
|
||||
return;
|
||||
}
|
||||
if (!shown.length) {
|
||||
note.textContent = "";
|
||||
body.innerHTML = '<tr><td class="empty" colspan="6"><div class="empty-state">' + icon("search") + '<p>Keine Sorte passt.</p></div></td></tr>';
|
||||
return;
|
||||
}
|
||||
note.textContent = shown.length === 1 ? "1 Sorte" : shown.length + " Sorten";
|
||||
|
||||
shown.forEach((material) => {
|
||||
const tr = document.createElement("tr");
|
||||
tr.className = "pick";
|
||||
if (sortenPanelOpen && String(material.id) === document.getElementById("sorten-id").value) tr.classList.add("on");
|
||||
const speeds = material.volumetric_s1 != null && material.volumetric_s1 !== material.volumetric
|
||||
? material.volumetric + " · S1 " + material.volumetric_s1
|
||||
: String(material.volumetric);
|
||||
tr.innerHTML = "<td></td><td></td><td class=\"nowrap\"></td><td class=\"num\"></td><td class=\"num\"></td><td class=\"num\"></td>";
|
||||
tr.children[0].textContent = material.name;
|
||||
tr.children[1].textContent = material.material_type;
|
||||
tr.children[2].textContent = material.machines_label;
|
||||
tr.children[3].textContent = String(material.density).replace(".", ",");
|
||||
tr.children[4].textContent = speeds;
|
||||
tr.children[5].textContent = euro(material.price_per_kg);
|
||||
tr.addEventListener("click", () => fillSorten(material));
|
||||
body.appendChild(tr);
|
||||
});
|
||||
}
|
||||
|
||||
function render() {
|
||||
renderSpools();
|
||||
renderSorten();
|
||||
}
|
||||
|
||||
async function load() {
|
||||
[spools, printers, materials, palette] = await Promise.all([
|
||||
api("/spulen"),
|
||||
api("/drucker?live=0"),
|
||||
api("/materialien"),
|
||||
api("/farben"),
|
||||
]);
|
||||
render();
|
||||
renderPalette();
|
||||
}
|
||||
|
||||
function setScanNote(text, ok) {
|
||||
const note = document.getElementById("scan-note");
|
||||
if (!text) {
|
||||
note.hidden = true;
|
||||
note.textContent = "";
|
||||
return;
|
||||
}
|
||||
note.hidden = false;
|
||||
note.textContent = text;
|
||||
note.className = "scan-note " + (ok ? "ok" : "msg");
|
||||
}
|
||||
|
||||
document.querySelectorAll(".mat-tabs [data-view]").forEach((btn) => {
|
||||
btn.addEventListener("click", () => setView(btn.dataset.view));
|
||||
});
|
||||
|
||||
document.getElementById("spool-filter").addEventListener("click", (event) => {
|
||||
const btn = event.target.closest("[data-filter]");
|
||||
if (!btn) return;
|
||||
spoolFilter = btn.dataset.filter || "all";
|
||||
renderSpools();
|
||||
});
|
||||
document.getElementById("spool-search").addEventListener("input", (event) => {
|
||||
spoolSearch = event.target.value || "";
|
||||
renderSpools();
|
||||
});
|
||||
document.getElementById("sorten-search").addEventListener("input", (event) => {
|
||||
sortenSearch = event.target.value || "";
|
||||
renderSorten();
|
||||
});
|
||||
|
||||
document.getElementById("spool-close").addEventListener("click", closeSpoolPanel);
|
||||
document.getElementById("sorten-close").addEventListener("click", closeSortenPanel);
|
||||
|
||||
document.getElementById("spool-material").addEventListener("change", () => applyMaterial(selectedMaterial(), true));
|
||||
document.getElementById("spool-printer").addEventListener("change", () => {
|
||||
const material = selectedMaterial();
|
||||
if (material) document.getElementById("spool-vol").value = volumeOf(material);
|
||||
showHint();
|
||||
});
|
||||
document.getElementById("color-mix").addEventListener("change", renderPalette);
|
||||
|
||||
document.getElementById("spool-form").addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
const id = document.getElementById("spool-id").value;
|
||||
const data = payload();
|
||||
const host = data.printer_host;
|
||||
const slot = data.ace_slot;
|
||||
try {
|
||||
const base = { ...data, printer_host: "", ace_slot: null };
|
||||
let saved = id
|
||||
? await api("/spulen/" + id, { method: "PATCH", json: base })
|
||||
: await api("/spulen", { method: "POST", json: base });
|
||||
if (host) {
|
||||
saved = await api("/spulen/" + saved.id + "/einlegen", {
|
||||
method: "POST",
|
||||
json: { host, ace_slot: slot },
|
||||
});
|
||||
}
|
||||
toast("Spule gemerkt.");
|
||||
document.getElementById("spool-note").textContent = "Gemerkt.";
|
||||
document.getElementById("spool-note").className = "ok";
|
||||
await load();
|
||||
fill(saved);
|
||||
} catch (error) {
|
||||
showNote("spool-note", error);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById("spool-new").addEventListener("click", () => {
|
||||
fill(null);
|
||||
document.getElementById("spool-material").focus();
|
||||
});
|
||||
document.getElementById("spool-shelf").addEventListener("click", async () => {
|
||||
const id = document.getElementById("spool-id").value;
|
||||
if (!id) return;
|
||||
try {
|
||||
const saved = await api("/spulen/" + id, { method: "PATCH", json: { printer_host: "", ace_slot: null } });
|
||||
toast("Spule liegt im Regal.");
|
||||
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");
|
||||
});
|
||||
document.getElementById("spool-delete").addEventListener("click", () => {
|
||||
const id = document.getElementById("spool-id").value;
|
||||
if (!id) return;
|
||||
armButton(document.getElementById("spool-delete"), "Löschen?", async () => {
|
||||
await api("/spulen/" + id, { method: "DELETE" });
|
||||
toast("Spule gelöscht.");
|
||||
await load();
|
||||
closeSpoolPanel();
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById("sorten-new").addEventListener("click", () => {
|
||||
fillSorten(null);
|
||||
document.getElementById("sorten-name").focus();
|
||||
});
|
||||
document.getElementById("sorten-form").addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
const id = document.getElementById("sorten-id").value;
|
||||
const volS1 = document.getElementById("sorten-vol-s1").value;
|
||||
const data = {
|
||||
name: document.getElementById("sorten-name").value.trim(),
|
||||
brand: document.getElementById("sorten-brand").value.trim(),
|
||||
material_type: document.getElementById("sorten-type").value.trim() || "PLA",
|
||||
machines: document.getElementById("sorten-machines").value,
|
||||
density: Number(document.getElementById("sorten-density").value) || 1.24,
|
||||
price_per_kg: Number(document.getElementById("sorten-price").value) || 0,
|
||||
volumetric: Number(document.getElementById("sorten-vol").value) || 12,
|
||||
volumetric_s1: volS1 === "" ? null : Number(volS1),
|
||||
note: document.getElementById("sorten-note").value.trim(),
|
||||
};
|
||||
try {
|
||||
const saved = id
|
||||
? await api("/materialien/" + id, { method: "PATCH", json: data })
|
||||
: await api("/materialien", { method: "POST", json: data });
|
||||
toast("Sorte gemerkt.");
|
||||
showNote("sorten-note-msg", null, "Gemerkt.");
|
||||
await load();
|
||||
fillSorten(saved);
|
||||
} catch (error) {
|
||||
showNote("sorten-note-msg", error);
|
||||
}
|
||||
});
|
||||
document.getElementById("sorten-use").addEventListener("click", () => {
|
||||
const id = document.getElementById("sorten-id").value;
|
||||
const material = materials.find((row) => String(row.id) === id);
|
||||
if (!material) return;
|
||||
setView("spools");
|
||||
fill(null);
|
||||
applyMaterial(material, true);
|
||||
document.getElementById("color-box").open = true;
|
||||
});
|
||||
document.getElementById("sorten-delete").addEventListener("click", () => {
|
||||
const id = document.getElementById("sorten-id").value;
|
||||
if (!id) return;
|
||||
armButton(document.getElementById("sorten-delete"), "Löschen?", async () => {
|
||||
try {
|
||||
await api("/materialien/" + id, { method: "DELETE" });
|
||||
toast("Sorte gelöscht.");
|
||||
await load();
|
||||
closeSortenPanel();
|
||||
} catch (error) {
|
||||
showNote("sorten-note-msg", error);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
async function openByCode(raw) {
|
||||
const code = (raw || "").trim();
|
||||
if (!code) return;
|
||||
try {
|
||||
let spool = null;
|
||||
if (/^\d+$/.test(code)) {
|
||||
spool = spools.find((row) => String(row.id) === code) || null;
|
||||
}
|
||||
if (!spool && code.includes("spool=")) {
|
||||
const match = code.match(/[?&]spool=(\d+)/);
|
||||
if (match) spool = spools.find((row) => String(row.id) === match[1]) || null;
|
||||
}
|
||||
if (!spool) spool = await api("/spulen/by-code/" + encodeURIComponent(code));
|
||||
setView("spools");
|
||||
fill(spool);
|
||||
setScanNote("Spule " + (spool.chargen_nr || spool.name) + " geöffnet.", true);
|
||||
document.getElementById("scan-code").select();
|
||||
} catch (error) {
|
||||
setScanNote(error.message || String(error), false);
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById("scan-code").addEventListener("keydown", (event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
openByCode(event.target.value);
|
||||
}
|
||||
});
|
||||
|
||||
load().then(() => {
|
||||
closeSpoolPanel();
|
||||
closeSortenPanel();
|
||||
const params = new URLSearchParams(location.search);
|
||||
const spoolId = params.get("spool");
|
||||
if (spoolId) {
|
||||
const spool = spools.find((row) => String(row.id) === spoolId);
|
||||
if (spool) fill(spool);
|
||||
else openByCode(spoolId);
|
||||
}
|
||||
}).catch((error) => showNote("spool-note", error));
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,195 @@
|
||||
{% extends "layout.html" %}
|
||||
{% block body %}
|
||||
<div class="page-head">
|
||||
<div>
|
||||
<h1>Projekte</h1>
|
||||
<p class="tiny">Gruppiere Dateien und Läufe.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="split">
|
||||
<section class="card">
|
||||
<h2>Liste</h2>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead><tr><th>Name</th><th>Status</th><th class="num">Dateien</th></tr></thead>
|
||||
<tbody id="project-rows"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
<section class="card">
|
||||
<h2 id="project-title">Neu</h2>
|
||||
<form id="project-form">
|
||||
<input id="project-id" type="hidden">
|
||||
<div class="field"><label for="project-name">Name</label><input id="project-name" required></div>
|
||||
<div class="field"><label for="project-notes">Notiz</label><textarea id="project-notes"></textarea></div>
|
||||
<div class="field"><label for="project-status">Status</label>
|
||||
<select id="project-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="actions">
|
||||
<button type="submit">Merken</button>
|
||||
<button type="button" class="ghost" id="project-new">Neu</button>
|
||||
<button type="button" class="warn" id="project-delete">Löschen</button>
|
||||
</div>
|
||||
</form>
|
||||
<p id="project-note"></p>
|
||||
<h2 class="section-gap">Kalkulation</h2>
|
||||
<label>Spule</label>
|
||||
<select id="quote-spool"></select>
|
||||
<div class="grid-2">
|
||||
<div><label>Teilegewicht g</label><input id="quote-weight" type="number" min="0" step="1" value="0"></div>
|
||||
<div><label>Stück</label><input id="quote-qty" type="number" min="1" step="1" value="1"></div>
|
||||
<div><label>Ausschuss %</label><input id="quote-waste" type="number" min="0" step="1" value="0"></div>
|
||||
<div><label>Auslastung %</label><input id="quote-util" type="number" min="1" max="100" step="1" value="50"></div>
|
||||
<div><label>Preis €</label><input id="quote-price" type="number" min="0" step="0.01" value="0"></div>
|
||||
<div>
|
||||
<label>Preis gilt</label>
|
||||
<select id="quote-mode">
|
||||
<option value="perPart">pro Stück</option>
|
||||
<option value="perKg">pro kg Teile</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions"><button type="button" id="quote-go">Rechnen</button></div>
|
||||
<div class="nums" id="quote-result"></div>
|
||||
</section>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block script %}
|
||||
<script>
|
||||
const statusLabel = { idee: "Idee", bereit: "Bereit", druckt: "Druckt", fertig: "Fertig" };
|
||||
let projects = [];
|
||||
let spools = [];
|
||||
|
||||
function fill(project) {
|
||||
document.getElementById("project-id").value = project ? project.id : "";
|
||||
document.getElementById("project-name").value = project ? project.name : "";
|
||||
document.getElementById("project-notes").value = project ? project.notes : "";
|
||||
document.getElementById("project-status").value = project ? project.status : "idee";
|
||||
document.getElementById("project-title").textContent = project ? project.name : "Neu";
|
||||
document.getElementById("project-delete").hidden = !project;
|
||||
const quote = project && project.quote && project.quote.inputs;
|
||||
if (quote) {
|
||||
document.getElementById("quote-weight").value = quote.weight_g;
|
||||
document.getElementById("quote-qty").value = quote.qty;
|
||||
document.getElementById("quote-waste").value = quote.waste_percent;
|
||||
document.getElementById("quote-util").value = quote.utilization;
|
||||
document.getElementById("quote-price").value = quote.price;
|
||||
document.getElementById("quote-mode").value = quote.price_mode || "perPart";
|
||||
}
|
||||
if (project && project.result) paint(project.result);
|
||||
else if (project && project.quote && project.quote.result) paint(project.quote.result);
|
||||
else document.getElementById("quote-result").innerHTML = "";
|
||||
}
|
||||
|
||||
function paint(result) {
|
||||
document.getElementById("quote-result").innerHTML = `
|
||||
<div class="num"><span>bleibt gesamt</span><b>${euro(result.margin)}</b></div>
|
||||
<div class="num"><span>bleibt pro Stunde</span><b>${euro(result.per_hour)}</b></div>
|
||||
<div class="num"><span>Filament</span><b>${euro(result.filament_cost)}</b></div>
|
||||
<div class="num"><span>Strom</span><b>${euro(result.energy_cost)}</b></div>
|
||||
<div class="num"><span>Zeit</span><b>${hours(result.hours * 3600) || "0 min"}</b></div>
|
||||
<div class="num"><span>Filamentmenge</span><b>${grams(result.filament_g)}</b></div>`;
|
||||
}
|
||||
|
||||
function render() {
|
||||
const body = document.getElementById("project-rows");
|
||||
body.innerHTML = "";
|
||||
if (!projects.length) {
|
||||
body.innerHTML = '<tr><td class="empty" colspan="3"><div class="empty-state">' + icon("folder") + '<p>Noch kein Projekt.</p></div></td></tr>';
|
||||
}
|
||||
projects.forEach((project) => {
|
||||
const tr = document.createElement("tr");
|
||||
tr.className = "pick";
|
||||
tr.innerHTML = "<td></td><td></td><td></td>";
|
||||
tr.children[0].textContent = project.name;
|
||||
tr.children[1].textContent = statusLabel[project.status] || project.status;
|
||||
tr.children[2].textContent = project.file_count || 0;
|
||||
tr.addEventListener("click", async () => {
|
||||
const full = await api("/projekte/" + project.id);
|
||||
fill(full);
|
||||
if (full.files && full.files[0] && full.files[0].weight_g && !full.quote.inputs) {
|
||||
document.getElementById("quote-weight").value = full.files[0].weight_g;
|
||||
}
|
||||
});
|
||||
body.appendChild(tr);
|
||||
});
|
||||
const select = document.getElementById("quote-spool");
|
||||
select.innerHTML = '<option value="">ohne Spule</option>';
|
||||
spools.forEach((spool) => {
|
||||
const option = document.createElement("option");
|
||||
option.value = spool.id;
|
||||
option.textContent = spool.name;
|
||||
select.appendChild(option);
|
||||
});
|
||||
}
|
||||
|
||||
async function load() {
|
||||
[projects, spools] = await Promise.all([api("/projekte"), api("/spulen")]);
|
||||
const settings = await api("/einstellungen");
|
||||
document.getElementById("quote-waste").value = settings.energy.waste_percent;
|
||||
document.getElementById("quote-util").value = settings.energy.utilization;
|
||||
render();
|
||||
fill(null);
|
||||
}
|
||||
|
||||
document.getElementById("project-form").addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
const id = document.getElementById("project-id").value;
|
||||
const payload = {
|
||||
name: document.getElementById("project-name").value,
|
||||
notes: document.getElementById("project-notes").value,
|
||||
status: document.getElementById("project-status").value,
|
||||
};
|
||||
try {
|
||||
const saved = id
|
||||
? await api("/projekte/" + id, { method: "PATCH", json: payload })
|
||||
: await api("/projekte", { method: "POST", json: payload });
|
||||
showNote("project-note", null);
|
||||
document.getElementById("project-note").textContent = "Gemerkt.";
|
||||
await load();
|
||||
fill(saved);
|
||||
} catch (error) {
|
||||
showNote("project-note", error);
|
||||
}
|
||||
});
|
||||
document.getElementById("project-new").addEventListener("click", () => fill(null));
|
||||
document.getElementById("project-delete").addEventListener("click", (event) => {
|
||||
const id = document.getElementById("project-id").value;
|
||||
if (!id) return;
|
||||
armButton(event.currentTarget, "Dateien bleiben — löschen?", async () => {
|
||||
try {
|
||||
await api("/projekte/" + id, { method: "DELETE" });
|
||||
toast("Projekt gelöscht.");
|
||||
load();
|
||||
} catch (error) {
|
||||
showNote("project-note", error);
|
||||
}
|
||||
});
|
||||
});
|
||||
document.getElementById("quote-go").addEventListener("click", async () => {
|
||||
try {
|
||||
const payload = await api("/kalkulation", {
|
||||
method: "POST",
|
||||
json: {
|
||||
project_id: document.getElementById("project-id").value || null,
|
||||
spool_id: document.getElementById("quote-spool").value || null,
|
||||
weight_g: Number(document.getElementById("quote-weight").value),
|
||||
qty: Number(document.getElementById("quote-qty").value),
|
||||
waste_percent: Number(document.getElementById("quote-waste").value),
|
||||
utilization: Number(document.getElementById("quote-util").value),
|
||||
price: Number(document.getElementById("quote-price").value),
|
||||
price_mode: document.getElementById("quote-mode").value,
|
||||
},
|
||||
});
|
||||
paint(payload.result);
|
||||
} catch (error) {
|
||||
showNote("project-note", error);
|
||||
}
|
||||
});
|
||||
load().catch((error) => showNote("project-note", error));
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,221 @@
|
||||
{% 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>
|
||||
</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="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>
|
||||
<thead><tr><th>Drucker</th><th>Datei</th><th>Zustand</th><th>Dauer</th><th>Gramm</th><th>Kosten</th><th>Urteil</th><th>Spule</th><th></th></tr></thead>
|
||||
<tbody id="run-rows"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block script %}
|
||||
<script>
|
||||
const states = {
|
||||
completed: "fertig", cancelled: "abgebrochen", error: "Fehler",
|
||||
in_progress: "druckt", interrupted: "unterbrochen",
|
||||
};
|
||||
let runs = [];
|
||||
let spools = [];
|
||||
|
||||
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 render() {
|
||||
fillPrinterFilter();
|
||||
const body = document.getElementById("run-rows");
|
||||
body.innerHTML = "";
|
||||
const shown = filteredRuns();
|
||||
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>';
|
||||
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>';
|
||||
return;
|
||||
}
|
||||
shown.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[1].textContent = (run.filename || "").split("/").pop();
|
||||
tr.children[2].textContent = states[run.state] || run.state;
|
||||
tr.children[3].textContent = hours(run.duration_s);
|
||||
tr.children[4].textContent = run.weight_g ? grams(run.weight_g) : "";
|
||||
const cost = Number(run.filament_cost || 0) + Number(run.energy_cost || 0);
|
||||
tr.children[5].textContent = cost ? euro(cost) : "";
|
||||
const verdict = document.createElement("select");
|
||||
[["", "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 } });
|
||||
} catch (error) {
|
||||
showNote("run-note", error);
|
||||
}
|
||||
});
|
||||
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 {
|
||||
const spool = spools.find((item) => item.id === run.spool_id);
|
||||
tr.children[7].textContent = spool ? spool.name : "abgezogen";
|
||||
}
|
||||
if (run.file_id) {
|
||||
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;
|
||||
}
|
||||
});
|
||||
});
|
||||
tr.children[8].appendChild(again);
|
||||
}
|
||||
body.appendChild(tr);
|
||||
});
|
||||
}
|
||||
|
||||
async function load() {
|
||||
[runs, spools] = await Promise.all([api("/laeufe"), api("/spulen")]);
|
||||
render();
|
||||
}
|
||||
|
||||
["run-filter-printer", "run-filter-verdict", "run-filter-spool"].forEach((id) => {
|
||||
document.getElementById(id).addEventListener("change", render);
|
||||
document.getElementById(id).addEventListener("input", 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 %}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,218 @@
|
||||
{% extends "layout.html" %}
|
||||
{% block body %}
|
||||
<div class="page-head">
|
||||
<div>
|
||||
<h1>Warteschlange</h1>
|
||||
<p class="tiny">Wartende und laufende Aufträge. Direktstarts erscheinen unter Läuft. Priorität mit den Pfeilen ändern.</p>
|
||||
</div>
|
||||
<div class="page-head-side">
|
||||
<button type="button" class="ghost" id="q-refresh"><svg class="ico"><use href="#i-refresh"/></svg>Neu laden</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="filter-bar" id="q-filter" role="group" aria-label="Status">
|
||||
<button type="button" class="fchip on" data-status="">Alle aktiv</button>
|
||||
<button type="button" class="fchip" data-status="running">Läuft</button>
|
||||
<button type="button" class="fchip" data-status="waiting">Wartet</button>
|
||||
<button type="button" class="fchip" data-status="error">Fehler</button>
|
||||
<button type="button" class="fchip" data-status="history">Historie</button>
|
||||
</div>
|
||||
<p id="q-note" class="tiny"></p>
|
||||
<div class="card table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Status</th>
|
||||
<th>Datei</th>
|
||||
<th>Ziel</th>
|
||||
<th>Schritte</th>
|
||||
<th>Meldung</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="q-rows"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block script %}
|
||||
<script>
|
||||
const STATUS_LABEL = {
|
||||
running: "läuft", waiting: "wartet", error: "Fehler",
|
||||
done: "fertig", cancelled: "abgebrochen", dismissed: "verworfen",
|
||||
};
|
||||
let jobs = [];
|
||||
let liveDirect = [];
|
||||
let filter = "";
|
||||
|
||||
function targetOf(job) {
|
||||
return job.printer_name || job.host || (job.family_label ? "nächster " + job.family_label : "nächster freier");
|
||||
}
|
||||
|
||||
function directRows() {
|
||||
return liveDirect.map((p) => {
|
||||
const pct = Math.round((Number(p.progress) || 0) * 100);
|
||||
const eta = p.remain > 0 ? "noch " + hours(p.remain) + " · fertig " + finishClock(p.remain) : "";
|
||||
const state = STATE_LABELS[p.state] || p.state || "läuft";
|
||||
return {
|
||||
id: "live:" + p.host,
|
||||
status: "running",
|
||||
direct: true,
|
||||
host: p.host,
|
||||
printer_name: p.name || p.host,
|
||||
filename: p.filename || "",
|
||||
message: state + (pct ? " · " + pct + " %" : "") + (eta ? " · " + eta : ""),
|
||||
progress: pct,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function shownJobs() {
|
||||
const direct = directRows();
|
||||
if (filter === "history") {
|
||||
return jobs.filter((j) => ["done", "cancelled", "dismissed"].includes(j.status));
|
||||
}
|
||||
if (filter === "running") {
|
||||
return jobs.filter((j) => j.status === "running").concat(direct);
|
||||
}
|
||||
if (filter) return jobs.filter((j) => j.status === filter);
|
||||
return jobs.filter((j) => ["running", "waiting", "error"].includes(j.status)).concat(direct);
|
||||
}
|
||||
|
||||
function render() {
|
||||
document.querySelectorAll("#q-filter .fchip").forEach((b) => {
|
||||
b.classList.toggle("on", (b.dataset.status || "") === filter);
|
||||
});
|
||||
const body = document.getElementById("q-rows");
|
||||
const shown = shownJobs();
|
||||
if (!shown.length) {
|
||||
const hint = jobs.length
|
||||
? "Nichts in dieser Ansicht."
|
||||
: (liveDirect.length ? "Keine Queue-Aufträge — laufende Direktstarts unter „Läuft“." : "Noch kein Eintrag.");
|
||||
body.innerHTML = '<tr><td class="empty" colspan="6"><div class="empty-state">' + icon("queue") + '<p>' + hint + '</p></div></td></tr>';
|
||||
document.getElementById("q-note").textContent = "";
|
||||
return;
|
||||
}
|
||||
const waiting = jobs.filter((j) => j.status === "waiting");
|
||||
body.innerHTML = shown.map((job) => {
|
||||
const st = job.status;
|
||||
if (job.direct) {
|
||||
return `<tr data-direct="${escapeHtml(job.host)}">
|
||||
<td>${chipHtml("printing", "läuft")}</td>
|
||||
<td><span title="${escapeHtml(job.filename || "")}">${escapeHtml(jobLabel(job.filename))}</span></td>
|
||||
<td><a href="/#${encodeURIComponent(job.host)}">${escapeHtml(targetOf(job))}</a></td>
|
||||
<td>—</td>
|
||||
<td class="tiny">${escapeHtml(job.message || "")}</td>
|
||||
<td class="queue-tools"><a class="icon-btn" href="/#${encodeURIComponent(job.host)}" title="Zur Wand">${icon("printer")}</a></td>
|
||||
</tr>`;
|
||||
}
|
||||
const waitIdx = waiting.findIndex((j) => j.id === job.id);
|
||||
const steps = job.is_ablauf
|
||||
? stepsHtml(job.step_labels, job.display_step_index != null ? job.display_step_index : job.step_index, true)
|
||||
: "";
|
||||
const rep = repeatText(job);
|
||||
const tools = [];
|
||||
if (st === "waiting" && waitIdx > 0) {
|
||||
tools.push(`<button type="button" class="icon-btn" data-prio="${job.id}" data-dir="-1" title="Höher">${icon("up")}</button>`);
|
||||
}
|
||||
if (st === "waiting" && waitIdx >= 0 && waitIdx < waiting.length - 1) {
|
||||
tools.push(`<button type="button" class="icon-btn" data-prio="${job.id}" data-dir="1" title="Tiefer">${icon("down")}</button>`);
|
||||
}
|
||||
if (st === "error" && job.is_ablauf) {
|
||||
tools.push(`<button type="button" class="icon-btn" data-resume="${job.id}" title="Fortsetzen">${icon("play")}</button>`);
|
||||
tools.push(`<button type="button" class="icon-btn danger" data-dismiss="${job.id}" title="Verwerfen">${icon("x")}</button>`);
|
||||
} else if (st === "waiting" || st === "running") {
|
||||
tools.push(`<button type="button" class="icon-btn danger" data-cancel="${job.id}" title="Abbrechen">${icon("x")}</button>`);
|
||||
}
|
||||
return `<tr data-id="${job.id}">
|
||||
<td>${chipHtml(st === "running" ? "printing" : st === "waiting" ? "waiting" : st === "error" ? "error" : "complete", STATUS_LABEL[st] || st)}</td>
|
||||
<td><span title="${escapeHtml(job.filename || "")}">${escapeHtml(jobLabel(job.filename))}</span>${rep ? " · " + escapeHtml(rep) : ""}</td>
|
||||
<td>${job.host ? `<a href="/#${encodeURIComponent(job.host)}">${escapeHtml(targetOf(job))}</a>` : escapeHtml(targetOf(job))}</td>
|
||||
<td>${steps || "—"}</td>
|
||||
<td class="tiny">${escapeHtml(job.message || "")}</td>
|
||||
<td class="queue-tools">${tools.join("")}</td>
|
||||
</tr>`;
|
||||
}).join("");
|
||||
document.getElementById("q-note").textContent = shown.length === 1 ? "1 Eintrag" : shown.length + " Einträge";
|
||||
}
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const [queue, active] = await Promise.all([
|
||||
api("/druck-queue?limit=120"),
|
||||
api("/laeuft").catch(() => []),
|
||||
]);
|
||||
jobs = queue || [];
|
||||
const queuedHosts = new Set(jobs.filter((j) => j.status === "running").map((j) => j.host));
|
||||
liveDirect = (active || []).filter((p) => p.host && !queuedHosts.has(p.host));
|
||||
render();
|
||||
} catch (error) {
|
||||
document.getElementById("q-rows").innerHTML =
|
||||
`<tr><td class="empty" colspan="6">${escapeHtml(error.message)}</td></tr>`;
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById("q-filter").addEventListener("click", (event) => {
|
||||
const btn = event.target.closest("[data-status]");
|
||||
if (!btn) return;
|
||||
filter = btn.dataset.status || "";
|
||||
render();
|
||||
});
|
||||
document.getElementById("q-refresh").addEventListener("click", load);
|
||||
|
||||
document.getElementById("q-rows").addEventListener("click", async (event) => {
|
||||
const cancel = event.target.closest("[data-cancel]");
|
||||
if (cancel) {
|
||||
const job = jobs.find((j) => j.id === Number(cancel.dataset.cancel));
|
||||
if (job) await window.cancelQueueItem(job, cancel);
|
||||
load();
|
||||
return;
|
||||
}
|
||||
const resume = event.target.closest("[data-resume]");
|
||||
if (resume) {
|
||||
armButton(resume, "Fortsetzen?", async () => {
|
||||
try {
|
||||
await api("/druck-queue/" + resume.dataset.resume, {
|
||||
method: "PATCH", json: { status: "running", resume: true },
|
||||
});
|
||||
toast("Ablauf geht weiter.");
|
||||
load();
|
||||
if (typeof refreshPrintQueue === "function") refreshPrintQueue();
|
||||
} catch (error) { toast(error.message, "bad"); }
|
||||
});
|
||||
return;
|
||||
}
|
||||
const dismiss = event.target.closest("[data-dismiss]");
|
||||
if (dismiss) {
|
||||
armButton(dismiss, "Verwerfen?", async () => {
|
||||
try {
|
||||
await api("/druck-queue/" + dismiss.dataset.dismiss, {
|
||||
method: "PATCH", json: { status: "dismissed" },
|
||||
});
|
||||
load();
|
||||
if (typeof refreshPrintQueue === "function") refreshPrintQueue();
|
||||
} catch (error) { toast(error.message, "bad"); }
|
||||
});
|
||||
return;
|
||||
}
|
||||
const prio = event.target.closest("[data-prio]");
|
||||
if (prio) {
|
||||
const id = Number(prio.dataset.prio);
|
||||
const dir = Number(prio.dataset.dir);
|
||||
const waiting = jobs.filter((j) => j.status === "waiting");
|
||||
const idx = waiting.findIndex((j) => j.id === id);
|
||||
const other = waiting[idx + dir];
|
||||
if (!other) return;
|
||||
try {
|
||||
await api("/druck-queue/" + id, {
|
||||
method: "PATCH", json: { priority: Number(other.priority) + dir },
|
||||
});
|
||||
load();
|
||||
if (typeof refreshPrintQueue === "function") refreshPrintQueue();
|
||||
} catch (error) { toast(error.message, "bad"); }
|
||||
}
|
||||
});
|
||||
|
||||
window.onQueueChanged = load;
|
||||
load();
|
||||
setInterval(load, 5000);
|
||||
</script>
|
||||
{% endblock %}
|
||||
Reference in new issue
Block a user