Grotti 3.1.0: Bedarf-UI, Zeitfenster, HT/NT, Theme, Chrome-Fixes.

Bedarf in Sidebar/Material, Drucker-Zeitfenster und Tarif in Einstellungen, helles Theme; Legacy-Token und /api/drucker entfernt; SSE/SW-Fixes gegen hängende Navigation in Chrome.
This commit is contained in:
Dominic Gundel committed 2026-10-18 02:20:12 +02:00
1 parent 8e88efc094
commit 6e59df22e5
23 files changed
+692 -122

No files matched your search

+24
View File
@@ -4,6 +4,30 @@ Alle Änderungen an Grotti. Format nach [Keep a Changelog](https://keepachangelo
## [Unreleased]
## [3.1.0] – 2026-10-18
### Neu
- **Bedarf in der UI:** Sidebar-Warnung, Karte unter Material, Chip in der Warteschlange.
- **Start-Zeitfenster** je Drucker in den Einstellungen (Queue nur im Fenster).
- **HT/NT-Tarif** in den Energie-Einstellungen.
- **ntfy-Test** wie bei Telegram.
- **Helles Theme** (Dunkel / Hell / System) pro Browser.
- Statistik: Balkendiagramme Druckzeit/Verbrauch, Empty-State ohne Daten.
- Wartung: KPIs, Empty-State, Reset mit Bestätigung (`armButton`).
### Geändert
- Cockpit und Warteschlange nutzen SSE/`createPoller` statt hartem 5‑s-Intervall.
- Shared-`config.token` wird nicht mehr als Auth akzeptiert — nur noch Session und `grt_…`-Tokens.
- Legacy-Alias `GET /api/drucker` entfernt (nur `/api/v1/drucker`).
### Behoben
- SSE unter waitress: Hop-by-Hop-Header `Connection` entfernt (sonst Fehler auf `/api/v1/stream`).
- Bedarf: Material/Farbe aus `files.filaments` statt nicht existierender Spalten.
- Chrome: Service-Worker lieferte bei Fehlern die Startseite statt der angeklickten Route; „Mehr“-Dialog blockierte die Navigation; SSE und Poller liefen parallel und stauten Verbindungen.
## [3.0.0] – 2026-10-18
Major: neue Tabellen (Aufträge, Wartung), Projekte/Ideen → Aufträge, Navigation umgebaut, Dev-Server → waitress, Schema über Migrationen.
+7 -1
View File
@@ -23,7 +23,13 @@
"dryer_watts": 0,
"dry_hours": 0,
"waste_percent": 0,
"utilization": 50
"utilization": 50,
"tariff": {
"ht_euro": 0.35,
"nt_euro": 0.22,
"nt_from": "22:00",
"nt_to": "06:00"
}
},
"telegram": {
"enabled": false,
+8 -6
View File
@@ -1,12 +1,13 @@
# Einstellungen
Stand **17.10.2026**. Seite `/einstellungen` — Drucker, Automatik, Anzeige, Telegram, Energie.
Stand **18.10.2026**. Seite `/einstellungen` — Drucker, Automatik, Anzeige, Telegram/ntfy, Energie.
![Einstellungen: Drucker](../web/static/doku/einstellungen-drucker.png)
## Dieser Browser
- **Desktop** oder **Regal (Touch)** — nur lokal im Browser, siehe [Oberfläche](Anleitung-Oberflaeche.md).
- **Darstellung** — Dunkel, Hell oder System (folgt dem Gerät).
- **Zeitzone** — Browser automatisch oder fest (z. B. Europe/Berlin) für Protokoll, Bericht und Aktivität.
## Automatik
@@ -19,29 +20,30 @@ Pfad zum Ordner, den die Bibliothek beobachtet (Export aus Orca/Anycubic, **Ordn
## Drucker
Tabelle: Name, Host, Familie, **gesperrt**.
Pro Drucker: Name, Host, Familie, **gesperrt**, optional **Start-Zeitfenster**.
- Seed beim allerersten Start aus `drucker.json`, danach nur noch hier (Datenbank).
- Familie aus dem Namen (Kobra 3 / S1) oder gesetzt.
- **Gesperrt:** keine neuen Queue-Starts auf diesem Host.
- **Zeitfenster:** Queue startet nur zwischen Von/Bis (z. B. 22:00–06:00). Optional Max.-Stunden für Nachtjobs.
## FQDN
Öffentliche Basis-URL für QR-Codes auf Spulen-Etiketten. Leer = Host aus dem aktuellen Request. Wichtig, wenn du Etiketten von einem Handy scannst, das nicht `127.0.0.1` kennt.
## Telegram und Alerts
## Telegram, ntfy und Alerts
Bot-Token und Chat-ID. Pro Ereignisart an/aus (Fertig, Fehler, Offline, Queue, …). Test-Knopf zum Prüfen. Wichtige Ereignisse stehen auch in der Sidebar unter Alerts.
Telegram: Bot-Token und Chat-ID, pro Ereignisart an/aus, Test-Knopf. **ntfy:** Server, Topic, optional Token — gleiche Meldungen, unabhängig schaltbar, ebenfalls mit Test. Ereignisse stehen auch in der Sidebar unter Alerts.
## Benutzer und API-Tokens
Unter **Benutzer** (nur Admin) legst du Konten mit Rolle Admin oder Operator an. Unter **API-Tokens** erzeugst du Schlüssel für Home-Automation, App oder MCP — der Klartext erscheint nur einmal (`Authorization: Bearer grt_…`).
Unter **Benutzer** (nur Admin) legst du Konten mit Rolle Admin oder Operator an. Unter **API-Tokens** erzeugst du Schlüssel für Home-Automation, App oder MCP — der Klartext erscheint nur einmal (`Authorization: Bearer grt_…`). Ein Shared-`token` in der Config-Datei gilt nicht mehr.
MCP (optional): Token erzeugen, dann `python -m grotti.mcp_server` mit `GROTTI_URL` und `GROTTI_TOKEN`. Für nur Lesen: `GROTTI_MCP_READ_ONLY=1`.
## Energie
**Grundlast W** (Elektronik/Motion, nicht die Heizer) und Strompreis für die Kosten im **Protokoll**. **Düse W** / **Bett W** zusammen mit ED-Proben im Verlauf → Schätzung „aus ED“; stehen sie auf 0, bleibt es bei Dauer × Grundlast („pauschal“). Typisch Kobra: Grundlast ~40 W, Düse ~60 W, Bett ~200–300 W. Beim Speichern warnt die Seite, wenn Düse oder Bett noch 0 sind. Optional Trockner/Ausschuss/Auslastung für die Kalkulation.
**Grundlast W** (Elektronik/Motion, nicht die Heizer) und Strompreis für die Kosten im **Protokoll**. **Düse W** / **Bett W** zusammen mit ED-Proben im Verlauf → Schätzung „aus ED“; stehen sie auf 0, bleibt es bei Dauer × Grundlast („pauschal“). Typisch Kobra: Grundlast ~40 W, Düse ~60 W, Bett ~200–300 W. Optional **HT/NT-Tarif** mit Zeiten und Preisen — Grotti gewichtet dann die Stromkosten nach Uhrzeit. Beim Speichern warnt die Seite, wenn Düse oder Bett noch 0 sind.
## Weiter
+6 -4
View File
@@ -1,6 +1,6 @@
# Material und mehr
Stand **17.10.2026**. Spulen, Etiketten, Projekte, Protokoll, Statistik, Kameras, Aktivität und Ideen.
Stand **18.10.2026**. Spulen, Etiketten, Aufträge, Protokoll, Statistik, Kameras, Aktivität.
![Material: Spulen](../web/static/doku/material-spule.png)
@@ -8,6 +8,8 @@ Stand **17.10.2026**. Spulen, Etiketten, Projekte, Protokoll, Statistik, Kameras
Zwei Reiter: **Spulen** und **Sorten**.
Oben die Karte **Bedarf aus Warteschlange**, wenn wartende Jobs Filament brauchen: Queue-Gewicht vs. Spulenrest je Sorte/Farbe. Fehlmengen erscheinen auch in der Sidebar und als Chip in der Warteschlange.
**Spulen:** Filter Alle / Am Drucker / Regal / Wenig Rest. Formular bei Auswahl oder „Neue Spule“. Ort = Drucker · ACE/extern oder Regal. Scan-Feld im Seitenkopf (QR vom Etikett). Unter der ausgewählten Spule die **Akte**: verdruckte Gramm, Ausschuss, Leer-in.
**Sorten:** Profile (Dichte, Preis, Maschinen). „Spule damit anlegen“ wechselt zum Spulen-Reiter.
@@ -18,15 +20,15 @@ Pro Spule ein Etikett mit Chargennummer `S-…` und QR. Der Code öffnet Materia
Druck-Etiketten (`D-…`) kommen vom Druckbericht: ein Etikett für den ganzen Lauf oder je Objekt. Scan von `D-…` öffnet den Bericht.
## Projekte
## Aufträge
Unter **Projekte** Aufträge mit Notiz und Kalkulation. Dateien lassen sich einem Projekt zuweisen (Bibliothek / Senden).
Unter **Aufträge** Kunden, Positionen und Fortschritt. Dateien lassen sich einem Auftrag zuweisen; siehe [Aufträge](Anleitung-Auftraege.md).
## Protokoll und Statistik
Abgeschlossene Läufe: Dauer, Ist-Gramm, Kosten, Urteil. Zeile öffnet den **Druckbericht** (Soll/Ist, Spulen, Temperaturverlauf, Charge). **Nochmal** startet denselben Job inkl. gespeicherter ACE-Map und Bettnetz-Option.
Unter **Statistik** Auslastung, Erfolg, Verbrauch und CSV-Export. Zeitraum per Knopf (Tag, Woche, Monat, Gestern, Letzte Woche, Letzter Monat) oder eigene Von/Bis-Daten.
Unter **Statistik** KPIs, Balkendiagramme (Druckzeit je Drucker, Verbrauch je Material), Tabellen und CSV-Export. Zeitraum per Knopf oder Von/Bis.
Druck-Etiketten (`D-…`) schon während des Drucks: an der Wand **Etikett**, im Druckbericht **Etikett Druck** / **Etiketten Objekte**. Auf dem Bogen steht das Sollgewicht aus dem Slicer (geplante Filamentmenge). Bei Ausschuss die gedruckten Bögen wegwerfen; die Charge bleibt im Protokoll.
+6 -4
View File
@@ -1,18 +1,18 @@
# Oberfläche
Stand **15.10.2026**. Wie die App aufgebaut ist — Menü, Live-Leiste, Desktop und Regal.
Stand **18.10.2026**. Wie die App aufgebaut ist — Menü, Live-Leiste, Desktop und Regal.
## Navigation
Links (Desktop): **Drucker**, **Dateien**, **Kameras**, **Aktivität**, **Warteschlange**, **Protokoll**, **Statistik**, **Material**, **Projekte**. Darunter **Einstellungen**, **Ideen**, **Doku**. Fuß: Version (öffnet die Doku).
Links (Desktop): **Drucker**, **Dateien**, **Kameras**, **Warteschlange**, **Aufträge**, **Protokoll**, **Statistik**, **Aktivität**, **Material**, **Wartung**. Darunter **Einstellungen**, **Doku**. Fuß: Version (öffnet die Doku).
Unter ca. 760 px: untere Leiste mit den wichtigsten Seiten. Das Seitenmenü lässt sich zur Icon-Rail einklappen.
Unter ca. 760 px: untere Leiste plus **Mehr**-Sheet. Das Seitenmenü lässt sich zur Icon-Rail einklappen.
![Seitenleiste mit Live-Boxen](../web/static/doku/sidebar-live.png)
## Live-Leiste
Unter dem Menü, eigene Scrollfläche. Boxen auf- und zuklappbar (Merker im Browser):
Unter dem Menü, eigene Scrollfläche. Boxen auf- und zuklappbar (Merker im Browser). Aktualisierung über Live-Stream (SSE), sonst Polling.
| Box | Inhalt |
| --- | --- |
@@ -21,6 +21,7 @@ Unter dem Menü, eigene Scrollfläche. Boxen auf- und zuklappbar (Merker im Brow
| **Aufgaben** | Platte belegt ohne laufenden Ablauf — **geräumt** (Zwei-Klick). |
| **Senden** | Uploads mit MB-Fortschritt. |
| **Alerts** | Ungelesene wichtige Ereignisse; als gelesen markieren oder zur Aktivität. |
| **Bedarf** | Fehlende Filamentmenge für die Queue (Link zu Material). |
## Desktop und Regal
@@ -28,6 +29,7 @@ Unter **Einstellungen → Dieser Browser**:
- **Desktop** — normale Dichte
- **Regal (Touch)** — größere Bedienelemente, kompaktere Nav, Sidebar oft eingeklappt
- **Darstellung** — Dunkel, Hell oder System
- **Zeitzone** — Browser oder fest (z. B. Europe/Berlin) für Uhrzeiten in Protokoll und Bericht
Shortcut in der URL: `?shelf=1` bzw. `?shelf=0`. Nur dieser Browser (`localStorage`).
+4 -2
View File
@@ -1,6 +1,6 @@
# Warteschlange und Ablauf
Stand **15.10.2026**. Aufträge „wenn frei“, Priorität, und der Kobra-3-Ablauf (drucken → abkühlen → schieben → tasten).
Stand **18.10.2026**. Aufträge „wenn frei“, Priorität, und der Kobra-3-Ablauf (drucken → abkühlen → schieben → tasten).
![Warteschlange](../web/static/doku/warteschlange.png)
@@ -18,9 +18,11 @@ Unter **Läuft** siehst du auch Direktstarts ohne Queue-Zeile (Link zur Wand).
Dieselbe Information steht kompakt in der Sidebar (**Läuft jetzt** / **Wartet**). Unter **Läuft jetzt** eine Zeile pro Drucker; Klick öffnet diese Seite, × bricht ab.
Reicht der Spulenrest nicht für wartende Jobs, erscheint oben ein **Fehlmenge**-Hinweis (Details unter Material → Bedarf) und in der Sidebar unter **Bedarf**.
## Wann startet ein wartender Auftrag?
Sobald der Drucker bereit ist (`standby` / fertig / abgebrochen), die **Platte frei** ist und der Drucker nicht gesperrt. Automatik aus (`auto_queue`) oder gesperrt → keine neuen Starts; laufende Abläufe laufen weiter.
Sobald der Drucker bereit ist (`standby` / fertig / abgebrochen), die **Platte frei** ist und der Drucker nicht gesperrt. Automatik aus (`auto_queue`) oder gesperrt → keine neuen Starts; laufende Abläufe laufen weiter. Mit **Start-Zeitfenster** in den Einstellungen nur innerhalb von Von/Bis.
## Ablauf (nur Kobra 3)
+7 -3
View File
@@ -15,7 +15,7 @@ def compute(conn):
queue_rows = conn.execute(
"""
SELECT q.id, q.spool_id, q.repeat_total, q.repeat_done,
f.weight_g, f.material_type AS file_material, f.color AS file_color,
f.weight_g, f.filaments,
s.material_type AS spool_material, s.color AS spool_color
FROM print_queue q
JOIN files f ON f.id = q.file_id
@@ -26,8 +26,12 @@ def compute(conn):
needed_map = {}
for row in queue_rows:
mat = (row.get("spool_material") or row.get("file_material") or "").strip()
color = (row.get("spool_color") or row.get("file_color") or "").strip()
filaments = row.get("filaments") or []
first = filaments[0] if isinstance(filaments, list) and filaments else {}
if not isinstance(first, dict):
first = {}
mat = (row.get("spool_material") or first.get("type") or "").strip()
color = (row.get("spool_color") or first.get("color") or "").strip()
key = _group_key(mat, color)
try:
repeats = max(1, int(row.get("repeat_total") or 1) - int(row.get("repeat_done") or 0))
+1 -1
View File
@@ -300,7 +300,7 @@ def _save_settings_locked(
"utilization": float(energy.get("utilization", 50)),
}
tariff_in = energy.get("tariff") if isinstance(energy.get("tariff"), dict) else None
if tariff_in is not None:
if tariff_in:
raw_energy["tariff"] = {
"ht_euro": float(tariff_in.get("ht_euro", raw_energy["euro_per_kwh"])),
"nt_euro": float(tariff_in.get("nt_euro", raw_energy["euro_per_kwh"])),
+11 -3
View File
@@ -2081,20 +2081,28 @@ def create_printer(conn, printer_id, name, host, family):
return printer_out(row)
def update_printer(conn, printer_id, name, host, family, locked=None):
def update_printer(conn, printer_id, name, host, family, locked=None, start_window=None):
current = printer_by_id(conn, printer_id)
if not current:
return None
if locked is None:
locked = bool(current.get("locked"))
if start_window is None:
sw = current.get("start_window") or {}
elif isinstance(start_window, dict):
sw = start_window
else:
sw = {}
if not isinstance(sw, dict):
sw = {}
row = conn.execute(
"""
UPDATE printers
SET name = %s, host = %s, family = %s, locked = %s
SET name = %s, host = %s, family = %s, locked = %s, start_window = %s
WHERE id = %s
RETURNING *
""",
(name, host, family, bool(locked), printer_id),
(name, host, family, bool(locked), Json(sw), printer_id),
).fetchone()
if current["host"] != host:
old = current["host"]
+1 -1
View File
@@ -1,3 +1,3 @@
"""Version von Grotti (SemVer). Bei jeder Änderung CHANGELOG.md mitführen."""
__version__ = "3.0.0"
__version__ = "3.1.0"
+2 -4
View File
@@ -26,8 +26,7 @@ class BedarfTests(unittest.TestCase):
"repeat_total": 2,
"repeat_done": 0,
"weight_g": 50.0,
"file_material": "PLA",
"file_color": "Schwarz",
"filaments": [{"type": "PLA", "color": "Schwarz"}],
"spool_material": "PLA",
"spool_color": "Schwarz",
},
@@ -52,8 +51,7 @@ class BedarfTests(unittest.TestCase):
"repeat_total": 1,
"repeat_done": 0,
"weight_g": 20.0,
"file_material": "PETG",
"file_color": "",
"filaments": [{"type": "PETG", "color": ""}],
"spool_material": None,
"spool_color": None,
},
+24 -11
View File
@@ -103,6 +103,7 @@ def create_app(cfg):
)
ADMIN_API_EXACT = {
"/api/v1/telegram/test",
"/api/v1/ntfy/test",
}
ADMIN_METHODS_ON = {
# Drucker anlegen/ändern/löschen nur Admin
@@ -135,14 +136,6 @@ def create_app(cfg):
return user
def resolve_bearer(presented):
legacy = cfg.get("token") or ""
if legacy and secrets.compare_digest(presented, legacy):
return {
"id": None,
"username": "legacy-token",
"role": "admin",
"auth": "legacy",
}
if not presented.startswith(grotti_auth.TOKEN_PREFIX):
return None
row = store.get_api_token_by_hash(conn(), grotti_auth.hash_token(presented))
@@ -821,8 +814,17 @@ def create_app(cfg):
)
return jsonify({"ok": True})
@app.post("/api/v1/ntfy/test")
def ntfy_test():
ok = notify.send_ntfy(cfg, "Grotti-Test: ntfy ist verbunden.")
if not ok:
raise GrottiError(
"ntfy nicht erreichbar. Server, Topic und „aktiv“ prüfen.",
"ntfy",
)
return jsonify({"ok": True})
@app.get("/api/v1/drucker")
@app.get("/api/drucker") # veraltet — Alias zu /api/v1/drucker
def printers():
rows = store.list_printers(conn())
clears = store.bed_clear_map(conn())
@@ -908,7 +910,18 @@ def create_app(cfg):
if other and str(other["id"]) != str(printer_id):
raise GrottiError("Diesen Host gibt es schon.", "host")
locked = data.get("locked") if "locked" in data else current.get("locked")
row = store.update_printer(conn(), printer_id, name, host, family, locked=bool(locked))
start_window = data.get("start_window") if "start_window" in data else None
if start_window is not None and not isinstance(start_window, dict):
raise GrottiError("Zeitfenster muss ein Objekt sein.", "start_window")
row = store.update_printer(
conn(),
printer_id,
name,
host,
family,
locked=bool(locked),
start_window=start_window,
)
return jsonify(row)
@app.delete("/api/v1/drucker/id/<printer_id>")
@@ -2388,12 +2401,12 @@ def create_app(cfg):
finally:
events_bus.unsubscribe(q)
# Kein Connection-Header: waitress verbietet Hop-by-Hop-Header (PEP 3333).
return Response(
stream_with_context(generate()),
mimetype="text/event-stream; charset=utf-8",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
+17 -1
View File
@@ -28,13 +28,29 @@ async function api(path, options = {}) {
headers.set("Content-Type", "application/json");
body = JSON.stringify(options.json);
}
const response = await fetch("/api/v1" + path, {
const timeoutMs = options.timeoutMs != null ? Number(options.timeoutMs) : 20000;
const ctrl = options.signal ? null : new AbortController();
const timer = ctrl && timeoutMs > 0
? setTimeout(() => ctrl.abort(), timeoutMs)
: 0;
let response;
try {
response = await fetch("/api/v1" + path, {
method,
headers,
body,
cache: "no-store",
credentials: "same-origin",
signal: options.signal || (ctrl && ctrl.signal),
});
} catch (error) {
if (error && error.name === "AbortError") {
throw new Error("Zeitüberschreitung — Server antwortet nicht.");
}
throw error;
} finally {
if (timer) clearTimeout(timer);
}
if (response.status === 401) {
const here = location.pathname + location.search;
if (!here.startsWith("/login") && !here.startsWith("/setup")) {
+95 -2
View File
@@ -2398,7 +2398,7 @@ label.button:hover { background: rgba(255, 255, 255, 0.04); }
.nav-main { display: none; }
html.shelf .mobile-nav,
body.shelf .mobile-nav {
grid-template-columns: repeat(3, 1fr);
grid-template-columns: repeat(5, 1fr);
}
.nav-secondary { margin-top: 0; border-top: 0; padding-top: 0; flex: 1; justify-content: flex-end; }
.side-foot { display: none; }
@@ -2421,7 +2421,7 @@ label.button:hover { background: rgba(255, 255, 255, 0.04); }
.desk:not(.no-cam) .desk-cam { max-height: 18rem; }
.mobile-nav {
display: grid;
grid-template-columns: repeat(3, 1fr);
grid-template-columns: repeat(5, 1fr);
position: fixed;
left: 0;
right: 0;
@@ -2631,6 +2631,99 @@ ul.plain li:last-child { border-bottom: 0; }
}
}
html[data-theme="light"] {
color-scheme: light;
--bg: #f4f4f6;
--ink: #16161a;
--muted: #5c5c66;
--faint: #6e6e78;
--line: rgba(0, 0, 0, 0.08);
--line-strong: rgba(0, 0, 0, 0.14);
--card: #ffffff;
--card-2: #f0f0f3;
--accent: #ea580c;
--accent-ink: #fff7ed;
--accent-soft: rgba(234, 88, 12, 0.12);
--warn: #dc2626;
--pause: #d97706;
--track: #e4e4ea;
--shadow: 0 10px 28px rgba(20, 20, 30, 0.08);
--live: #16a34a;
--live-soft: rgba(22, 163, 74, 0.12);
--st-printing: #16a34a;
--st-printing-soft: rgba(22, 163, 74, 0.1);
--st-heating: #d97706;
--st-heating-soft: rgba(217, 119, 6, 0.1);
--st-done: #4f46e5;
--st-done-soft: rgba(79, 70, 229, 0.1);
--st-error: #dc2626;
--st-error-soft: rgba(220, 38, 38, 0.1);
--st-offline: #73737c;
--st-offline-soft: rgba(0, 0, 0, 0.04);
--st-waiting: #ea580c;
--st-waiting-soft: rgba(234, 88, 12, 0.1);
--st-idle: #5c5c66;
--st-idle-soft: rgba(0, 0, 0, 0.04);
}
html[data-theme="light"] body {
background:
radial-gradient(800px 400px at 0% 0%, rgba(234, 88, 12, 0.06), transparent 55%),
var(--bg);
}
html[data-theme="light"] a:hover { color: #c2410c; }
html[data-theme="light"] {
scrollbar-color: rgba(0, 0, 0, 0.18) transparent;
}
html[data-theme="light"]::-webkit-scrollbar-thumb { background: rgba(0, 0, 0, 0.18); }
html[data-theme="light"]::-webkit-scrollbar-thumb:hover { background: rgba(0, 0, 0, 0.28); }
.narrow-fields { max-width: 36rem; }
.printer-edit-list { display: grid; gap: 0.85rem; }
.printer-edit {
border: 1px solid var(--line);
border-radius: var(--radius-sm);
padding: 0.85rem 1rem;
background: var(--card-2);
}
.printer-edit .actions { margin-top: 0.65rem; gap: 0.5rem; }
.bedarf-card { margin-bottom: 1rem; }
.bedarf-card tr.warn td { color: var(--warn); }
.wartung-card .lib-head { align-items: center; }
.bar-chart { display: grid; gap: 0.45rem; margin-top: 0.35rem; }
.bar-row {
display: grid;
grid-template-columns: minmax(5rem, 8rem) 1fr auto;
gap: 0.55rem;
align-items: center;
}
.bar-label {
font-size: var(--t-sm);
color: var(--muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.bar-track {
height: 0.55rem;
border-radius: 99px;
background: var(--track);
overflow: hidden;
}
.bar-fill {
display: block;
height: 100%;
border-radius: inherit;
background: linear-gradient(90deg, var(--accent), color-mix(in srgb, var(--accent) 60%, #fbbf24));
}
.bar-val {
font-size: var(--t-xs);
font-variant-numeric: tabular-nums;
color: var(--faint);
min-width: 3.5rem;
text-align: right;
}
@media print {
.sidebar, .mobile-nav, .conn-banner, .toast-host, .nav-toggle { display: none !important; }
.app-shell { display: block; }
+26 -7
View File
@@ -1,27 +1,46 @@
/* Minimaler Service-Worker: App-Shell-Cache für Offline-Hinweis. */
const CACHE = "grotti-shell-v1";
const SHELL = ["/", "/static/grotti.css?v=54", "/static/api.js?v=28", "/static/ui.js?v=2"];
/* Nur Static-Assets cachen — nie HTML-Navigation abfangen (sonst falsche Seite in Chrome). */
const CACHE = "grotti-shell-v2";
self.addEventListener("install", (event) => {
event.waitUntil(caches.open(CACHE).then((c) => c.addAll(SHELL).catch(() => undefined)));
self.skipWaiting();
event.waitUntil(
caches.open(CACHE).then((cache) =>
cache.addAll([
"/static/grotti.css?v=56",
"/static/api.js?v=29",
"/static/ui.js?v=3",
]).catch(() => undefined)
)
);
});
self.addEventListener("activate", (event) => {
event.waitUntil(
caches.keys().then((keys) =>
Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k)))
)
).then(() => self.clients.claim())
);
self.clients.claim();
});
self.addEventListener("fetch", (event) => {
const req = event.request;
if (req.method !== "GET") return;
// Dokument-Navigation immer ans Netz — kein Fallback auf „/“.
if (req.mode === "navigate") return;
const url = new URL(req.url);
if (url.origin !== self.location.origin) return;
if (url.pathname.startsWith("/api/")) return;
if (!url.pathname.startsWith("/static/")) return;
event.respondWith(
fetch(req).catch(() => caches.match(req).then((hit) => hit || caches.match("/")))
fetch(req)
.then((res) => {
if (res.ok) {
const copy = res.clone();
caches.open(CACHE).then((c) => c.put(req, copy)).catch(() => {});
}
return res;
})
.catch(() => caches.match(req))
);
});
+7 -1
View File
@@ -203,16 +203,22 @@ function renderList() {
body.innerHTML = '<tr><td colspan="3" class="empty">Noch kein Auftrag.</td></tr>';
return;
}
const selected = document.getElementById("order-id").value;
orders.forEach((order) => {
const tr = document.createElement("tr");
tr.className = "pick";
tr.className = "pick" + (selected && String(order.id) === selected ? " on" : "");
tr.innerHTML = "<td></td><td></td><td></td>";
tr.children[0].textContent = order.title;
tr.children[1].textContent = STATUS_LABEL[order.status] || order.status;
tr.children[2].textContent = (order.progress && order.progress.label) || "";
tr.addEventListener("click", async () => {
try {
const full = await api("/auftraege/" + order.id);
fillOrder(full);
renderList();
} catch (error) {
showNote("order-note", error);
}
});
body.appendChild(tr);
});
+155 -45
View File
@@ -10,15 +10,23 @@
<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">
<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>
<p class="hint">Regal: größere Bedienelemente, kompakte Navigation — ideal für ~1024×860 Touch am Regal. Shortcut: <code>?shelf=1</code></p>
<div class="field" style="max-width:16rem; margin-top:0.85rem">
<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>
@@ -28,7 +36,8 @@
<option value="UTC">UTC</option>
</select>
</div>
<p class="hint">Für Protokoll, Bericht und Aktivitätszeiten. Nur dieser Browser.</p>
</div>
<p class="hint">Regal: größere Bedienelemente — Shortcut <code>?shelf=1</code>. Darstellung und Zeitzone nur dieser Browser.</p>
<p id="display-note" class="ok" hidden>Gespeichert — gilt sofort.</p>
</section>
@@ -68,14 +77,9 @@
<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">
<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">
<p class="hint">Gesperrt = kein neuer „wenn frei“-Start. Zeitfenster begrenzt Starts (z. B. Nachtstrom).</p>
<div id="printer-list" class="printer-edit-list"></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">
@@ -143,8 +147,11 @@
<input id="ntfy-token" type="password" autocomplete="off" placeholder="Bearer-Token">
</div>
</div>
<p class="hint">Gleiche Meldungen wie Telegram — unabhängig abschaltbar.</p>
<div class="actions"><button type="button" id="ntfy-save">Speichern</button></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">Speichern</button>
<button type="button" class="ghost" id="ntfy-test">Test senden</button>
</div>
<p id="ntfy-note"></p>
</section>
@@ -171,7 +178,15 @@
<div class="field"><label for="en-waste">Ausschuss %</label><input id="en-waste" type="number" step="0.1"></div>
<div class="field"><label for="en-util">Auslastung %</label><input id="en-util" type="number" step="1"></div>
</div>
<p class="hint">Grundlast = Elektronik/Motion (nicht ganzer Drucker). Düse/Bett 0 = nur pauschal. Typisch Kobra: Düse ~60 W, Bett ~200–300 W.</p>
<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">Speichern</button></div>
<p id="en-note"></p>
@@ -217,7 +232,7 @@
</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>
<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>
{% endblock %}
{% block script %}
@@ -234,39 +249,87 @@ if (!isAdmin) {
/* 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 || "",
};
}
function renderPrinters(rows) {
const body = document.getElementById("printer-rows");
body.innerHTML = "";
const root = document.getElementById("printer-list");
root.innerHTML = "";
if (!rows.length) {
body.innerHTML = '<tr><td class="empty" colspan="5">Keine Drucker.</td></tr>';
root.innerHTML = '<div class="empty-state"><svg class="ico"><use href="#i-printer"/></svg><p>Keine Drucker.</p></div>';
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">
const win = windowFrom(row);
const card = document.createElement("div");
card.className = "printer-edit";
card.innerHTML = `
<div class="grid-2">
<div class="field"><label>Name</label><input data-f="name"></div>
<div class="field"><label>Host / IP</label><input data-f="host"></div>
<div class="field"><label>Familie</label><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;
</select></div>
<div class="field"><label class="check" style="margin-top:1.4rem"><input type="checkbox" data-f="locked"> Gesperrt</label></div>
</div>
<details class="mat-more" ${win.enabled ? "open" : ""}>
<summary><svg class="ico caret"><use href="#i-down"/></svg><span class="grow">Start-Zeitfenster</span>
<span class="tiny">${win.enabled ? escapeHtml(win.from) + "–" + escapeHtml(win.to) : "aus"}</span>
</summary>
<div class="grid-2" style="margin-top:0.6rem">
<label class="check" style="grid-column:1/-1"><input type="checkbox" data-f="win-on"> Nur in Zeitfenster starten</label>
<div class="field"><label>Von</label><input type="time" data-f="win-from"></div>
<div class="field"><label>Bis</label><input type="time" data-f="win-to"></div>
<div class="field"><label>Max. Stunden (Nachtjob)</label><input type="number" min="0" step="0.5" data-f="win-max" placeholder="leer = egal"></div>
</div>
</details>
<div class="actions"></div>`;
card.querySelector('[data-f="name"]').value = row.name || "";
card.querySelector('[data-f="host"]').value = row.host || "";
card.querySelector('[data-f="family"]').value = row.family || "";
card.querySelector('[data-f="locked"]').checked = !!row.locked;
card.querySelector('[data-f="win-on"]').checked = win.enabled;
card.querySelector('[data-f="win-from"]').value = win.from;
card.querySelector('[data-f="win-to"]').value = win.to;
card.querySelector('[data-f="win-max"]').value = win.maxHours;
const save = document.createElement("button");
save.type = "button";
save.textContent = "Speichern";
save.addEventListener("click", async () => {
const on = card.querySelector('[data-f="win-on"]').checked;
const start_window = {};
if (on) {
start_window.allowed = [{
from: card.querySelector('[data-f="win-from"]').value || "22:00",
to: card.querySelector('[data-f="win-to"]').value || "06:00",
}];
const maxH = Number(card.querySelector('[data-f="win-max"]').value);
if (maxH > 0) start_window.max_hours_overnight = maxH;
}
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,
name: card.querySelector('[data-f="name"]').value,
host: card.querySelector('[data-f="host"]').value,
family: card.querySelector('[data-f="family"]').value,
locked: card.querySelector('[data-f="locked"]').checked,
start_window,
},
});
showNote("printer-note", null, "Gespeichert: " + tr.querySelector('[data-f="name"]').value);
showNote("printer-note", null, "Gespeichert: " + card.querySelector('[data-f="name"]').value);
toast("Drucker gespeichert.");
load();
} catch (error) {
showNote("printer-note", error);
@@ -287,9 +350,8 @@ function renderPrinters(rows) {
}
});
});
tr.children[4].appendChild(save);
tr.children[4].appendChild(del);
body.appendChild(tr);
card.querySelector(".actions").append(save, del);
root.appendChild(card);
});
}
@@ -430,6 +492,13 @@ async function load() {
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");
@@ -634,6 +703,19 @@ document.getElementById("tg-test").addEventListener("click", async () => {
}
});
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;
@@ -657,10 +739,7 @@ document.getElementById("en-save").addEventListener("click", async () => {
try {
const nozzle = Number(document.getElementById("en-nozzle").value) || 0;
const bed = Number(document.getElementById("en-bed").value) || 0;
await api("/einstellungen", {
method: "PATCH",
json: {
energy: {
const energy = {
watts: Number(document.getElementById("en-watts").value),
nozzle_watts: nozzle,
bed_watts: bed,
@@ -669,9 +748,16 @@ document.getElementById("en-save").addEventListener("click", async () => {
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");
@@ -688,6 +774,13 @@ document.getElementById("en-save").addEventListener("click", async () => {
}
});
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;
@@ -703,11 +796,28 @@ document.getElementById("en-save").addEventListener("click", async () => {
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);
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();
});
})();
+110 -15
View File
@@ -9,11 +9,27 @@
<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=54">
<link rel="stylesheet" href="{{ url_for('static', filename='grotti.css') }}?v=56">
<link rel="manifest" href="{{ url_for('static', filename='manifest.webmanifest') }}">
<meta name="theme-color" content="#0a0a0c">
<meta name="theme-color" content="#0a0a0c" id="theme-color-meta">
<script>
(function () {
function applyThemeEarly(mode) {
var light = mode === "light";
if (mode === "system") {
try { light = window.matchMedia("(prefers-color-scheme: light)").matches; } catch (e) { light = false; }
}
document.documentElement.dataset.theme = light ? "light" : "dark";
document.documentElement.style.colorScheme = light ? "light" : "dark";
var meta = document.getElementById("theme-color-meta");
if (meta) meta.content = light ? "#f4f4f6" : "#0a0a0c";
}
window.applyTheme = function (mode) {
try { if (mode) localStorage.setItem("grotti-theme", mode); } catch (e) {}
var m = mode;
try { m = m || localStorage.getItem("grotti-theme") || "dark"; } catch (e) { m = "dark"; }
applyThemeEarly(m);
};
try {
if (!localStorage.getItem("grotti-display") && localStorage.getItem("farm-display")) {
localStorage.setItem("grotti-display", localStorage.getItem("farm-display"));
@@ -35,6 +51,14 @@
if (nav === "1" || (nav == null && localStorage.getItem("grotti-display") === "shelf")) {
document.documentElement.classList.add("nav-collapsed");
}
applyThemeEarly(localStorage.getItem("grotti-theme") || "dark");
} catch (e) {}
try {
window.matchMedia("(prefers-color-scheme: light)").addEventListener("change", function () {
var mode = "dark";
try { mode = localStorage.getItem("grotti-theme") || "dark"; } catch (err) {}
if (mode === "system") applyThemeEarly("system");
});
} catch (e) {}
})();
</script>
@@ -132,6 +156,11 @@
<ul class="queue-list" id="alert-list"></ul>
<div class="queue-foot"><button type="button" class="ghost tiny-btn" id="events-clear">gelesen</button>&nbsp;<a href="/aktivitaet">alle&nbsp;→</a></div>
</details>
<details class="queue-box" data-box="bedarf" id="bedarf-box" open hidden>
<summary class="queue-title"><svg class="ico caret"><use href="#i-down"/></svg><span class="grow">Bedarf</span><span class="count bad" id="bedarf-count">0</span></summary>
<ul class="queue-list" id="bedarf-list"></ul>
<div class="queue-foot"><a href="/material#bedarf">Material&nbsp;→</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>
@@ -171,10 +200,10 @@
<a href="/einstellungen">Einstellungen</a>
<a href="/doku">Doku</a>
</nav>
<form method="dialog"><button class="ghost">Schließen</button></form>
<form method="dialog"><button class="ghost" value="cancel">Schließen</button></form>
</dialog>
<script src="{{ url_for('static', filename='api.js') }}?v=28"></script>
<script src="{{ url_for('static', filename='ui.js') }}?v=2"></script>
<script src="{{ url_for('static', filename='api.js') }}?v=29"></script>
<script src="{{ url_for('static', filename='ui.js') }}?v=3"></script>
<script>
document.getElementById("logout-btn")?.addEventListener("click", async () => {
try { await api("/auth/logout", { method: "POST", json: {} }); } catch (e) {}
@@ -477,6 +506,25 @@ async function refreshEvents() {
</li>`).join("");
}
async function refreshBedarf() {
let data;
try { data = await api("/bedarf"); } catch (error) { return; }
const shortfall = data.shortfall || [];
const box = document.getElementById("bedarf-box");
if (!box) return;
box.hidden = !shortfall.length;
document.getElementById("bedarf-count").textContent = shortfall.length;
document.getElementById("bedarf-list").innerHTML = shortfall.slice(0, 5).map((item) => {
const label = [item.material_type, item.color].filter(Boolean).join(" · ") || "Filament";
return `<li class="queue-item">
<div class="queue-row"><span class="grow"><span class="queue-name">${escapeHtml(label)}</span></span>
<span class="queue-sub">${grams(item.shortfall_g)} fehlen</span></div>
<span class="queue-msg">${grams(item.weight_g)} Queue · ${grams(item.remaining_g)} Rest</span>
</li>`;
}).join("");
if (typeof window.onBedarfChanged === "function") window.onBedarfChanged(data);
}
async function refreshTasks() {
let items;
try { items = await api("/aufgaben"); } catch (error) { return; }
@@ -508,11 +556,29 @@ async function refreshTasks() {
};
});
}
document.getElementById("mobile-more")?.addEventListener("click", () => {
document.getElementById("mobile-more-sheet")?.showModal();
(function mobileMoreSheet() {
const sheet = document.getElementById("mobile-more-sheet");
const btn = document.getElementById("mobile-more");
if (!sheet || !btn) return;
btn.addEventListener("click", () => {
if (typeof sheet.showModal === "function") sheet.showModal();
});
// Modal schließt und blockiert sonst die ganze App (Chrome).
sheet.addEventListener("click", (event) => {
const link = event.target.closest("a[href]");
if (!link) return;
event.preventDefault();
const href = link.getAttribute("href");
sheet.close();
if (href) location.href = href;
});
sheet.addEventListener("close", () => {
btn.setAttribute("aria-expanded", "false");
});
btn.addEventListener("click", () => btn.setAttribute("aria-expanded", "true"));
})();
if ("serviceWorker" in navigator) {
navigator.serviceWorker.register("/static/sw.js").catch(() => {});
navigator.serviceWorker.register("/static/sw.js", { updateViaCache: "none" }).catch(() => {});
}
document.getElementById("events-clear").addEventListener("click", async (event) => {
@@ -524,15 +590,21 @@ document.getElementById("events-clear").addEventListener("click", async (event)
window.refreshPrintQueue = refreshPrintQueue;
window.refreshSendQueue = refreshSendQueue;
window.refreshBedarf = refreshBedarf;
refreshSendQueue();
refreshPrintQueue();
refreshEvents();
refreshTasks();
refreshBedarf();
window.refreshTasks = refreshTasks;
(function liveUpdates() {
let streamLive = false;
const sseKinds = {
queue: () => refreshPrintQueue(),
queue: () => {
refreshPrintQueue();
refreshBedarf();
},
senden: () => refreshSendQueue(),
ereignis: () => refreshEvents(),
aufgaben: () => refreshTasks(),
@@ -543,23 +615,37 @@ window.refreshTasks = refreshTasks;
},
};
function whenStreamDown(fn) {
return async () => {
if (streamLive) return;
await fn();
};
}
if (typeof EventSource !== "undefined") {
let es;
let retryMs = 2000;
const retryMax = 60000;
let hadStream = false;
let retryTimer = 0;
function connectStream() {
if (es) {
try { es.close(); } catch (e) {}
}
es = new EventSource("/api/v1/stream", { withCredentials: true });
es.addEventListener("open", () => {
streamLive = true;
retryMs = 2000;
if (hadStream) setConnectionBanner(true, "Live-Verbindung wieder da.");
hadStream = true;
});
es.addEventListener("error", () => {
es.close();
streamLive = false;
try { es.close(); } catch (e) {}
setConnectionBanner(false, "Live-Verbindung unterbrochen — Fallback-Polling läuft.");
setTimeout(connectStream, retryMs);
clearTimeout(retryTimer);
retryTimer = setTimeout(connectStream, retryMs);
retryMs = Math.min(retryMax, retryMs * 2);
});
Object.keys(sseKinds).forEach((kind) => {
@@ -568,12 +654,21 @@ window.refreshTasks = refreshTasks;
es.addEventListener("ping", () => {});
}
connectStream();
window.addEventListener("pagehide", () => {
streamLive = false;
clearTimeout(retryTimer);
if (es) {
try { es.close(); } catch (e) {}
}
});
}
createPoller(refreshPrintQueue, 8000).start();
createPoller(refreshSendQueue, 4000).start();
createPoller(refreshEvents, 12000).start();
createPoller(refreshTasks, 12000).start();
// Poller nur als Fallback, wenn SSE tot ist — sonst staut Chrome die Verbindungen.
createPoller(whenStreamDown(refreshPrintQueue), 8000).start();
createPoller(whenStreamDown(refreshSendQueue), 4000).start();
createPoller(whenStreamDown(refreshEvents), 12000).start();
createPoller(whenStreamDown(refreshTasks), 12000).start();
createPoller(whenStreamDown(refreshBedarf), 30000).start();
})();
</script>
{% block script %}{% endblock %}
+56 -1
View File
@@ -14,6 +14,20 @@
</div>
</div>
<section class="card bedarf-card" id="bedarf-card" hidden>
<div class="lib-head">
<h2 id="bedarf">Bedarf aus Warteschlange</h2>
<span class="count" id="bedarf-short-count">0</span>
</div>
<p class="hint" id="bedarf-hint">Fehlmenge = Queue-Gewicht minus Spulenrest (gleiche Sorte/Farbe).</p>
<div class="table-wrap">
<table>
<thead><tr><th>Sorte</th><th>Farbe</th><th class="num">Queue</th><th class="num">Rest</th><th class="num">Fehlt</th><th class="num">Jobs</th></tr></thead>
<tbody id="bedarf-rows"></tbody>
</table>
</div>
</section>
<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>
@@ -721,17 +735,58 @@ function render() {
renderSorten();
}
function renderBedarf(data) {
const card = document.getElementById("bedarf-card");
const shortfall = (data && data.shortfall) || [];
const needed = (data && data.needed) || [];
if (!needed.length && !shortfall.length) {
card.hidden = true;
return;
}
card.hidden = false;
document.getElementById("bedarf-short-count").textContent = shortfall.length
? shortfall.length + " Fehl"
: "ok";
document.getElementById("bedarf-hint").textContent = shortfall.length
? shortfall.length + " Sorten reichen nicht für die wartende Queue."
: "Queue hat Bedarf — Spulenrest reicht.";
const rows = (shortfall.length ? shortfall : needed).map((item) => {
const missing = item.shortfall_g != null ? grams(item.shortfall_g) : "—";
const rest = item.remaining_g != null
? grams(item.remaining_g)
: "—";
return `<tr class="${item.shortfall_g > 0 ? "warn" : ""}">
<td>${escapeHtml(item.material_type || "—")}</td>
<td>${escapeHtml(item.color || "—")}</td>
<td class="num">${grams(item.weight_g)}</td>
<td class="num">${rest}</td>
<td class="num">${missing}</td>
<td class="num">${item.jobs || 0}</td>
</tr>`;
}).join("");
document.getElementById("bedarf-rows").innerHTML = rows
|| '<tr><td class="empty" colspan="6">Kein Queue-Bedarf.</td></tr>';
}
async function load() {
[spools, printers, materials, palette] = await Promise.all([
const [spoolData, printerData, materialData, paletteData, bedarfData] = await Promise.all([
api("/spulen"),
api("/drucker?live=0"),
api("/materialien"),
api("/farben"),
api("/bedarf").catch(() => ({ needed: [], shortfall: [] })),
]);
spools = spoolData;
printers = printerData;
materials = materialData;
palette = paletteData;
render();
renderPalette();
renderBedarf(bedarfData);
}
window.onBedarfChanged = renderBedarf;
function setScanNote(text, ok) {
const note = document.getElementById("scan-note");
if (!text) {
+55
View File
@@ -110,6 +110,21 @@ function fillPrinters(rows) {
if ([...select.options].some((o) => o.value === current)) select.value = current;
}
function barChart(items, valueKey, labelFn) {
const rows = (items || []).filter((item) => Number(item[valueKey]) > 0);
if (!rows.length) return '<p class="empty">Keine Werte fürs Diagramm.</p>';
const max = Math.max(...rows.map((item) => Number(item[valueKey]) || 0)) || 1;
return `<div class="bar-chart" role="img">${rows.map((item) => {
const value = Number(item[valueKey]) || 0;
const pct = Math.max(4, Math.round(100 * value / max));
return `<div class="bar-row">
<span class="bar-label">${escapeHtml(labelFn(item))}</span>
<span class="bar-track"><span class="bar-fill" style="width:${pct}%"></span></span>
<span class="bar-val">${escapeHtml(String(item._barLabel || value))}</span>
</div>`;
}).join("")}</div>`;
}
function render(data) {
fillPrinters(data.by_printer);
const o = data.overview || {};
@@ -120,6 +135,16 @@ function render(data) {
? (data.estimate_ratio * 100).toFixed(0) + " %"
: "—";
if (!jobs) {
document.getElementById("stat").innerHTML = `
<div class="empty-state">
<svg class="ico"><use href="#i-grid"/></svg>
<p>Keine Läufe im gewählten Zeitraum.</p>
<a class="ghost" href="/protokoll">Protokoll →</a>
</div>`;
return;
}
const printers = (data.by_printer || []).map((p) => `
<tr>
<td>${escapeHtml(p.printer_name || p.printer_host)}</td>
@@ -145,6 +170,15 @@ function render(data) {
<td class="num">${m.jobs}</td>
</tr>`).join("") || '<tr><td class="empty" colspan="5">Kein Spulenabzug im Zeitraum.</td></tr>';
const chartPrinters = (data.by_printer || []).map((p) => ({
...p,
_barLabel: hours(p.duration_s),
}));
const chartMaterials = (data.by_material || []).map((m) => ({
...m,
_barLabel: grams(m.weight_g),
}));
document.getElementById("stat").innerHTML = `
<div class="kpi-row">
${kpi("Läufe", String(jobs))}
@@ -155,6 +189,16 @@ function render(data) {
${kpi("Ausschuss", scrap + " %", (o.scrap || 0) + " mit Urteil Ausschuss")}
${kpi("Schätztreue", ratio, data.estimate_count ? data.estimate_count + " Läufe mit Soll-Zeit" : "keine Soll-Zeit")}
</div>
<div class="split">
<div class="card">
<h2>Druckzeit je Drucker</h2>
${barChart(chartPrinters, "duration_s", (p) => p.printer_name || p.printer_host || "—")}
</div>
<div class="card">
<h2>Verbrauch je Material</h2>
${barChart(chartMaterials, "weight_g", (m) => [m.material_type, m.brand].filter(Boolean).join(" · ") || "—")}
</div>
</div>
<div class="card">
<h2>Nach Drucker</h2>
<div class="table-wrap"><table>
@@ -222,6 +266,17 @@ document.getElementById("reload").addEventListener("click", () => {
document.getElementById("host").addEventListener("change", () => {
load().catch((error) => showNote("stat-note", error));
});
["material", "brand"].forEach((id) => {
const node = document.getElementById(id);
if (!node) return;
node.addEventListener("change", () => load().catch((error) => showNote("stat-note", error)));
node.addEventListener("keydown", (event) => {
if (event.key === "Enter") {
event.preventDefault();
load().catch((error) => showNote("stat-note", error));
}
});
});
load().catch((error) => showNote("stat-note", error));
</script>
{% endblock %}
+2 -1
View File
@@ -1337,6 +1337,7 @@ async function refresh() {
}
window.onQueueChanged = () => refresh().catch(() => {});
window.onDruckerLive = () => refresh().catch(() => {});
document.getElementById("hide-offline").addEventListener("change", (event) => {
hideOffline = event.target.checked;
@@ -1627,7 +1628,7 @@ deskEl.addEventListener("keydown", async (event) => {
});
refresh().catch((error) => showNote("note", error));
setInterval(() => refresh().catch((error) => showNote("note", error)), 5000);
createPoller(() => refresh().catch((error) => showNote("note", error)), 12000).start();
setInterval(() => {
api("/dateien").then((rows) => { libraryFiles = rows || []; }).catch(() => {});
api("/spulen").then((rows) => { wallSpools = rows || []; }).catch(() => {});
+13 -1
View File
@@ -6,6 +6,7 @@
<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">
<a class="ghost" id="q-bedarf" href="/material#bedarf" hidden></a>
<button type="button" class="ghost" id="q-refresh"><svg class="ico"><use href="#i-refresh"/></svg>Neu laden</button>
</div>
</div>
@@ -212,7 +213,18 @@ document.getElementById("q-rows").addEventListener("click", async (event) => {
});
window.onQueueChanged = load;
window.onBedarfChanged = (data) => {
const link = document.getElementById("q-bedarf");
if (!link) return;
const n = (data.shortfall || []).length;
if (!n) {
link.hidden = true;
return;
}
link.hidden = false;
link.textContent = n + " Fehlmenge" + (n === 1 ? "" : "n");
};
load();
setInterval(load, 5000);
if (typeof refreshBedarf === "function") refreshBedarf();
</script>
{% endblock %}
+55 -8
View File
@@ -5,7 +5,11 @@
<h1>Wartung</h1>
<p class="tiny">Laufstunden und Filament je Drucker — Verschleißteile im Blick.</p>
</div>
<div class="page-head-side">
<button type="button" class="ghost" id="wartung-refresh"><svg class="ico"><use href="#i-refresh"/></svg>Neu laden</button>
</div>
</div>
<div id="wartung-summary" class="kpi-row" hidden></div>
<div id="wartung-root"></div>
<p id="wartung-note"></p>
{% endblock %}
@@ -19,22 +23,28 @@ function partRow(part) {
<td class="num">${Math.round(part.meters_since || 0).toLocaleString("de-DE")} mm</td>
<td class="num">${part.life_hours ? part.life_hours + " h" : "—"}</td>
<td class="num">${part.life_meters ? Math.round(part.life_meters) + " mm" : "—"}</td>
<td><button type="button" class="ghost sm" data-reset="${part.id}">Zurücksetzen</button></td>
<td><button type="button" class="ghost tiny-btn" data-reset="${part.id}">Zurücksetzen</button></td>
</tr>`;
}
function printerCard(block) {
const c = block.counters || {};
const overdue = (block.parts || []).filter((p) => p.overdue_hours || p.overdue_meters).length;
const parts = (block.parts || []).map(partRow).join("")
|| '<tr><td colspan="6" class="empty">Noch keine Teile.</td></tr>';
return `<section class="card">
|| '<tr><td colspan="6" class="empty">Noch keine Teile — unten anlegen.</td></tr>';
return `<section class="card wartung-card">
<div class="lib-head">
<h2>${escapeHtml(block.name || block.host)}</h2>
${overdue ? chipHtml("error", overdue + " fällig") : chipHtml("idle", "ok")}
</div>
<p class="hint">${escapeHtml(block.host)} · ${(c.hours || 0).toLocaleString("de-DE", { maximumFractionDigits: 1 })} h gesamt · ${Math.round(c.meters || 0).toLocaleString("de-DE")} mm · ${c.runs || 0} Läufe</p>
<div class="table-wrap"><table>
<thead><tr><th>Teil</th><th>Seit Reset</th><th>Seit Reset</th><th>Intervall h</th><th>Intervall mm</th><th></th></tr></thead>
<thead><tr><th>Teil</th><th>Seit Reset</th><th>Filament</th><th>Intervall h</th><th>Intervall mm</th><th></th></tr></thead>
<tbody>${parts}</tbody>
</table></div>
<form class="grid-2 wartung-add" data-host="${escapeHtml(block.host)}" style="margin-top:0.75rem">
<details class="mat-more section-gap">
<summary><svg class="ico caret"><use href="#i-down"/></svg><span class="grow">Teil anlegen</span></summary>
<form class="grid-2 wartung-add" data-host="${escapeHtml(block.host)}">
<div class="field"><label>Schlüssel</label><input name="part_key" required placeholder="nozzle"></div>
<div class="field"><label>Bezeichnung</label><input name="label" placeholder="Düse 0.4"></div>
<div class="field"><label>Intervall h</label><input name="life_hours" type="number" step="1" min="0" value="0"></div>
@@ -42,15 +52,48 @@ function printerCard(block) {
<div class="field" style="grid-column:1/-1"><label>Notiz</label><input name="note"></div>
<div class="actions"><button type="submit">Teil anlegen</button></div>
</form>
</details>
</section>`;
}
function renderSummary(data) {
const box = document.getElementById("wartung-summary");
if (!data.length) {
box.hidden = true;
box.innerHTML = "";
return;
}
let hours = 0;
let meters = 0;
let overdue = 0;
data.forEach((block) => {
hours += Number((block.counters || {}).hours) || 0;
meters += Number((block.counters || {}).meters) || 0;
overdue += (block.parts || []).filter((p) => p.overdue_hours || p.overdue_meters).length;
});
box.hidden = false;
box.innerHTML = `
<div class="kpi"><div class="kpi-val">${data.length}</div><div class="kpi-label">Drucker</div></div>
<div class="kpi"><div class="kpi-val">${hours.toLocaleString("de-DE", { maximumFractionDigits: 0 })} h</div><div class="kpi-label">Laufzeit</div></div>
<div class="kpi"><div class="kpi-val">${Math.round(meters / 1000).toLocaleString("de-DE")} m</div><div class="kpi-label">Filament</div></div>
<div class="kpi"><div class="kpi-val">${overdue}</div><div class="kpi-label">Fällig</div></div>`;
}
async function load() {
const data = await api("/wartung");
renderSummary(data || []);
document.getElementById("wartung-root").innerHTML = (data || []).map(printerCard).join("")
|| '<p class="empty">Keine Drucker angelegt.</p>';
|| `<div class="empty-state">
<svg class="ico"><use href="#i-nozzle"/></svg>
<p>Keine Drucker angelegt.</p>
<a class="ghost" href="/einstellungen">Einstellungen →</a>
</div>`;
}
document.getElementById("wartung-refresh")?.addEventListener("click", () => {
load().catch((error) => showNote("wartung-note", error));
});
document.getElementById("wartung-root").addEventListener("submit", async (event) => {
const form = event.target.closest("form.wartung-add");
if (!form) return;
@@ -70,24 +113,28 @@ document.getElementById("wartung-root").addEventListener("submit", async (event)
});
form.reset();
showNote("wartung-note", null, "Teil gespeichert.");
toast("Wartungsteil angelegt.");
load();
} catch (error) {
showNote("wartung-note", error);
}
});
document.getElementById("wartung-root").addEventListener("click", async (event) => {
document.getElementById("wartung-root").addEventListener("click", (event) => {
const btn = event.target.closest("[data-reset]");
if (!btn) return;
armButton(btn, "Wirklich zurücksetzen?", async () => {
try {
await api("/wartung/teile/" + btn.dataset.reset + "/reset", { method: "POST", json: {} });
showNote("wartung-note", null, "Teil zurückgesetzt.");
toast("Zähler zurückgesetzt.");
load();
} catch (error) {
showNote("wartung-note", error);
}
});
});
load();
load().catch((error) => showNote("wartung-note", error));
</script>
{% endblock %}