Files
grotti/web/templates/einstellungen.html
T
Dominic Gundel db6a85a703 Grotti 3.7.0: Treiber für Kobra 4 und Marlin, smartere Dateien-Auswahl und einstellbare Etiketten.
Senden und Abbruch laufen über den Treiber, Orca-Doppelzeilen und tote Ideen-API fallen weg, Drucker folgen der Datei, Etikettenformat liegt in den Einstellungen.
2026-11-02 14:03:57 +01:00

1218 lines
49 KiB
HTML
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{% extends "layout.html" %}
{% block body %}
<div class="page-head">
<div>
<h1>Einstellungen</h1>
<p class="tiny">Drucker, Benachrichtigungen und Energiekosten.</p>
</div>
<p class="settings-save-hint" id="settings-autosave-hint" aria-live="polite"></p>
</div>
<div class="settings-layout">
<nav class="settings-nav" aria-label="Abschnitte">
<a href="#sec-browser"><svg class="ico"><use href="#i-monitor"/></svg>Browser</a>
<a href="#sec-auto"><svg class="ico"><use href="#i-zap"/></svg>Automatik</a>
<a href="#sec-slice" class="admin-only"><svg class="ico"><use href="#i-folder"/></svg>Slice</a>
<a href="#sec-fqdn" class="admin-only"><svg class="ico"><use href="#i-link"/></svg>Adresse</a>
<a href="#sec-etikett" class="admin-only"><svg class="ico"><use href="#i-tag"/></svg>Etiketten</a>
<a href="#sec-drucker" class="admin-only"><svg class="ico"><use href="#i-printer"/></svg>Drucker</a>
<a href="#sec-meldungen" class="admin-only"><svg class="ico"><use href="#i-alert"/></svg>Meldungen</a>
<a href="#sec-telegram" class="admin-only"><svg class="ico"><use href="#i-message"/></svg>Telegram</a>
<a href="#sec-ntfy" class="admin-only"><svg class="ico"><use href="#i-bell"/></svg>ntfy</a>
<a href="#sec-backup" class="admin-only"><svg class="ico"><use href="#i-hard-drive"/></svg>Backup</a>
<a href="#sec-energie" class="admin-only"><svg class="ico"><use href="#i-zap"/></svg>Energie</a>
<a href="#sec-users" class="admin-only"><svg class="ico"><use href="#i-users"/></svg>Benutzer</a>
<a href="#sec-tokens"><svg class="ico"><use href="#i-key"/></svg>API-Tokens</a>
</nav>
<div class="settings-main">
<section class="card" id="sec-browser">
<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="grid-2 narrow-fields">
<div class="field">
<label for="display-mode">Anzeige</label>
<select id="display-mode">
<option value="desktop">Desktop</option>
<option value="shelf">Regal (Touch)</option>
</select>
</div>
<div class="field">
<label for="theme-mode">Darstellung</label>
<select id="theme-mode">
<option value="dark">Dunkel</option>
<option value="light">Hell</option>
<option value="system">System</option>
</select>
</div>
<div class="field">
<label for="tz-mode">Zeitzone</label>
<select id="tz-mode">
<option value="auto">Browser (automatisch)</option>
<option value="Europe/Berlin">Europe/Berlin</option>
<option value="Europe/Vienna">Europe/Vienna</option>
<option value="Europe/Zurich">Europe/Zurich</option>
<option value="UTC">UTC</option>
</select>
</div>
<div class="field">
<label for="camera-mode">Kamera</label>
<select id="camera-mode">
<option value="snapshot">Snapshot (schonend)</option>
<option value="stream">Stream (flüssig)</option>
</select>
</div>
</div>
<p class="hint">Regal: größere Bedienelemente — Shortcut <code>?shelf=1</code>. Darstellung, Zeitzone und Kamera nur dieser Browser. Stream hält eine Verbindung je sichtbarer Kamera.</p>
<p id="display-note" class="ok" hidden>Gespeichert — gilt sofort.</p>
</section>
<section class="card" id="sec-auto">
<h2>Automatik</h2>
<label class="check"><input type="checkbox" id="auto-queue"> Warteschlange „wenn frei“ und Abläufe automatisch starten</label>
<label class="check"><input type="checkbox" id="auto-retry"> Automatisch einmal nochmal versuchen bei Fehler</label>
<p class="hint">Aus = keine neuen Starts. Laufende Abläufe und gesperrte Drucker bleiben unberührt. Änderungen speichern sich selbst.</p>
<div class="actions"><button type="button" id="auto-save" hidden>Speichern</button></div>
<p id="auto-note" class="settings-save-hint"></p>
</section>
<section class="card admin-only" id="sec-slice">
<h2>Slice-Ordner</h2>
<p class="hint">Ordner, den Grotti für die Bibliothek beobachtet (Orca-Export, „Ordner neu lesen“). Leer = nur manueller Upload. Standard: Ordner <code>slice</code> im Grotti-Repo.</p>
<div class="field" style="max-width:36rem">
<label for="watch-dir">Pfad</label>
<input id="watch-dir" type="text" autocomplete="off" placeholder="D:\Projekte\Grotti\slice" spellcheck="false">
</div>
<div class="actions">
<button type="button" id="watch-save" hidden>Speichern</button>
<button type="button" class="ghost" id="watch-default">Standardordner</button>
</div>
<p id="watch-note"></p>
</section>
<section class="card admin-only" id="sec-fqdn">
<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">
<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" hidden>Speichern</button></div>
<p id="fqdn-note"></p>
</section>
<section class="card admin-only" id="sec-etikett">
<h2>Etiketten</h2>
<p class="hint">Papierformat für Spulen- und Drucketiketten (Browser-Druck). Standard 70×40 mm. Der QR schrumpft auf flacheren Rollen mit.</p>
<div class="field" style="max-width:16rem">
<label for="etikett-preset">Voreinstellung</label>
<select id="etikett-preset">
<option value="">Eigene Maße</option>
</select>
</div>
<div class="grid-2" style="max-width:20rem">
<div class="field">
<label for="etikett-w">Breite (mm)</label>
<input id="etikett-w" type="number" min="20" max="200" step="0.1" value="70">
</div>
<div class="field">
<label for="etikett-h">Höhe (mm)</label>
<input id="etikett-h" type="number" min="15" max="200" step="0.1" value="40">
</div>
</div>
<div class="actions"><button type="button" id="etikett-save" hidden>Speichern</button></div>
<p id="etikett-note"></p>
</section>
<section class="card admin-only" id="sec-drucker">
<h2>Drucker</h2>
<p class="hint">Gesperrt = kein neuer „wenn frei“-Start. Zeitfenster begrenzt Starts (z. B. Nachtstrom). Tippen öffnet den Dialog.</p>
<p class="hint">USB (Anycubic i3, Tenlog): Schnittstelle statt IP, zum Beispiel <code>COM5</code> oder <code>COM5@250000</code>. Grotti startet den USB-Dienst dazu selbst und lässt ihn bei einem Neustart weiterlaufen.</p>
<p class="hint" id="marlin-line" hidden></p>
<div id="printer-list" class="printer-edit-cards"></div>
<form id="printer-add" class="grid-2 section-gap">
<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" id="add-host-label">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>
<option value="kobra_4">Kobra 4</option>
<option value="anycubic_i3">Anycubic i3</option>
<option value="tenlog">Tenlog</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 admin-only" id="sec-meldungen">
<h2>Problem-Meldungen</h2>
<p class="hint">Kopien von „Problem melden“. Offene Einträge hier erledigen. Issues landen beim Entwickler über den Empfänger auf git.gundel.dev.</p>
<div id="meldung-list" class="printer-edit-cards"></div>
<p id="meldung-admin-note" class="tiny"></p>
</section>
<section class="card admin-only" id="sec-telegram">
<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" hidden>Speichern</button>
<button type="button" class="ghost" id="tg-test">Test senden</button>
</div>
<p id="tg-note"></p>
</section>
<section class="card admin-only" id="sec-ntfy">
<h2>ntfy</h2>
<label class="check"><input type="checkbox" id="ntfy-enabled"> Push über ntfy</label>
<div class="grid-2" style="margin-top:0.75rem">
<div class="field">
<label for="ntfy-server">Server</label>
<input id="ntfy-server" placeholder="https://ntfy.sh" autocomplete="off">
</div>
<div class="field">
<label for="ntfy-topic">Topic</label>
<input id="ntfy-topic" placeholder="grotti-werkstatt" autocomplete="off">
</div>
<div class="field" style="grid-column:1/-1">
<label for="ntfy-token">Token (optional)</label>
<input id="ntfy-token" type="password" autocomplete="off" placeholder="Bearer-Token">
</div>
</div>
<p class="hint">Gleiche Meldungen wie Telegram — unabhängig abschaltbar. Push in der ntfy-App auf dem Handy.</p>
<div class="actions">
<button type="button" id="ntfy-save" hidden>Speichern</button>
<button type="button" class="ghost" id="ntfy-test">Test senden</button>
</div>
<p id="ntfy-note"></p>
</section>
<section class="card admin-only" id="sec-backup">
<h2>Backup</h2>
<p class="hint">PostgreSQL-Dump und Datenordner (ohne toolpath/ und alte Backups).</p>
<div class="actions">
<button type="button" id="backup-now">Backup jetzt</button>
</div>
<ul id="backup-list" class="tiny"></ul>
<p id="backup-note"></p>
</section>
<section class="card admin-only" id="sec-energie">
<h2>Energie</h2>
<p class="hint">Kosten im Protokoll. Mit Düse-/Bett-Watt und ED-Proben schätzt Grotti den Verbrauch; sonst Dauer × Grundlast. Filament braucht eine Spule.</p>
<div class="grid-2">
<div class="field"><label for="en-watts">Grundlast W</label><input id="en-watts" type="number" step="1" title="Elektronik/Motion — nicht die Heizer"></div>
<div class="field"><label for="en-kwh">€ / kWh</label><input id="en-kwh" type="number" step="0.01"></div>
<div class="field"><label for="en-nozzle">Düse W</label><input id="en-nozzle" type="number" step="1" placeholder="z. B. 60"></div>
<div class="field"><label for="en-bed">Bett W</label><input id="en-bed" type="number" step="1" placeholder="z. B. 220"></div>
<div class="field"><label for="en-dryer">Trockner W</label><input id="en-dryer" type="number" step="1"></div>
<div class="field"><label for="en-dryh">Trockenstunden</label><input id="en-dryh" type="number" step="0.1"></div>
<div class="field"><label for="en-waste">Ausschuss %</label><input id="en-waste" type="number" step="0.1"></div>
<div class="field"><label for="en-util">Auslastung %</label><input id="en-util" type="number" step="1"></div>
</div>
<h3 class="section-gap">HT / NT-Tarif</h3>
<label class="check"><input type="checkbox" id="en-tariff"> Unterschiedliche Preise für Tag und Nacht</label>
<div class="grid-2" id="en-tariff-fields" hidden>
<div class="field"><label for="en-ht">HT € / kWh</label><input id="en-ht" type="number" step="0.01"></div>
<div class="field"><label for="en-nt">NT € / kWh</label><input id="en-nt" type="number" step="0.01"></div>
<div class="field"><label for="en-nt-from">NT ab</label><input id="en-nt-from" type="time" value="22:00"></div>
<div class="field"><label for="en-nt-to">NT bis</label><input id="en-nt-to" type="time" value="06:00"></div>
</div>
<p class="hint">Grundlast = Elektronik/Motion. Düse/Bett 0 = nur pauschal. Mit HT/NT gewichtet Grotti die Stromkosten nach Uhrzeit.</p>
<p id="en-ed-hint" class="hint" hidden></p>
<div class="actions"><button type="button" id="en-save" hidden>Speichern</button></div>
<p id="en-note"></p>
</section>
<section class="card admin-only" id="sec-users">
<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" id="sec-tokens">
<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>
<div id="token-qr-wrap" hidden style="margin-top:0.75rem">
<p class="tiny">App-Kopplung — QR mit Server-URL und Token (nur jetzt):</p>
<canvas id="token-qr" width="180" height="180" aria-label="QR-Code"></canvas>
</div>
<p class="hint" id="legacy-token-hint" hidden>In der Config steht noch ein Shared-<code>token</code> — der wird seit 3.1 nicht mehr akzeptiert. Bitte leeren und API-Tokens (<code>grt_…</code>) nutzen.</p>
</section>
</div>
</div>
<dialog id="printer-edit-dlg" class="abraeumen-dlg">
<form method="dialog" id="printer-edit-form" class="abraeumen-form">
<h2 id="printer-edit-title">Drucker</h2>
<input type="hidden" id="pe-id">
<div class="grid-2">
<div class="field"><label for="pe-name">Name</label><input id="pe-name" required></div>
<div class="field"><label for="pe-host" id="pe-host-label">Host / IP</label><input id="pe-host" required placeholder="10.10.1.60"></div>
<div class="field"><label for="pe-family">Familie</label>
<select id="pe-family">
<option value="kobra_3">Kobra 3</option>
<option value="kobra_s1">Kobra S1</option>
<option value="kobra_4">Kobra 4</option>
<option value="anycubic_i3">Anycubic i3</option>
<option value="tenlog">Tenlog</option>
<option value="">—</option>
</select>
</div>
<div class="field"><label class="check" style="margin-top:1.4rem"><input type="checkbox" id="pe-locked"> Gesperrt</label></div>
</div>
<label class="check"><input type="checkbox" id="pe-win-on"> Nur in Zeitfenster starten</label>
<div class="grid-2" id="pe-win-fields">
<div class="field"><label for="pe-win-from">Von</label><input type="time" id="pe-win-from" value="22:00"></div>
<div class="field"><label for="pe-win-to">Bis</label><input type="time" id="pe-win-to" value="06:00"></div>
<div class="field"><label for="pe-win-max">Max. Stunden</label><input type="number" id="pe-win-max" min="0" step="0.5" placeholder="leer = egal"></div>
</div>
<div class="actions">
<button type="submit" value="ok" class="btn-primary">Speichern</button>
<button type="button" class="ghost" id="pe-cancel">Abbrechen</button>
<button type="button" class="btn-danger" id="pe-delete">Entfernen</button>
</div>
</form>
</dialog>
{% endblock %}
{% block script %}
<script src="{{ url_for('static', filename='qrcode.min.js') }}?v=1"></script>
<script>
const families = {
kobra_3: "Kobra 3", kobra_s1: "Kobra S1", kobra_4: "Kobra 4",
anycubic_i3: "Anycubic i3", tenlog: "Tenlog", "": "—",
};
const USB_FAMILIES = new Set(["anycubic_i3", "tenlog"]);
function syncHostField(family, labelEl, inputEl, nameHint) {
const usb = USB_FAMILIES.has(family)
|| (!family && /tenlog|i3/i.test(nameHint || ""));
if (labelEl) labelEl.textContent = usb ? "Schnittstelle" : "Host / IP";
if (inputEl) inputEl.placeholder = usb ? "COM5 oder COM5@250000" : "10.10.1.60";
}
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 */
function windowFrom(row) {
const sw = row.start_window || {};
const allowed = Array.isArray(sw.allowed) ? sw.allowed : [];
const first = allowed[0] || {};
return {
enabled: allowed.length > 0,
from: first.from || "22:00",
to: first.to || "06:00",
maxHours: sw.max_hours_overnight || "",
};
}
let printerRowsCache = [];
function openPrinterDialog(row) {
const dlg = document.getElementById("printer-edit-dlg");
const win = windowFrom(row);
document.getElementById("printer-edit-title").textContent = row.name || "Drucker";
document.getElementById("pe-id").value = row.id;
document.getElementById("pe-name").value = row.name || "";
document.getElementById("pe-host").value = row.host || "";
document.getElementById("pe-family").value = row.family || "";
syncHostField(row.family || "", document.getElementById("pe-host-label"), document.getElementById("pe-host"), row.name || "");
document.getElementById("pe-locked").checked = !!row.locked;
document.getElementById("pe-win-on").checked = win.enabled;
document.getElementById("pe-win-from").value = win.from;
document.getElementById("pe-win-to").value = win.to;
document.getElementById("pe-win-max").value = win.maxHours;
document.getElementById("pe-win-fields").hidden = !win.enabled;
dlg.showModal();
}
function renderPrinters(rows) {
printerRowsCache = rows || [];
const root = document.getElementById("printer-list");
root.innerHTML = "";
if (!rows.length) {
root.innerHTML = '<div class="empty-state"><svg class="ico"><use href="#i-printer"/></svg><p>Keine Drucker.</p></div>';
return;
}
rows.forEach((row) => {
const win = windowFrom(row);
const card = document.createElement("div");
card.className = "printer-edit-card";
card.innerHTML = `
<div class="grow">
<strong></strong>
<div class="tiny"></div>
</div>
<button type="button" class="ghost">Bearbeiten</button>`;
card.querySelector("strong").textContent = row.name || row.host;
card.querySelector(".tiny").textContent = [
row.host,
families[row.family] || row.family || "",
row.locked ? "gesperrt" : "",
win.enabled ? ("Fenster " + win.from + "–" + win.to) : "",
].filter(Boolean).join(" · ");
card.querySelector("button").addEventListener("click", () => openPrinterDialog(row));
card.addEventListener("dblclick", () => openPrinterDialog(row));
root.appendChild(card);
});
}
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 loadMeldungen() {
const root = document.getElementById("meldung-list");
const note = document.getElementById("meldung-admin-note");
if (!root || !isAdmin) return;
try {
const rows = await api("/meldungen?status=offen");
if (!rows.length) {
root.innerHTML = '<p class="tiny">Keine offenen Meldungen.</p>';
return;
}
root.innerHTML = rows.map((row) => `
<article class="printer-edit-card">
<div class="grow">
<strong>#${row.id}</strong>
<span class="tiny">${escapeHtml((row.created_at || "").replace("T", " ").slice(0, 16))} · ${escapeHtml(row.username || "—")} · ${escapeHtml(row.page || "")}</span>
<p>${escapeHtml(row.text || "")}</p>
<p class="tiny">${row.relay_ok ? "Beim Entwickler angekommen" : ("Lokal: " + escapeHtml(row.relay_message || "Empfänger fehlte"))}</p>
</div>
<button type="button" class="ghost" data-meldung-done="${row.id}">Erledigt</button>
</article>`).join("");
root.querySelectorAll("[data-meldung-done]").forEach((button) => {
button.addEventListener("click", async () => {
button.disabled = true;
try {
await api("/meldungen/" + button.dataset.meldungDone + "/erledigt", { method: "POST", json: {} });
loadMeldungen();
} catch (error) {
if (note) note.textContent = error.message || String(error);
}
});
});
} catch (error) {
if (note) note.textContent = error.message || String(error);
}
}
async function loadMarlin(printers) {
const line = document.getElementById("marlin-line");
if (!line) return;
const usb = (printers || []).some((row) => row.family === "tenlog" || row.family === "anycubic_i3");
if (!usb) {
line.hidden = true;
return;
}
try {
const status = await api("/marlin-dienst");
line.hidden = false;
line.textContent = status.running
? "USB-Dienst läuft."
: (status.message || "USB-Dienst ist aus.");
} catch (err) {
line.hidden = false;
line.textContent = "USB-Dienst: " + (err.message || "keine Antwort");
}
}
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"),
]);
if (isAdmin) {
renderPrinters(printers);
loadMarlin(printers);
loadMeldungen();
}
document.getElementById("auto-queue").checked = settings.auto_queue !== false;
const ar = document.getElementById("auto-retry");
if (ar) ar.checked = settings.auto_retry !== false;
const watchDir = document.getElementById("watch-dir");
if (watchDir) {
watchDir.value = settings.watch_dir || "";
watchDir.dataset.default = settings.watch_dir_default || "";
}
const fqdn = document.getElementById("fqdn");
if (fqdn) fqdn.value = settings.fqdn || "";
fillEtikett(settings.etikett || {});
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 ntfy = settings.ntfy || {};
document.getElementById("ntfy-enabled").checked = !!ntfy.enabled;
document.getElementById("ntfy-server").value = ntfy.server || "https://ntfy.sh";
document.getElementById("ntfy-topic").value = ntfy.topic || "";
document.getElementById("ntfy-token").value = "";
document.getElementById("ntfy-token").placeholder = ntfy.token_set
? ("gesetzt: " + (ntfy.token || "••••"))
: "optional";
const en = settings.energy || {};
document.getElementById("en-watts").value = en.watts ?? 40;
document.getElementById("en-nozzle").value = en.nozzle_watts ?? 60;
document.getElementById("en-bed").value = en.bed_watts ?? 220;
document.getElementById("en-kwh").value = en.euro_per_kwh ?? 0.35;
document.getElementById("en-dryer").value = en.dryer_watts ?? 0;
document.getElementById("en-dryh").value = en.dry_hours ?? 0;
document.getElementById("en-waste").value = en.waste_percent ?? 0;
document.getElementById("en-util").value = en.utilization ?? 50;
const tariff = en.tariff || null;
document.getElementById("en-tariff").checked = !!tariff;
document.getElementById("en-tariff-fields").hidden = !tariff;
document.getElementById("en-ht").value = (tariff && tariff.ht_euro) ?? (en.euro_per_kwh ?? 0.35);
document.getElementById("en-nt").value = (tariff && tariff.nt_euro) ?? (en.euro_per_kwh ?? 0.35);
document.getElementById("en-nt-from").value = (tariff && tariff.nt_from) || "22:00";
document.getElementById("en-nt-to").value = (tariff && tariff.nt_to) || "06:00";
updateEnergyEdHint();
}
const legacy = document.getElementById("legacy-token-hint");
if (legacy) legacy.hidden = !settings.legacy_token_set;
await Promise.all([loadUsers(), loadTokens(), loadBackups()]);
}
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");
const qrWrap = document.getElementById("token-qr-wrap");
once.hidden = true;
if (qrWrap) qrWrap.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 oder QR scannen.");
loadTokens();
if (out.token && window.QRCode && qrWrap) {
const canvas = document.getElementById("token-qr");
const payload = JSON.stringify({
url: location.origin,
token: out.token,
name: "Grotti",
});
try {
QRCode.toCanvas(canvas, payload, { width: 180, margin: 1, errorCorrectionLevel: "M" });
qrWrap.hidden = false;
} catch (error) { /* QR optional */ }
}
} catch (error) {
showNote("token-note", error);
}
});
document.getElementById("auto-save").addEventListener("click", async () => {
try {
await api("/einstellungen", {
method: "PATCH",
json: {
auto_queue: document.getElementById("auto-queue").checked,
auto_retry: document.getElementById("auto-retry")?.checked !== false,
},
});
showNote(
"auto-note",
null,
document.getElementById("auto-queue").checked ? "Automatik an." : "Automatik aus — nichts Neues startet."
);
} catch (error) {
showNote("auto-note", error);
}
});
document.getElementById("watch-default")?.addEventListener("click", () => {
const input = document.getElementById("watch-dir");
if (input) input.value = input.dataset.default || "";
});
document.getElementById("watch-save")?.addEventListener("click", async () => {
try {
const out = await api("/einstellungen", {
method: "PATCH",
json: { watch_dir: document.getElementById("watch-dir").value.trim() },
});
document.getElementById("watch-dir").value = out.watch_dir || "";
showNote(
"watch-note",
null,
out.watch_dir
? "Slice-Ordner aktiv: " + out.watch_dir
: "Kein Slice-Ordner — nur manueller Upload."
);
} catch (error) {
showNote("watch-note", error);
}
});
document.getElementById("fqdn-save").addEventListener("click", async () => {
try {
const out = await api("/einstellungen", {
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);
}
});
let etikettPresets = [];
function fillEtikett(etikett) {
const w = document.getElementById("etikett-w");
const h = document.getElementById("etikett-h");
const preset = document.getElementById("etikett-preset");
if (!w || !h || !preset) return;
etikettPresets = Array.isArray(etikett.presets) ? etikett.presets : etikettPresets;
const width = Number(etikett.width_mm) || 70;
const height = Number(etikett.height_mm) || 40;
w.value = String(width);
h.value = String(height);
const current = preset.value;
preset.innerHTML = '<option value="">Eigene Maße</option>';
etikettPresets.forEach((row) => {
const option = document.createElement("option");
option.value = row.id;
option.textContent = row.label;
option.dataset.w = String(row.width_mm);
option.dataset.h = String(row.height_mm);
preset.appendChild(option);
});
const hit = etikettPresets.find(
(row) => Number(row.width_mm) === width && Number(row.height_mm) === height
);
preset.value = hit ? hit.id : (current && [...preset.options].some((o) => o.value === current) ? current : "");
}
function syncEtikettPresetFromFields() {
const preset = document.getElementById("etikett-preset");
const width = Number(document.getElementById("etikett-w").value);
const height = Number(document.getElementById("etikett-h").value);
const hit = etikettPresets.find(
(row) => Number(row.width_mm) === width && Number(row.height_mm) === height
);
if (preset) preset.value = hit ? hit.id : "";
}
document.getElementById("etikett-preset")?.addEventListener("change", () => {
const preset = document.getElementById("etikett-preset");
const option = preset.selectedOptions[0];
if (!option || !option.value) return;
document.getElementById("etikett-w").value = option.dataset.w || "70";
document.getElementById("etikett-h").value = option.dataset.h || "40";
document.getElementById("etikett-save")?.click();
});
["etikett-w", "etikett-h"].forEach((id) => {
document.getElementById(id)?.addEventListener("change", () => {
syncEtikettPresetFromFields();
});
});
document.getElementById("etikett-save").addEventListener("click", async () => {
try {
const out = await api("/einstellungen", {
method: "PATCH",
json: {
etikett: {
width_mm: Number(document.getElementById("etikett-w").value) || 70,
height_mm: Number(document.getElementById("etikett-h").value) || 40,
},
},
});
fillEtikett(out.etikett || {});
const e = out.etikett || {};
showNote(
"etikett-note",
null,
"Gespeichert — " + (e.width_mm || 70) + " × " + (e.height_mm || 40) + " mm (QR " + (e.qr_mm || 20) + " mm)."
);
} catch (error) {
showNote("etikett-note", error);
}
});
function syncAddHost() {
syncHostField(
document.getElementById("add-family").value,
document.getElementById("add-host-label"),
document.getElementById("add-host"),
document.getElementById("add-name").value,
);
}
document.getElementById("add-family")?.addEventListener("change", syncAddHost);
document.getElementById("add-name")?.addEventListener("input", syncAddHost);
document.getElementById("pe-family")?.addEventListener("change", () => {
syncHostField(
document.getElementById("pe-family").value,
document.getElementById("pe-host-label"),
document.getElementById("pe-host"),
document.getElementById("pe-name").value,
);
});
document.getElementById("pe-name")?.addEventListener("input", () => {
syncHostField(
document.getElementById("pe-family").value,
document.getElementById("pe-host-label"),
document.getElementById("pe-host"),
document.getElementById("pe-name").value,
);
});
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("ntfy-save")?.addEventListener("click", async () => {
const token = document.getElementById("ntfy-token").value.trim();
const payload = {
ntfy: {
enabled: document.getElementById("ntfy-enabled").checked,
server: document.getElementById("ntfy-server").value.trim() || "https://ntfy.sh",
topic: document.getElementById("ntfy-topic").value.trim(),
},
};
if (token) payload.ntfy.token = token;
try {
await api("/einstellungen", { method: "PATCH", json: payload });
showNote("ntfy-note", null, "ntfy gespeichert.");
load();
} catch (error) {
showNote("ntfy-note", error);
}
});
async function loadBackups() {
const list = document.getElementById("backup-list");
if (!list) return;
try {
const out = await api("/backup");
list.innerHTML = (out.items || []).slice(0, 8).map((item) => {
const mb = ((item.size || 0) / (1024 * 1024)).toLocaleString("de-DE", { maximumFractionDigits: 1 });
return `<li><a href="/backup/${encodeURIComponent(item.name)}" download>${escapeHtml(item.name)}</a> · ${mb} MB</li>`;
}).join("") || "<li>Noch kein Backup.</li>";
} catch (error) {
list.innerHTML = "<li>Liste nicht verfügbar.</li>";
}
}
document.getElementById("backup-now")?.addEventListener("click", async () => {
try {
const out = await api("/backup", { method: "POST", json: {} });
showNote("backup-note", null, "Backup erstellt: " + (out.name || ""));
loadBackups();
} catch (error) {
showNote("backup-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("ntfy-test")?.addEventListener("click", async () => {
try {
await api("/ntfy/test", { method: "POST", json: {} });
showNote("ntfy-note", null, "Testnachricht gesendet.");
} catch (error) {
showNote("ntfy-note", error);
}
});
document.getElementById("en-tariff")?.addEventListener("change", (event) => {
document.getElementById("en-tariff-fields").hidden = !event.target.checked;
});
function updateEnergyEdHint() {
const hint = document.getElementById("en-ed-hint");
if (!hint) return;
const nozzle = Number(document.getElementById("en-nozzle").value) || 0;
const bed = Number(document.getElementById("en-bed").value) || 0;
if (nozzle > 0 && bed > 0) {
hint.hidden = true;
hint.textContent = "";
return;
}
hint.hidden = false;
hint.textContent = "Düse oder Bett noch 0 — Bericht bleibt „pauschal“. Für ED z. B. Düse 60 W und Bett 220 W setzen.";
}
["en-nozzle", "en-bed"].forEach((id) => {
const node = document.getElementById(id);
if (node) node.addEventListener("input", updateEnergyEdHint);
});
document.getElementById("en-save").addEventListener("click", async () => {
try {
const nozzle = Number(document.getElementById("en-nozzle").value) || 0;
const bed = Number(document.getElementById("en-bed").value) || 0;
const energy = {
watts: Number(document.getElementById("en-watts").value),
nozzle_watts: nozzle,
bed_watts: bed,
euro_per_kwh: Number(document.getElementById("en-kwh").value),
dryer_watts: Number(document.getElementById("en-dryer").value),
dry_hours: Number(document.getElementById("en-dryh").value),
waste_percent: Number(document.getElementById("en-waste").value),
utilization: Number(document.getElementById("en-util").value),
};
if (document.getElementById("en-tariff").checked) {
energy.tariff = {
ht_euro: Number(document.getElementById("en-ht").value),
nt_euro: Number(document.getElementById("en-nt").value),
nt_from: document.getElementById("en-nt-from").value || "22:00",
nt_to: document.getElementById("en-nt-to").value || "06:00",
};
}
await api("/einstellungen", { method: "PATCH", json: { energy } });
updateEnergyEdHint();
if (nozzle <= 0 || bed <= 0) {
const note = document.getElementById("en-note");
if (note) {
note.textContent = "Gespeichert. Düse/Bett 0 → nur pauschal. Für ED z. B. 60 / 220 W setzen.";
note.className = "msg";
}
toast("Energie gespeichert — ED noch aus (Düse/Bett 0).", "bad");
} else {
showNote("en-note", null, "Energie gespeichert.");
}
} catch (error) {
showNote("en-note", error);
}
});
function flashDisplayNote() {
const note = document.getElementById("display-note");
if (!note) return;
note.hidden = false;
setTimeout(() => { note.hidden = true; }, 2500);
}
(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");
}
flashDisplayNote();
});
})();
(function initThemeMode() {
const sel = document.getElementById("theme-mode");
if (!sel) return;
try {
const value = localStorage.getItem("grotti-theme") || "dark";
sel.value = ["dark", "light", "system"].includes(value) ? value : "dark";
} catch (error) {
sel.value = "dark";
}
sel.addEventListener("change", () => {
try { localStorage.setItem("grotti-theme", sel.value); } catch (error) {}
if (typeof applyTheme === "function") applyTheme(sel.value);
else {
const light = sel.value === "light" || (sel.value === "system" && window.matchMedia("(prefers-color-scheme: light)").matches);
document.documentElement.dataset.theme = light ? "light" : "dark";
document.documentElement.style.colorScheme = light ? "light" : "dark";
}
flashDisplayNote();
});
})();
(function initTimeZone() {
const sel = document.getElementById("tz-mode");
if (!sel) return;
const known = ["auto", "Europe/Berlin", "Europe/Vienna", "Europe/Zurich", "UTC"];
try {
const value = localStorage.getItem(TZ_KEY) || "auto";
sel.value = known.includes(value) ? value : "auto";
} catch (error) {
sel.value = "auto";
}
sel.addEventListener("change", () => {
try { localStorage.setItem(TZ_KEY, sel.value || "auto"); } catch (error) {}
const note = document.getElementById("display-note");
if (note) {
note.hidden = false;
setTimeout(() => { note.hidden = true; }, 2500);
}
});
})();
(function initCameraMode() {
const sel = document.getElementById("camera-mode");
if (!sel || typeof cameraMode !== "function") return;
sel.value = cameraMode();
sel.addEventListener("change", () => {
setCameraMode(sel.value);
flashDisplayNote();
});
window.addEventListener("grotti-camera-mode", () => {
sel.value = cameraMode();
});
})();
document.getElementById("pe-win-on")?.addEventListener("change", () => {
document.getElementById("pe-win-fields").hidden = !document.getElementById("pe-win-on").checked;
});
document.getElementById("pe-cancel")?.addEventListener("click", () => {
document.getElementById("printer-edit-dlg").close();
});
document.getElementById("printer-edit-form")?.addEventListener("submit", async (event) => {
event.preventDefault();
const id = document.getElementById("pe-id").value;
const on = document.getElementById("pe-win-on").checked;
const start_window = {};
if (on) {
start_window.allowed = [{
from: document.getElementById("pe-win-from").value || "22:00",
to: document.getElementById("pe-win-to").value || "06:00",
}];
const maxH = Number(document.getElementById("pe-win-max").value);
if (maxH > 0) start_window.max_hours_overnight = maxH;
}
try {
await api("/drucker/id/" + id, {
method: "PATCH",
json: {
name: document.getElementById("pe-name").value,
host: document.getElementById("pe-host").value,
family: document.getElementById("pe-family").value,
locked: document.getElementById("pe-locked").checked,
start_window,
},
});
document.getElementById("printer-edit-dlg").close();
toast("Drucker gespeichert.");
markAutosaved();
load();
} catch (error) {
showNote("printer-note", error);
}
});
document.getElementById("pe-delete")?.addEventListener("click", (event) => {
const id = document.getElementById("pe-id").value;
armButton(event.currentTarget, "Wirklich entfernen?", async () => {
try {
await api("/drucker/id/" + id, { method: "DELETE" });
document.getElementById("printer-edit-dlg").close();
toast("Drucker entfernt.");
load();
} catch (error) {
showNote("printer-note", error);
}
});
});
function markAutosaved() {
const el = document.getElementById("settings-autosave-hint");
if (!el) return;
el.textContent = "Gespeichert ✓";
clearTimeout(markAutosaved._t);
markAutosaved._t = setTimeout(() => { el.textContent = ""; }, 2200);
}
function wireAutosave(triggerId, noteId) {
const btn = document.getElementById(triggerId);
if (!btn) return;
const run = () => {
btn.click();
markAutosaved();
if (noteId) {
const note = document.getElementById(noteId);
if (note) note.textContent = "Gespeichert ✓";
}
};
let timer = 0;
return (event) => {
if (event && event.target && event.target.id === triggerId) return;
clearTimeout(timer);
timer = setTimeout(run, 450);
};
}
document.getElementById("auto-queue")?.addEventListener("change", wireAutosave("auto-save", "auto-note"));
document.getElementById("auto-retry")?.addEventListener("change", wireAutosave("auto-save", "auto-note"));
["watch-dir"].forEach((id) => {
document.getElementById(id)?.addEventListener("change", wireAutosave("watch-save", "watch-note"));
});
document.getElementById("fqdn")?.addEventListener("change", wireAutosave("fqdn-save", "fqdn-note"));
["etikett-w", "etikett-h"].forEach((id) => {
document.getElementById(id)?.addEventListener("change", wireAutosave("etikett-save", "etikett-note"));
});
["tg-enabled", "tg-token", "tg-chat", "al-fertig", "al-fehler", "al-offline", "al-queue", "al-ablauf", "al-start"].forEach((id) => {
document.getElementById(id)?.addEventListener("change", wireAutosave("tg-save", "tg-note"));
});
["ntfy-enabled", "ntfy-server", "ntfy-topic", "ntfy-token"].forEach((id) => {
document.getElementById(id)?.addEventListener("change", wireAutosave("ntfy-save", "ntfy-note"));
});
["en-watts", "en-kwh", "en-nozzle", "en-bed", "en-dryer", "en-dryh", "en-waste", "en-util", "en-tariff", "en-ht", "en-nt", "en-nt-from", "en-nt-to"].forEach((id) => {
document.getElementById(id)?.addEventListener("change", wireAutosave("en-save", "en-note"));
});
document.querySelectorAll(".settings-nav a").forEach((a) => {
a.addEventListener("click", () => {
document.querySelectorAll(".settings-nav a").forEach((x) => x.classList.remove("on"));
a.classList.add("on");
});
});
load().catch((error) => showNote("printer-note", error));
</script>
{% endblock %}