Grotti 2.2.0: Auth, API-Tokens, MCP und Doku-Trennung.

This commit is contained in:
Dominic Gundel committed 2026-10-15 10:53:12 +02:00
1 parent b20f8a7779
commit 5a3716b26a
65 files changed
+2212 -198

No files matched your search

+4 -7
View File
@@ -3,15 +3,12 @@
<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#kapitel" data-sec="kapitel">Anleitung</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>
<p>Anleitung</p>
{% for name in anleitung %}
<a href="/doku/{{ name }}" class="{{ 'on' if current == name else '' }}">{{ anleitung_title(name) }}</a>
{% endfor %}
</nav>
{% endmacro %}
+13 -37
View File
@@ -6,8 +6,8 @@
<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>
<p>Sie läuft auf diesem Rechner und spricht jeden Drucker an. Alles, was die Seiten zeigen, gibt es auch unter <code>/api/v1</code> — mit Login oder API-Token.</p>
<blockquote>Sicherheit: Ein Druck startet nur nach ausdrücklicher Bestätigung. Es gibt keine freie G-code-Konsole.</blockquote>
<h2 id="druck">Ein Druck von A bis Z</h2>
<div class="flow-diagram">
@@ -22,46 +22,28 @@
<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>Während des Drucks</strong> zeigt die <a href="/">Wand</a> Fortschritt, Fertig-Uhrzeit und Temperaturen. Ein Klick auf die Karte öffnet das Cockpit mit Kamera (falls vorhanden), Steuerung, Bettnetz und 3D-Pfad. Die Seitenleiste zeigt immer, was läuft und was wartet.</li>
<li><strong>Danach</strong> ist die Platte belegt. Ein wartender Auftrag startet erst, wenn auf der Karte <em>Platte geräumt</em> gedrückt wurde, oder ein Ablauf sie selbst freischiebt.</li>
</ol>
<p>Ausführlich mit Screenshots: <a href="/doku/Anleitung-Dateien">Dateien senden</a>, <a href="/doku/Anleitung-Wand">Druckerwand</a>.</p>
<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>
<h2 id="kapitel">Anleitung</h2>
<p>Schritt für Schritt mit Screenshots. Kurzfassung oben, Details in den Kapiteln.</p>
<ul class="doc-chapters">
{% for name in anleitung %}
<li><a href="/doku/{{ name }}">{{ anleitung_title(name) }}</a></li>
{% endfor %}
</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>Ablauf gestoppt</dt><dd>Auf der Karte oder in der Seitenleiste Ursache beheben, dann <em>Weiter</em> (ab dem fehlgeschlagenen Schritt) oder Verwerfen. 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>
<p>Mehr Fälle: <a href="/doku/Anleitung-Stoerungen">Störungen</a>.</p>
<h2 id="begriffe">Begriffe</h2>
<dl class="terms">
@@ -71,14 +53,8 @@
<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>
<dt>API-Token</dt><dd>Persönlicher Schlüssel für App, Home-Automation oder MCP — unter Einstellungen erzeugen.</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 %}
+216 -26
View File
@@ -29,7 +29,7 @@
<p id="auto-note"></p>
</section>
<section class="card">
<section class="card admin-only">
<h2>Öffentliche Adresse</h2>
<p class="hint">FQDN bzw. Basis-URL für QR-Codes auf Spulen-Etiketten. Leer = Host der aktuellen Anfrage (z.&nbsp;B. localhost).</p>
<div class="field" style="max-width:28rem">
@@ -41,7 +41,7 @@
<p id="fqdn-note"></p>
</section>
<section class="card">
<section class="card admin-only">
<h2>Drucker</h2>
<p class="hint">Gesperrt = kein neuer „wenn frei“-Start auf diesem Drucker.</p>
<div class="table-wrap">
@@ -72,7 +72,7 @@
<p id="printer-note"></p>
</section>
<section class="card">
<section class="card admin-only">
<h2>Telegram</h2>
<label class="check"><input type="checkbox" id="tg-enabled"> Benachrichtigungen aktiv</label>
<div class="grid-2" style="margin-top:0.75rem">
@@ -101,7 +101,7 @@
<p id="tg-note"></p>
</section>
<section class="card">
<section class="card admin-only">
<h2>Energie</h2>
<p class="hint">Für Kosten im Protokoll. Filament zählt erst mit zugeordneter Spule.</p>
<div class="grid-2">
@@ -115,10 +115,61 @@
<div class="actions"><button type="button" id="en-save">Speichern</button></div>
<p id="en-note"></p>
</section>
<section class="card admin-only">
<h2>Benutzer</h2>
<p class="hint">Rollen: <strong>Admin</strong> (alles) und <strong>Operator</strong> (Drucker, Queue, Material — keine Benutzerverwaltung).</p>
<div class="table-wrap">
<table>
<thead><tr><th>Name</th><th>Rolle</th><th>Aktiv</th><th></th></tr></thead>
<tbody id="user-rows"></tbody>
</table>
</div>
<form id="user-add" class="grid-2" style="margin-top:1rem">
<div class="field"><label for="user-name">Benutzer</label><input id="user-name" required minlength="2" autocomplete="off"></div>
<div class="field"><label for="user-pass">Passwort</label><input id="user-pass" type="password" required minlength="8" autocomplete="new-password"></div>
<div class="field">
<label for="user-role">Rolle</label>
<select id="user-role"><option value="operator">Operator</option><option value="admin">Admin</option></select>
</div>
<div class="actions" style="align-self:end"><button type="submit">Anlegen</button></div>
</form>
<p id="user-note"></p>
</section>
<section class="card">
<h2>API-Tokens</h2>
<p class="hint">Für App, Home-Automation oder MCP. Klartext erscheint nur einmal bei der Erstellung. Authorization: <code>Bearer grt_…</code></p>
<div class="table-wrap">
<table>
<thead><tr><th>Name</th><th>Prefix</th><th>Rolle</th><th></th></tr></thead>
<tbody id="token-rows"></tbody>
</table>
</div>
<form id="token-add" class="grid-2" style="margin-top:1rem">
<div class="field"><label for="token-name">Bezeichnung</label><input id="token-name" placeholder="Home Assistant" required></div>
<div class="field">
<label for="token-role">Rolle</label>
<select id="token-role"><option value="operator">Operator</option><option value="admin">Admin</option></select>
</div>
<div class="actions" style="align-self:end"><button type="submit">Token erzeugen</button></div>
</form>
<p id="token-note"></p>
<p class="ok" id="token-once" hidden></p>
<p class="hint" id="legacy-token-hint" hidden>In der Config steht noch ein Shared-Token — der gilt weiter als Admin. Besser auf Benutzer-Tokens umstellen und <code>token</code> in der Config leeren.</p>
</section>
{% endblock %}
{% block script %}
<script>
const families = { kobra_3: "Kobra 3", kobra_s1: "Kobra S1", "": "—" };
const isAdmin = document.querySelector('meta[name="user-role"]')?.content === "admin";
document.querySelectorAll(".admin-only").forEach((el) => { el.hidden = !isAdmin; });
if (!isAdmin) {
const roleSel = document.getElementById("token-role");
if (roleSel) {
[...roleSel.options].forEach((opt) => { if (opt.value === "admin") opt.remove(); });
}
}
/* showNote aus api.js: Inline-Hinweis + Toast */
@@ -181,36 +232,175 @@ function renderPrinters(rows) {
});
}
function renderUsers(rows) {
const body = document.getElementById("user-rows");
if (!body) return;
body.innerHTML = "";
rows.forEach((row) => {
const tr = document.createElement("tr");
tr.innerHTML = `<td>${escapeHtml(row.username)}</td>
<td><select data-f="role"><option value="operator">Operator</option><option value="admin">Admin</option></select></td>
<td><label class="check"><input type="checkbox" data-f="active"></label></td><td></td>`;
tr.querySelector('[data-f="role"]').value = row.role;
tr.querySelector('[data-f="active"]').checked = !!row.active;
const save = document.createElement("button");
save.type = "button";
save.textContent = "Speichern";
save.addEventListener("click", async () => {
try {
await api("/users/" + row.id, {
method: "PATCH",
json: {
role: tr.querySelector('[data-f="role"]').value,
active: tr.querySelector('[data-f="active"]').checked,
},
});
showNote("user-note", null, "Benutzer gespeichert.");
loadUsers();
} catch (error) {
showNote("user-note", error);
}
});
const del = document.createElement("button");
del.type = "button";
del.className = "ghost warn";
del.textContent = "Löschen";
del.addEventListener("click", () => {
armButton(del, "Wirklich löschen?", async () => {
try {
await api("/users/" + row.id, { method: "DELETE" });
showNote("user-note", null, "Benutzer gelöscht.");
loadUsers();
} catch (error) {
showNote("user-note", error);
}
});
});
tr.children[3].appendChild(save);
tr.children[3].appendChild(del);
body.appendChild(tr);
});
}
function renderTokens(rows) {
const body = document.getElementById("token-rows");
body.innerHTML = "";
if (!rows.length) {
body.innerHTML = '<tr><td class="empty" colspan="4">Keine Tokens.</td></tr>';
return;
}
rows.forEach((row) => {
const tr = document.createElement("tr");
tr.innerHTML = `<td>${escapeHtml(row.name)}${row.username ? ' <span class="tiny">(' + escapeHtml(row.username) + ")</span>" : ""}</td>
<td><code>${escapeHtml(row.prefix)}…</code></td>
<td>${escapeHtml(row.role)}</td><td></td>`;
const rev = document.createElement("button");
rev.type = "button";
rev.className = "ghost warn";
rev.textContent = "Widerrufen";
rev.addEventListener("click", () => {
armButton(rev, "Widerrufen?", async () => {
try {
await api("/tokens/" + row.id, { method: "DELETE" });
showNote("token-note", null, "Token widerrufen.");
loadTokens();
} catch (error) {
showNote("token-note", error);
}
});
});
tr.children[3].appendChild(rev);
body.appendChild(tr);
});
}
async function loadUsers() {
if (!isAdmin) return;
renderUsers(await api("/users"));
}
async function loadTokens() {
renderTokens(await api("/tokens" + (isAdmin ? "?all=1" : "")));
}
async function load() {
const [settings, printers] = await Promise.all([
api("/einstellungen"),
api("/drucker?live=0"),
]);
renderPrinters(printers);
if (isAdmin) 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;
const fqdn = document.getElementById("fqdn");
if (fqdn) fqdn.value = settings.fqdn || "";
const tgEnabled = document.getElementById("tg-enabled");
if (tgEnabled) {
tgEnabled.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;
}
const legacy = document.getElementById("legacy-token-hint");
if (legacy) legacy.hidden = !settings.legacy_token_set;
await Promise.all([loadUsers(), loadTokens()]);
}
document.getElementById("user-add")?.addEventListener("submit", async (event) => {
event.preventDefault();
try {
await api("/users", {
method: "POST",
json: {
username: document.getElementById("user-name").value,
password: document.getElementById("user-pass").value,
role: document.getElementById("user-role").value,
},
});
document.getElementById("user-add").reset();
showNote("user-note", null, "Benutzer angelegt.");
loadUsers();
} catch (error) {
showNote("user-note", error);
}
});
document.getElementById("token-add").addEventListener("submit", async (event) => {
event.preventDefault();
const once = document.getElementById("token-once");
once.hidden = true;
try {
const out = await api("/tokens", {
method: "POST",
json: {
name: document.getElementById("token-name").value,
role: document.getElementById("token-role").value,
},
});
document.getElementById("token-add").reset();
once.hidden = false;
once.textContent = "Token (nur jetzt sichtbar): " + out.token;
showNote("token-note", null, "Token erzeugt — jetzt kopieren.");
loadTokens();
} catch (error) {
showNote("token-note", error);
}
});
document.getElementById("auto-save").addEventListener("click", async () => {
try {
await api("/einstellungen", {
+1 -1
View File
@@ -101,7 +101,7 @@
</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 src="{{ url_for('static', filename='qrcode.min.js') }}?v=1"></script>
<script>
QRCode.toCanvas(document.getElementById("qr"), {{ qr_url|tojson }}, {
width: 120,
+28 -24
View File
@@ -11,23 +11,12 @@
{% 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];
const bits = [STATE_LABELS[state] || state];
if (busy) {
const pct = Math.round((printer.progress || 0) * 100);
bits.push(pct + " %");
@@ -52,8 +41,11 @@ function ensureTile(printer) {
<span class="host"></span>
</figcaption>
<p class="cam-meta tiny"></p>
<img alt="" data-role="cam">
<a class="cam-url" href="#"></a>`;
<div class="cam-stage">
<img alt="" data-role="cam" hidden>
<p class="cam-empty" data-role="cam-empty">${icon("camera")}<span>Keine Kamera</span></p>
</div>
<a class="ghost cam-open" href="/#${encodeURIComponent(printer.host)}">Cockpit</a>`;
tiles.set(printer.host, tile);
document.getElementById("grid").appendChild(tile);
return tile;
@@ -65,10 +57,10 @@ async function refresh() {
const grid = document.getElementById("grid");
if (!printers.length) {
tiles.clear();
grid.innerHTML = '<p class="empty">Keine Drucker eingetragen.</p>';
grid.innerHTML = `<div class="empty-state">${icon("camera", "lg")}<p>Noch keine Drucker.</p><a href="/einstellungen">Zu den Einstellungen</a></div>`;
return;
}
if (grid.querySelector(".empty")) grid.innerHTML = "";
if (grid.querySelector(".empty-state")) grid.innerHTML = "";
const seen = new Set();
for (const printer of printers) {
seen.add(printer.host);
@@ -78,16 +70,28 @@ async function refresh() {
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;
tile.querySelector(".cam-open").href = "/#" + encodeURIComponent(printer.host);
const img = tile.querySelector('[data-role="cam"]');
const empty = tile.querySelector('[data-role="cam-empty"]');
if (!printer.camera || printer.has_camera === false) {
img.removeAttribute("src");
img.hidden = true;
empty.hidden = false;
empty.innerHTML = icon("camera") + "<span>Keine Kamera</span>";
continue;
}
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;
const next = withToken(printer.camera) + "&t=" + Math.floor(Date.now() / 2000);
img.onerror = () => {
img.hidden = true;
empty.hidden = false;
empty.innerHTML = icon("camera") + "<span>Keine Kamera</span>";
};
img.onload = () => {
img.hidden = false;
empty.hidden = true;
};
img.src = next;
}
for (const [host, tile] of [...tiles.entries()]) {
if (!seen.has(host)) {
+22 -5
View File
@@ -3,9 +3,13 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="token" content="{{ token }}">
<meta name="csrf" content="{{ csrf }}">
{% if current_user %}<meta name="user-role" content="{{ current_user.role }}">{% endif %}
<title>{{ title }} · Grotti</title>
<link rel="stylesheet" href="{{ url_for('static', filename='grotti.css') }}?v=36">
<link rel="icon" type="image/png" sizes="32x32" href="{{ url_for('static', filename='brand/favicon-32.png') }}">
<link rel="icon" type="image/png" sizes="512x512" href="{{ url_for('static', filename='brand/favicon.png') }}">
<link rel="apple-touch-icon" href="{{ url_for('static', filename='brand/apple-touch-icon.png') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='grotti.css') }}?v=39">
<script>
(function () {
try {
@@ -84,7 +88,10 @@
</svg>
<aside class="sidebar">
<div class="brand-row">
<a class="brand" href="/">Grotti</a>
<a class="brand" href="/" title="Grotti">
<img class="brand-mark" src="{{ url_for('static', filename='brand/logo.png') }}" alt="" width="28" height="28" decoding="async">
<span class="brand-name">Grotti</span>
</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ü">
@@ -127,8 +134,14 @@
<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">
{% if current_user %}
<div class="side-user">
<span class="tiny" title="{{ current_user.role }}">{{ current_user.username }}</span>
<button type="button" class="ghost tiny-btn" id="logout-btn">Abmelden</button>
</div>
{% endif %}
<span class="side-copy">© by <a href="mailto:[email protected]">[email protected]</a></span>
<a href="/doku/changelog" title="Changelog">v{{ version }}</a>
<a href="/doku" title="Doku">v{{ version }}</a>
</div>
</aside>
<div class="app-main">
@@ -143,8 +156,12 @@
<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 src="{{ url_for('static', filename='api.js') }}?v=24"></script>
<script>
document.getElementById("logout-btn")?.addEventListener("click", async () => {
try { await api("/auth/logout", { method: "POST", json: {} }); } catch (e) {}
location.href = "/login";
});
(function applyShellClasses() {
const shelf = document.documentElement.classList.contains("shelf");
const collapsed = document.documentElement.classList.contains("nav-collapsed");
+76
View File
@@ -0,0 +1,76 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="csrf" content="{{ csrf }}">
<title>Anmelden · Grotti</title>
<link rel="icon" type="image/png" sizes="32x32" href="{{ url_for('static', filename='brand/favicon-32.png') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='grotti.css') }}?v=40">
</head>
<body class="auth-body">
<main class="auth-card">
<h1 class="auth-brand"><img src="{{ url_for('static', filename='brand/logo.png') }}" alt="" width="40" height="40" decoding="async"> Grotti</h1>
<figure class="auth-quote" id="auth-quote" hidden>
<blockquote id="auth-quote-text"></blockquote>
<figcaption id="auth-quote-by"></figcaption>
</figure>
<form id="login-form" class="stack">
<div class="field">
<label for="username">Benutzer</label>
<input id="username" name="username" autocomplete="username" required autofocus>
</div>
<div class="field">
<label for="password">Passwort</label>
<input id="password" name="password" type="password" autocomplete="current-password" required>
</div>
<button type="submit">Anmelden</button>
<p class="msg" id="note" hidden></p>
</form>
</main>
<script>
(function () {
const quotes = [
{ t: "Wissenschaftler entdecken die Welt, die ist; Ingenieure schaffen die Welt, die noch nie war.", a: "Theodore von Kármán" },
{ t: "Perfektion ist nicht dann erreicht, wenn man nichts mehr hinzufügen, sondern wenn man nichts mehr weglassen kann.", a: "Antoine de Saint-Exupéry" },
{ t: "Wenn man es nicht messen kann, kann man es nicht verbessern.", a: "Lord Kelvin" },
{ t: "Einfachheit ist die höchste Form der Raffinesse.", a: "Leonardo da Vinci" },
{ t: "Es gibt nichts Praktischeres als eine gute Theorie.", a: "Kurt Lewin" },
{ t: "Gott steckt im Detail.", a: "Ludwig Mies van der Rohe" },
{ t: "Erst das Problem lösen. Dann den Code schreiben.", a: "John Johnson" },
{ t: "Qualität heißt, es von Anfang an richtig zu machen.", a: "W. Edwards Deming" },
{ t: "Wer misst, misst Mist — wer nicht misst, steuert blind.", a: "Werkstattweisheit" },
{ t: "Simplicity is a great virtue, but it requires hard work to achieve it.", a: "Edsger W. Dijkstra" },
];
const pick = quotes[Math.floor(Date.now() / 86400000) % quotes.length];
document.getElementById("auth-quote-text").textContent = pick.t;
document.getElementById("auth-quote-by").textContent = "— " + pick.a;
document.getElementById("auth-quote").hidden = false;
})();
document.getElementById("login-form").addEventListener("submit", async (event) => {
event.preventDefault();
const note = document.getElementById("note");
note.hidden = true;
try {
const response = await fetch("/api/v1/auth/login", {
method: "POST",
credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
username: document.getElementById("username").value,
password: document.getElementById("password").value,
}),
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload.error || response.statusText);
const next = new URLSearchParams(location.search).get("next") || "/";
location.href = next.startsWith("/") ? next : "/";
} catch (error) {
note.hidden = false;
note.textContent = error.message || String(error);
note.className = "msg";
}
});
</script>
</body>
</html>
+65
View File
@@ -0,0 +1,65 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="csrf" content="{{ csrf }}">
<title>Setup · Grotti</title>
<link rel="icon" type="image/png" sizes="32x32" href="{{ url_for('static', filename='brand/favicon-32.png') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='grotti.css') }}?v=40">
</head>
<body class="auth-body">
<main class="auth-card">
<h1 class="auth-brand"><img src="{{ url_for('static', filename='brand/logo.png') }}" alt="" width="40" height="40" decoding="async"> Grotti einrichten</h1>
<p class="tiny">Lege den ersten Administrator an. Danach ist Login Pflicht.</p>
<form id="setup-form" class="stack">
<div class="field">
<label for="username">Admin-Benutzer</label>
<input id="username" name="username" autocomplete="username" required autofocus minlength="2">
</div>
<div class="field">
<label for="password">Passwort</label>
<input id="password" name="password" type="password" autocomplete="new-password" required minlength="8">
</div>
<div class="field">
<label for="password2">Passwort wiederholen</label>
<input id="password2" name="password2" type="password" autocomplete="new-password" required minlength="8">
</div>
<button type="submit">Admin anlegen</button>
<p class="msg" id="note" hidden></p>
</form>
</main>
<script>
document.getElementById("setup-form").addEventListener("submit", async (event) => {
event.preventDefault();
const note = document.getElementById("note");
note.hidden = true;
const password = document.getElementById("password").value;
if (password !== document.getElementById("password2").value) {
note.hidden = false;
note.textContent = "Passwörter stimmen nicht überein.";
note.className = "msg";
return;
}
try {
const response = await fetch("/api/v1/auth/setup", {
method: "POST",
credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
username: document.getElementById("username").value,
password,
}),
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload.error || response.statusText);
location.href = "/";
} catch (error) {
note.hidden = false;
note.textContent = error.message || String(error);
note.className = "msg";
}
});
</script>
</body>
</html>
+33 -20
View File
@@ -49,7 +49,7 @@
<div class="desk-facts" data-role="facts"></div>
<div data-role="flow"></div>
<div data-role="strips"></div>
<details class="desk-fold desk-steer-fold" data-fold="steer" open>
<details class="desk-fold desk-steer-fold" data-fold="steer">
<summary class="desk-fold-sum"><svg class="ico caret"><use href="#i-down"/></svg><span class="grow">Steuerung</span></summary>
<div data-role="steer"></div>
</details>
@@ -61,7 +61,7 @@
</aside>
</div>
<div class="desk-tools">
<details class="desk-fold desk-tool" data-fold="path" open>
<details class="desk-fold desk-tool" data-fold="path">
<summary class="desk-fold-sum"><svg class="ico caret"><use href="#i-down"/></svg><span class="grow">3D-Pfad</span><span class="tiny" data-role="path-caption"></span></summary>
<div class="desk-fold-body">
<p class="tiny">Ziehen dreht · Rad zoomt · Rechtsklick/Shift schwenkt · Doppelklick setzt zurück</p>
@@ -75,7 +75,7 @@
</div>
</div>
</details>
<details class="desk-fold desk-tool desk-tool-mesh" data-fold="mesh" open>
<details class="desk-fold desk-tool desk-tool-mesh" data-fold="mesh">
<summary class="desk-fold-sum"><svg class="ico caret"><use href="#i-down"/></svg><span class="grow">Bettnetz</span></summary>
<div class="desk-fold-body" data-role="mesh">
<p class="desk-empty"><svg class="ico"><use href="#i-grid"/></svg><span>Noch kein Bettnetz</span></p>
@@ -88,16 +88,15 @@
<script src="{{ url_for('static', filename='three.min.js') }}?v=1"></script>
<script src="{{ url_for('static', filename='gcode-viewer.js') }}?v=8"></script>
<script>
const labels = {
printing: "druckt", paused: "pausiert", complete: "fertig", standby: "bereit",
heating: "heizt", error: "Fehler", cancelled: "abgebrochen", offline: "offline"
};
const labels = STATE_LABELS;
const rank = { printing: 0, paused: 1, error: 2, heating: 3, complete: 4, cancelled: 5, standby: 6, offline: 7 };
const DESK_STATES = ["printing", "paused", "complete", "standby", "heating", "error", "cancelled", "offline"];
let deskHost = "";
let hideOffline = false;
let libraryFiles = [];
let wallSpools = [];
let deskViewer = null;
let deskCamFails = 0;
let deskViewerFileId = null;
let deskExcludeSelected = "";
const wallPullTried = new Set();
@@ -123,9 +122,9 @@ function syncDeskCamCollapsed() {
}
function restoreDeskFolds() {
let closed = [];
try { closed = JSON.parse(localStorage.getItem(DESK_FOLD_KEY) || "[]"); } catch (error) { closed = []; }
if (!Array.isArray(closed)) closed = [];
let closed;
try { closed = JSON.parse(localStorage.getItem(DESK_FOLD_KEY) || "null"); } catch (error) { closed = null; }
if (!Array.isArray(closed)) closed = ["steer", "path", "mesh"];
deskEl.querySelectorAll("details[data-fold]").forEach((box) => {
box.open = !closed.includes(box.dataset.fold);
box.addEventListener("toggle", () => {
@@ -193,13 +192,6 @@ function heatMapHtml(printer) {
return `<div class="mesh desk-mesh"><p>Aktuelles Netz, oben ist hinten. ${low.toFixed(2)} bis ${high.toFixed(2)} mm</p><div class="gridmesh" style="grid-template-columns:repeat(${rows[0].length},1fr)">${cells}</div><p class="tiny">${bits.join(" · ")}</p></div>`;
}
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 jobName(filename) {
return (filename || "").replace(/\.gcode$/i, "").replace(/^(\d+-)+/, "");
}
@@ -575,13 +567,14 @@ function refreshDeskCamera(printer) {
const img = deskEl.querySelector('[data-role="camera"]');
const empty = deskEl.querySelector('[data-role="cam-empty"]');
if (!img || !empty) return;
if (!printer || !printer.camera) {
if (!printer || !printer.camera || printer.has_camera === false) {
img.removeAttribute("src");
img.hidden = true;
empty.hidden = false;
empty.innerHTML = icon("camera") + "<span>Keine Kamera</span>";
setDeskCameraMode(false, "keine");
img.dataset.srcKey = "";
deskCamFails = 0;
return;
}
// Layout (no-cam) nicht bei jedem Frame-Fehler umwerfen — sonst springt die
@@ -593,6 +586,7 @@ function refreshDeskCamera(printer) {
const probe = new Image();
probe.onload = () => {
if (img.dataset.srcKey !== next) return;
deskCamFails = 0;
img.src = next;
img.hidden = false;
empty.hidden = true;
@@ -600,7 +594,16 @@ function refreshDeskCamera(printer) {
};
probe.onerror = () => {
if (img.dataset.srcKey !== next) return;
// Altes Bild behalten; nur wenn noch nie was kam, Platzhalter zeigen
deskCamFails += 1;
if (deskCamFails >= 2) {
img.removeAttribute("src");
img.hidden = true;
empty.hidden = false;
empty.innerHTML = icon("camera") + "<span>Keine Kamera</span>";
setDeskCameraMode(false, "offline");
img.dataset.srcKey = "";
return;
}
if (!img.src || img.hidden) {
empty.hidden = false;
empty.innerHTML = icon("camera") + "<span>Kamera nicht erreichbar</span>";
@@ -652,7 +655,9 @@ function patchDesk(printer) {
deskEl.dataset.host = printer.host;
deskEl.dataset.filename = printer.filename || "";
deskEl.dataset.progress = String(printer.progress || 0);
deskEl.className = "desk " + state;
deskEl.classList.remove(...DESK_STATES);
deskEl.classList.add(state);
syncDeskCamCollapsed();
const role = (name) => deskEl.querySelector(`[data-role=${name}]`);
const nameEl = role("name");
@@ -1006,6 +1011,14 @@ async function refresh() {
document.getElementById("summary").innerHTML = summaryText(all) || "keine Drucker";
document.getElementById("when").textContent = new Date().toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" });
if (!all.length) {
lastFleetKey = "";
cardNodes.clear();
cardsEl.innerHTML = `<div class="empty-state">${icon("printer", "lg")}<p>Noch keine Drucker.</p><p class="tiny">Unter Einstellungen anlegen.</p><a href="/einstellungen">Zu den Einstellungen</a></div>`;
if (deskHost) closeDesk();
return;
}
const sections = ensureFleetShell(printers);
const seen = new Set();
for (const printer of printers) {