Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6e59df22e5 |
No files matched your search
@@ -4,6 +4,30 @@ Alle Änderungen an Grotti. Format nach [Keep a Changelog](https://keepachangelo
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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
|
## [3.0.0] – 2026-10-18
|
||||||
|
|
||||||
Major: neue Tabellen (Aufträge, Wartung), Projekte/Ideen → Aufträge, Navigation umgebaut, Dev-Server → waitress, Schema über Migrationen.
|
Major: neue Tabellen (Aufträge, Wartung), Projekte/Ideen → Aufträge, Navigation umgebaut, Dev-Server → waitress, Schema über Migrationen.
|
||||||
|
|||||||
+7
-1
@@ -23,7 +23,13 @@
|
|||||||
"dryer_watts": 0,
|
"dryer_watts": 0,
|
||||||
"dry_hours": 0,
|
"dry_hours": 0,
|
||||||
"waste_percent": 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": {
|
"telegram": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
# Einstellungen
|
# Einstellungen
|
||||||
|
|
||||||
Stand **17.10.2026**. Seite `/einstellungen` — Drucker, Automatik, Anzeige, Telegram, Energie.
|
Stand **18.10.2026**. Seite `/einstellungen` — Drucker, Automatik, Anzeige, Telegram/ntfy, Energie.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
## Dieser Browser
|
## Dieser Browser
|
||||||
|
|
||||||
- **Desktop** oder **Regal (Touch)** — nur lokal im Browser, siehe [Oberfläche](Anleitung-Oberflaeche.md).
|
- **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.
|
- **Zeitzone** — Browser automatisch oder fest (z. B. Europe/Berlin) für Protokoll, Bericht und Aktivität.
|
||||||
|
|
||||||
## Automatik
|
## Automatik
|
||||||
@@ -19,29 +20,30 @@ Pfad zum Ordner, den die Bibliothek beobachtet (Export aus Orca/Anycubic, **Ordn
|
|||||||
|
|
||||||
## Drucker
|
## 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).
|
- Seed beim allerersten Start aus `drucker.json`, danach nur noch hier (Datenbank).
|
||||||
- Familie aus dem Namen (Kobra 3 / S1) oder gesetzt.
|
- Familie aus dem Namen (Kobra 3 / S1) oder gesetzt.
|
||||||
- **Gesperrt:** keine neuen Queue-Starts auf diesem Host.
|
- **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
|
## 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.
|
Ö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
|
## 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`.
|
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
|
## 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
|
## Weiter
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Material und mehr
|
# 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.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
@@ -8,6 +8,8 @@ Stand **17.10.2026**. Spulen, Etiketten, Projekte, Protokoll, Statistik, Kameras
|
|||||||
|
|
||||||
Zwei Reiter: **Spulen** und **Sorten**.
|
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.
|
**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.
|
**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.
|
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
|
## 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.
|
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.
|
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.
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
# Oberfläche
|
# 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
|
## 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.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
## Live-Leiste
|
## 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 |
|
| 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). |
|
| **Aufgaben** | Platte belegt ohne laufenden Ablauf — **geräumt** (Zwei-Klick). |
|
||||||
| **Senden** | Uploads mit MB-Fortschritt. |
|
| **Senden** | Uploads mit MB-Fortschritt. |
|
||||||
| **Alerts** | Ungelesene wichtige Ereignisse; als gelesen markieren oder zur Aktivität. |
|
| **Alerts** | Ungelesene wichtige Ereignisse; als gelesen markieren oder zur Aktivität. |
|
||||||
|
| **Bedarf** | Fehlende Filamentmenge für die Queue (Link zu Material). |
|
||||||
|
|
||||||
## Desktop und Regal
|
## Desktop und Regal
|
||||||
|
|
||||||
@@ -28,6 +29,7 @@ Unter **Einstellungen → Dieser Browser**:
|
|||||||
|
|
||||||
- **Desktop** — normale Dichte
|
- **Desktop** — normale Dichte
|
||||||
- **Regal (Touch)** — größere Bedienelemente, kompaktere Nav, Sidebar oft eingeklappt
|
- **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
|
- **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`).
|
Shortcut in der URL: `?shelf=1` bzw. `?shelf=0`. Nur dieser Browser (`localStorage`).
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Warteschlange und Ablauf
|
# 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).
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
@@ -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.
|
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?
|
## 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)
|
## Ablauf (nur Kobra 3)
|
||||||
|
|
||||||
|
|||||||
+7
-3
@@ -15,7 +15,7 @@ def compute(conn):
|
|||||||
queue_rows = conn.execute(
|
queue_rows = conn.execute(
|
||||||
"""
|
"""
|
||||||
SELECT q.id, q.spool_id, q.repeat_total, q.repeat_done,
|
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
|
s.material_type AS spool_material, s.color AS spool_color
|
||||||
FROM print_queue q
|
FROM print_queue q
|
||||||
JOIN files f ON f.id = q.file_id
|
JOIN files f ON f.id = q.file_id
|
||||||
@@ -26,8 +26,12 @@ def compute(conn):
|
|||||||
|
|
||||||
needed_map = {}
|
needed_map = {}
|
||||||
for row in queue_rows:
|
for row in queue_rows:
|
||||||
mat = (row.get("spool_material") or row.get("file_material") or "").strip()
|
filaments = row.get("filaments") or []
|
||||||
color = (row.get("spool_color") or row.get("file_color") or "").strip()
|
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)
|
key = _group_key(mat, color)
|
||||||
try:
|
try:
|
||||||
repeats = max(1, int(row.get("repeat_total") or 1) - int(row.get("repeat_done") or 0))
|
repeats = max(1, int(row.get("repeat_total") or 1) - int(row.get("repeat_done") or 0))
|
||||||
|
|||||||
+1
-1
@@ -300,7 +300,7 @@ def _save_settings_locked(
|
|||||||
"utilization": float(energy.get("utilization", 50)),
|
"utilization": float(energy.get("utilization", 50)),
|
||||||
}
|
}
|
||||||
tariff_in = energy.get("tariff") if isinstance(energy.get("tariff"), dict) else None
|
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"] = {
|
raw_energy["tariff"] = {
|
||||||
"ht_euro": float(tariff_in.get("ht_euro", raw_energy["euro_per_kwh"])),
|
"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"])),
|
"nt_euro": float(tariff_in.get("nt_euro", raw_energy["euro_per_kwh"])),
|
||||||
|
|||||||
+11
-3
@@ -2081,20 +2081,28 @@ def create_printer(conn, printer_id, name, host, family):
|
|||||||
return printer_out(row)
|
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)
|
current = printer_by_id(conn, printer_id)
|
||||||
if not current:
|
if not current:
|
||||||
return None
|
return None
|
||||||
if locked is None:
|
if locked is None:
|
||||||
locked = bool(current.get("locked"))
|
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(
|
row = conn.execute(
|
||||||
"""
|
"""
|
||||||
UPDATE printers
|
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
|
WHERE id = %s
|
||||||
RETURNING *
|
RETURNING *
|
||||||
""",
|
""",
|
||||||
(name, host, family, bool(locked), printer_id),
|
(name, host, family, bool(locked), Json(sw), printer_id),
|
||||||
).fetchone()
|
).fetchone()
|
||||||
if current["host"] != host:
|
if current["host"] != host:
|
||||||
old = current["host"]
|
old = current["host"]
|
||||||
|
|||||||
+1
-1
@@ -1,3 +1,3 @@
|
|||||||
"""Version von Grotti (SemVer). Bei jeder Änderung CHANGELOG.md mitführen."""
|
"""Version von Grotti (SemVer). Bei jeder Änderung CHANGELOG.md mitführen."""
|
||||||
|
|
||||||
__version__ = "3.0.0"
|
__version__ = "3.1.0"
|
||||||
@@ -26,8 +26,7 @@ class BedarfTests(unittest.TestCase):
|
|||||||
"repeat_total": 2,
|
"repeat_total": 2,
|
||||||
"repeat_done": 0,
|
"repeat_done": 0,
|
||||||
"weight_g": 50.0,
|
"weight_g": 50.0,
|
||||||
"file_material": "PLA",
|
"filaments": [{"type": "PLA", "color": "Schwarz"}],
|
||||||
"file_color": "Schwarz",
|
|
||||||
"spool_material": "PLA",
|
"spool_material": "PLA",
|
||||||
"spool_color": "Schwarz",
|
"spool_color": "Schwarz",
|
||||||
},
|
},
|
||||||
@@ -52,8 +51,7 @@ class BedarfTests(unittest.TestCase):
|
|||||||
"repeat_total": 1,
|
"repeat_total": 1,
|
||||||
"repeat_done": 0,
|
"repeat_done": 0,
|
||||||
"weight_g": 20.0,
|
"weight_g": 20.0,
|
||||||
"file_material": "PETG",
|
"filaments": [{"type": "PETG", "color": ""}],
|
||||||
"file_color": "",
|
|
||||||
"spool_material": None,
|
"spool_material": None,
|
||||||
"spool_color": None,
|
"spool_color": None,
|
||||||
},
|
},
|
||||||
|
|||||||
+24
-11
@@ -103,6 +103,7 @@ def create_app(cfg):
|
|||||||
)
|
)
|
||||||
ADMIN_API_EXACT = {
|
ADMIN_API_EXACT = {
|
||||||
"/api/v1/telegram/test",
|
"/api/v1/telegram/test",
|
||||||
|
"/api/v1/ntfy/test",
|
||||||
}
|
}
|
||||||
ADMIN_METHODS_ON = {
|
ADMIN_METHODS_ON = {
|
||||||
# Drucker anlegen/ändern/löschen nur Admin
|
# Drucker anlegen/ändern/löschen nur Admin
|
||||||
@@ -135,14 +136,6 @@ def create_app(cfg):
|
|||||||
return user
|
return user
|
||||||
|
|
||||||
def resolve_bearer(presented):
|
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):
|
if not presented.startswith(grotti_auth.TOKEN_PREFIX):
|
||||||
return None
|
return None
|
||||||
row = store.get_api_token_by_hash(conn(), grotti_auth.hash_token(presented))
|
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})
|
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/v1/drucker")
|
||||||
@app.get("/api/drucker") # veraltet — Alias zu /api/v1/drucker
|
|
||||||
def printers():
|
def printers():
|
||||||
rows = store.list_printers(conn())
|
rows = store.list_printers(conn())
|
||||||
clears = store.bed_clear_map(conn())
|
clears = store.bed_clear_map(conn())
|
||||||
@@ -908,7 +910,18 @@ def create_app(cfg):
|
|||||||
if other and str(other["id"]) != str(printer_id):
|
if other and str(other["id"]) != str(printer_id):
|
||||||
raise GrottiError("Diesen Host gibt es schon.", "host")
|
raise GrottiError("Diesen Host gibt es schon.", "host")
|
||||||
locked = data.get("locked") if "locked" in data else current.get("locked")
|
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)
|
return jsonify(row)
|
||||||
|
|
||||||
@app.delete("/api/v1/drucker/id/<printer_id>")
|
@app.delete("/api/v1/drucker/id/<printer_id>")
|
||||||
@@ -2388,12 +2401,12 @@ def create_app(cfg):
|
|||||||
finally:
|
finally:
|
||||||
events_bus.unsubscribe(q)
|
events_bus.unsubscribe(q)
|
||||||
|
|
||||||
|
# Kein Connection-Header: waitress verbietet Hop-by-Hop-Header (PEP 3333).
|
||||||
return Response(
|
return Response(
|
||||||
stream_with_context(generate()),
|
stream_with_context(generate()),
|
||||||
mimetype="text/event-stream; charset=utf-8",
|
mimetype="text/event-stream; charset=utf-8",
|
||||||
headers={
|
headers={
|
||||||
"Cache-Control": "no-cache",
|
"Cache-Control": "no-cache",
|
||||||
"Connection": "keep-alive",
|
|
||||||
"X-Accel-Buffering": "no",
|
"X-Accel-Buffering": "no",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
+17
-1
@@ -28,13 +28,29 @@ async function api(path, options = {}) {
|
|||||||
headers.set("Content-Type", "application/json");
|
headers.set("Content-Type", "application/json");
|
||||||
body = JSON.stringify(options.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,
|
method,
|
||||||
headers,
|
headers,
|
||||||
body,
|
body,
|
||||||
cache: "no-store",
|
cache: "no-store",
|
||||||
credentials: "same-origin",
|
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) {
|
if (response.status === 401) {
|
||||||
const here = location.pathname + location.search;
|
const here = location.pathname + location.search;
|
||||||
if (!here.startsWith("/login") && !here.startsWith("/setup")) {
|
if (!here.startsWith("/login") && !here.startsWith("/setup")) {
|
||||||
|
|||||||
+95
-2
@@ -2398,7 +2398,7 @@ label.button:hover { background: rgba(255, 255, 255, 0.04); }
|
|||||||
.nav-main { display: none; }
|
.nav-main { display: none; }
|
||||||
html.shelf .mobile-nav,
|
html.shelf .mobile-nav,
|
||||||
body.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; }
|
.nav-secondary { margin-top: 0; border-top: 0; padding-top: 0; flex: 1; justify-content: flex-end; }
|
||||||
.side-foot { display: none; }
|
.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; }
|
.desk:not(.no-cam) .desk-cam { max-height: 18rem; }
|
||||||
.mobile-nav {
|
.mobile-nav {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, 1fr);
|
grid-template-columns: repeat(5, 1fr);
|
||||||
position: fixed;
|
position: fixed;
|
||||||
left: 0;
|
left: 0;
|
||||||
right: 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 {
|
@media print {
|
||||||
.sidebar, .mobile-nav, .conn-banner, .toast-host, .nav-toggle { display: none !important; }
|
.sidebar, .mobile-nav, .conn-banner, .toast-host, .nav-toggle { display: none !important; }
|
||||||
.app-shell { display: block; }
|
.app-shell { display: block; }
|
||||||
|
|||||||
+26
-7
@@ -1,27 +1,46 @@
|
|||||||
/* Minimaler Service-Worker: App-Shell-Cache für Offline-Hinweis. */
|
/* Nur Static-Assets cachen — nie HTML-Navigation abfangen (sonst falsche Seite in Chrome). */
|
||||||
const CACHE = "grotti-shell-v1";
|
const CACHE = "grotti-shell-v2";
|
||||||
const SHELL = ["/", "/static/grotti.css?v=54", "/static/api.js?v=28", "/static/ui.js?v=2"];
|
|
||||||
|
|
||||||
self.addEventListener("install", (event) => {
|
self.addEventListener("install", (event) => {
|
||||||
event.waitUntil(caches.open(CACHE).then((c) => c.addAll(SHELL).catch(() => undefined)));
|
|
||||||
self.skipWaiting();
|
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) => {
|
self.addEventListener("activate", (event) => {
|
||||||
event.waitUntil(
|
event.waitUntil(
|
||||||
caches.keys().then((keys) =>
|
caches.keys().then((keys) =>
|
||||||
Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k)))
|
Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k)))
|
||||||
)
|
).then(() => self.clients.claim())
|
||||||
);
|
);
|
||||||
self.clients.claim();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
self.addEventListener("fetch", (event) => {
|
self.addEventListener("fetch", (event) => {
|
||||||
const req = event.request;
|
const req = event.request;
|
||||||
if (req.method !== "GET") return;
|
if (req.method !== "GET") return;
|
||||||
|
// Dokument-Navigation immer ans Netz — kein Fallback auf „/“.
|
||||||
|
if (req.mode === "navigate") return;
|
||||||
const url = new URL(req.url);
|
const url = new URL(req.url);
|
||||||
|
if (url.origin !== self.location.origin) return;
|
||||||
if (url.pathname.startsWith("/api/")) return;
|
if (url.pathname.startsWith("/api/")) return;
|
||||||
|
if (!url.pathname.startsWith("/static/")) return;
|
||||||
|
|
||||||
event.respondWith(
|
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))
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -203,16 +203,22 @@ function renderList() {
|
|||||||
body.innerHTML = '<tr><td colspan="3" class="empty">Noch kein Auftrag.</td></tr>';
|
body.innerHTML = '<tr><td colspan="3" class="empty">Noch kein Auftrag.</td></tr>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const selected = document.getElementById("order-id").value;
|
||||||
orders.forEach((order) => {
|
orders.forEach((order) => {
|
||||||
const tr = document.createElement("tr");
|
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.innerHTML = "<td></td><td></td><td></td>";
|
||||||
tr.children[0].textContent = order.title;
|
tr.children[0].textContent = order.title;
|
||||||
tr.children[1].textContent = STATUS_LABEL[order.status] || order.status;
|
tr.children[1].textContent = STATUS_LABEL[order.status] || order.status;
|
||||||
tr.children[2].textContent = (order.progress && order.progress.label) || "";
|
tr.children[2].textContent = (order.progress && order.progress.label) || "";
|
||||||
tr.addEventListener("click", async () => {
|
tr.addEventListener("click", async () => {
|
||||||
|
try {
|
||||||
const full = await api("/auftraege/" + order.id);
|
const full = await api("/auftraege/" + order.id);
|
||||||
fillOrder(full);
|
fillOrder(full);
|
||||||
|
renderList();
|
||||||
|
} catch (error) {
|
||||||
|
showNote("order-note", error);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
body.appendChild(tr);
|
body.appendChild(tr);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,15 +10,23 @@
|
|||||||
<section class="card">
|
<section class="card">
|
||||||
<h2>Dieser Browser</h2>
|
<h2>Dieser Browser</h2>
|
||||||
<p class="hint">Nur auf diesem Gerät / in diesem Browser. Anderer PC oder Inkognito bleibt unberührt.</p>
|
<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>
|
<label for="display-mode">Anzeige</label>
|
||||||
<select id="display-mode">
|
<select id="display-mode">
|
||||||
<option value="desktop">Desktop</option>
|
<option value="desktop">Desktop</option>
|
||||||
<option value="shelf">Regal (Touch)</option>
|
<option value="shelf">Regal (Touch)</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</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">
|
||||||
<div class="field" style="max-width:16rem; margin-top:0.85rem">
|
<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>
|
<label for="tz-mode">Zeitzone</label>
|
||||||
<select id="tz-mode">
|
<select id="tz-mode">
|
||||||
<option value="auto">Browser (automatisch)</option>
|
<option value="auto">Browser (automatisch)</option>
|
||||||
@@ -28,7 +36,8 @@
|
|||||||
<option value="UTC">UTC</option>
|
<option value="UTC">UTC</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</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>
|
<p id="display-note" class="ok" hidden>Gespeichert — gilt sofort.</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -68,14 +77,9 @@
|
|||||||
|
|
||||||
<section class="card admin-only">
|
<section class="card admin-only">
|
||||||
<h2>Drucker</h2>
|
<h2>Drucker</h2>
|
||||||
<p class="hint">Gesperrt = kein neuer „wenn frei“-Start auf diesem Drucker.</p>
|
<p class="hint">Gesperrt = kein neuer „wenn frei“-Start. Zeitfenster begrenzt Starts (z. B. Nachtstrom).</p>
|
||||||
<div class="table-wrap">
|
<div id="printer-list" class="printer-edit-list"></div>
|
||||||
<table>
|
<form id="printer-add" class="grid-2 section-gap">
|
||||||
<thead><tr><th>Name</th><th>Host</th><th>Familie</th><th>Gesperrt</th><th></th></tr></thead>
|
|
||||||
<tbody id="printer-rows"></tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<form id="printer-add" class="grid-2" style="margin-top:1rem">
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="add-name">Name</label>
|
<label for="add-name">Name</label>
|
||||||
<input id="add-name" required placeholder="z. B. Kobra 3 Werkstatt">
|
<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">
|
<input id="ntfy-token" type="password" autocomplete="off" placeholder="Bearer-Token">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="hint">Gleiche Meldungen wie Telegram — unabhängig abschaltbar.</p>
|
<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></div>
|
<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>
|
<p id="ntfy-note"></p>
|
||||||
</section>
|
</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-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 class="field"><label for="en-util">Auslastung %</label><input id="en-util" type="number" step="1"></div>
|
||||||
</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>
|
<p id="en-ed-hint" class="hint" hidden></p>
|
||||||
<div class="actions"><button type="button" id="en-save">Speichern</button></div>
|
<div class="actions"><button type="button" id="en-save">Speichern</button></div>
|
||||||
<p id="en-note"></p>
|
<p id="en-note"></p>
|
||||||
@@ -217,7 +232,7 @@
|
|||||||
</form>
|
</form>
|
||||||
<p id="token-note"></p>
|
<p id="token-note"></p>
|
||||||
<p class="ok" id="token-once" hidden></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>
|
</section>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
{% block script %}
|
{% block script %}
|
||||||
@@ -234,39 +249,87 @@ if (!isAdmin) {
|
|||||||
|
|
||||||
/* showNote aus api.js: Inline-Hinweis + Toast */
|
/* 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) {
|
function renderPrinters(rows) {
|
||||||
const body = document.getElementById("printer-rows");
|
const root = document.getElementById("printer-list");
|
||||||
body.innerHTML = "";
|
root.innerHTML = "";
|
||||||
if (!rows.length) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
rows.forEach((row) => {
|
rows.forEach((row) => {
|
||||||
const tr = document.createElement("tr");
|
const win = windowFrom(row);
|
||||||
tr.innerHTML = `<td><input data-f="name"></td><td><input data-f="host"></td><td><select data-f="family">
|
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_3">Kobra 3</option>
|
||||||
<option value="kobra_s1">Kobra S1</option>
|
<option value="kobra_s1">Kobra S1</option>
|
||||||
<option value="">—</option>
|
<option value="">—</option>
|
||||||
</select></td><td><label class="check"><input type="checkbox" data-f="locked"></label></td><td></td>`;
|
</select></div>
|
||||||
tr.querySelector('[data-f="name"]').value = row.name || "";
|
<div class="field"><label class="check" style="margin-top:1.4rem"><input type="checkbox" data-f="locked"> Gesperrt</label></div>
|
||||||
tr.querySelector('[data-f="host"]').value = row.host || "";
|
</div>
|
||||||
tr.querySelector('[data-f="family"]').value = row.family || "";
|
<details class="mat-more" ${win.enabled ? "open" : ""}>
|
||||||
tr.querySelector('[data-f="locked"]').checked = !!row.locked;
|
<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");
|
const save = document.createElement("button");
|
||||||
save.type = "button";
|
save.type = "button";
|
||||||
save.textContent = "Speichern";
|
save.textContent = "Speichern";
|
||||||
save.addEventListener("click", async () => {
|
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 {
|
try {
|
||||||
await api("/drucker/id/" + row.id, {
|
await api("/drucker/id/" + row.id, {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
json: {
|
json: {
|
||||||
name: tr.querySelector('[data-f="name"]').value,
|
name: card.querySelector('[data-f="name"]').value,
|
||||||
host: tr.querySelector('[data-f="host"]').value,
|
host: card.querySelector('[data-f="host"]').value,
|
||||||
family: tr.querySelector('[data-f="family"]').value,
|
family: card.querySelector('[data-f="family"]').value,
|
||||||
locked: tr.querySelector('[data-f="locked"]').checked,
|
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();
|
load();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showNote("printer-note", error);
|
showNote("printer-note", error);
|
||||||
@@ -287,9 +350,8 @@ function renderPrinters(rows) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
tr.children[4].appendChild(save);
|
card.querySelector(".actions").append(save, del);
|
||||||
tr.children[4].appendChild(del);
|
root.appendChild(card);
|
||||||
body.appendChild(tr);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -430,6 +492,13 @@ async function load() {
|
|||||||
document.getElementById("en-dryh").value = en.dry_hours ?? 0;
|
document.getElementById("en-dryh").value = en.dry_hours ?? 0;
|
||||||
document.getElementById("en-waste").value = en.waste_percent ?? 0;
|
document.getElementById("en-waste").value = en.waste_percent ?? 0;
|
||||||
document.getElementById("en-util").value = en.utilization ?? 50;
|
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();
|
updateEnergyEdHint();
|
||||||
}
|
}
|
||||||
const legacy = document.getElementById("legacy-token-hint");
|
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() {
|
function updateEnergyEdHint() {
|
||||||
const hint = document.getElementById("en-ed-hint");
|
const hint = document.getElementById("en-ed-hint");
|
||||||
if (!hint) return;
|
if (!hint) return;
|
||||||
@@ -657,10 +739,7 @@ document.getElementById("en-save").addEventListener("click", async () => {
|
|||||||
try {
|
try {
|
||||||
const nozzle = Number(document.getElementById("en-nozzle").value) || 0;
|
const nozzle = Number(document.getElementById("en-nozzle").value) || 0;
|
||||||
const bed = Number(document.getElementById("en-bed").value) || 0;
|
const bed = Number(document.getElementById("en-bed").value) || 0;
|
||||||
await api("/einstellungen", {
|
const energy = {
|
||||||
method: "PATCH",
|
|
||||||
json: {
|
|
||||||
energy: {
|
|
||||||
watts: Number(document.getElementById("en-watts").value),
|
watts: Number(document.getElementById("en-watts").value),
|
||||||
nozzle_watts: nozzle,
|
nozzle_watts: nozzle,
|
||||||
bed_watts: bed,
|
bed_watts: bed,
|
||||||
@@ -669,9 +748,16 @@ document.getElementById("en-save").addEventListener("click", async () => {
|
|||||||
dry_hours: Number(document.getElementById("en-dryh").value),
|
dry_hours: Number(document.getElementById("en-dryh").value),
|
||||||
waste_percent: Number(document.getElementById("en-waste").value),
|
waste_percent: Number(document.getElementById("en-waste").value),
|
||||||
utilization: Number(document.getElementById("en-util").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();
|
updateEnergyEdHint();
|
||||||
if (nozzle <= 0 || bed <= 0) {
|
if (nozzle <= 0 || bed <= 0) {
|
||||||
const note = document.getElementById("en-note");
|
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() {
|
(function initDisplayMode() {
|
||||||
const sel = document.getElementById("display-mode");
|
const sel = document.getElementById("display-mode");
|
||||||
if (!sel) return;
|
if (!sel) return;
|
||||||
@@ -703,11 +796,28 @@ document.getElementById("en-save").addEventListener("click", async () => {
|
|||||||
document.documentElement.classList.toggle("shelf", sel.value === "shelf");
|
document.documentElement.classList.toggle("shelf", sel.value === "shelf");
|
||||||
document.body.classList.toggle("shelf", sel.value === "shelf");
|
document.body.classList.toggle("shelf", sel.value === "shelf");
|
||||||
}
|
}
|
||||||
const note = document.getElementById("display-note");
|
flashDisplayNote();
|
||||||
if (note) {
|
});
|
||||||
note.hidden = false;
|
})();
|
||||||
setTimeout(() => { note.hidden = true; }, 2500);
|
|
||||||
|
(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();
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
|||||||
+111
-16
@@ -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="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="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="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') }}">
|
<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>
|
<script>
|
||||||
(function () {
|
(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 {
|
try {
|
||||||
if (!localStorage.getItem("grotti-display") && localStorage.getItem("farm-display")) {
|
if (!localStorage.getItem("grotti-display") && localStorage.getItem("farm-display")) {
|
||||||
localStorage.setItem("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")) {
|
if (nav === "1" || (nav == null && localStorage.getItem("grotti-display") === "shelf")) {
|
||||||
document.documentElement.classList.add("nav-collapsed");
|
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) {}
|
} catch (e) {}
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
@@ -132,6 +156,11 @@
|
|||||||
<ul class="queue-list" id="alert-list"></ul>
|
<ul class="queue-list" id="alert-list"></ul>
|
||||||
<div class="queue-foot"><button type="button" class="ghost tiny-btn" id="events-clear">gelesen</button> <a href="/aktivitaet">alle →</a></div>
|
<div class="queue-foot"><button type="button" class="ghost tiny-btn" id="events-clear">gelesen</button> <a href="/aktivitaet">alle →</a></div>
|
||||||
</details>
|
</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 →</a></div>
|
||||||
|
</details>
|
||||||
</div>
|
</div>
|
||||||
<nav class="nav-secondary" aria-label="Weitere Seiten">
|
<nav class="nav-secondary" aria-label="Weitere Seiten">
|
||||||
<a href="/einstellungen" class="{{ 'on' if page == 'einstellungen' else '' }}" title="Einstellungen"><svg class="ico"><use href="#i-settings"/></svg><span>Einstellungen</span></a>
|
<a href="/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="/einstellungen">Einstellungen</a>
|
||||||
<a href="/doku">Doku</a>
|
<a href="/doku">Doku</a>
|
||||||
</nav>
|
</nav>
|
||||||
<form method="dialog"><button class="ghost">Schließen</button></form>
|
<form method="dialog"><button class="ghost" value="cancel">Schließen</button></form>
|
||||||
</dialog>
|
</dialog>
|
||||||
<script src="{{ url_for('static', filename='api.js') }}?v=28"></script>
|
<script src="{{ url_for('static', filename='api.js') }}?v=29"></script>
|
||||||
<script src="{{ url_for('static', filename='ui.js') }}?v=2"></script>
|
<script src="{{ url_for('static', filename='ui.js') }}?v=3"></script>
|
||||||
<script>
|
<script>
|
||||||
document.getElementById("logout-btn")?.addEventListener("click", async () => {
|
document.getElementById("logout-btn")?.addEventListener("click", async () => {
|
||||||
try { await api("/auth/logout", { method: "POST", json: {} }); } catch (e) {}
|
try { await api("/auth/logout", { method: "POST", json: {} }); } catch (e) {}
|
||||||
@@ -477,6 +506,25 @@ async function refreshEvents() {
|
|||||||
</li>`).join("");
|
</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() {
|
async function refreshTasks() {
|
||||||
let items;
|
let items;
|
||||||
try { items = await api("/aufgaben"); } catch (error) { return; }
|
try { items = await api("/aufgaben"); } catch (error) { return; }
|
||||||
@@ -508,11 +556,29 @@ async function refreshTasks() {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
document.getElementById("mobile-more")?.addEventListener("click", () => {
|
(function mobileMoreSheet() {
|
||||||
document.getElementById("mobile-more-sheet")?.showModal();
|
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) {
|
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) => {
|
document.getElementById("events-clear").addEventListener("click", async (event) => {
|
||||||
@@ -524,15 +590,21 @@ document.getElementById("events-clear").addEventListener("click", async (event)
|
|||||||
|
|
||||||
window.refreshPrintQueue = refreshPrintQueue;
|
window.refreshPrintQueue = refreshPrintQueue;
|
||||||
window.refreshSendQueue = refreshSendQueue;
|
window.refreshSendQueue = refreshSendQueue;
|
||||||
|
window.refreshBedarf = refreshBedarf;
|
||||||
refreshSendQueue();
|
refreshSendQueue();
|
||||||
refreshPrintQueue();
|
refreshPrintQueue();
|
||||||
refreshEvents();
|
refreshEvents();
|
||||||
refreshTasks();
|
refreshTasks();
|
||||||
|
refreshBedarf();
|
||||||
window.refreshTasks = refreshTasks;
|
window.refreshTasks = refreshTasks;
|
||||||
|
|
||||||
(function liveUpdates() {
|
(function liveUpdates() {
|
||||||
|
let streamLive = false;
|
||||||
const sseKinds = {
|
const sseKinds = {
|
||||||
queue: () => refreshPrintQueue(),
|
queue: () => {
|
||||||
|
refreshPrintQueue();
|
||||||
|
refreshBedarf();
|
||||||
|
},
|
||||||
senden: () => refreshSendQueue(),
|
senden: () => refreshSendQueue(),
|
||||||
ereignis: () => refreshEvents(),
|
ereignis: () => refreshEvents(),
|
||||||
aufgaben: () => refreshTasks(),
|
aufgaben: () => refreshTasks(),
|
||||||
@@ -543,23 +615,37 @@ window.refreshTasks = refreshTasks;
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function whenStreamDown(fn) {
|
||||||
|
return async () => {
|
||||||
|
if (streamLive) return;
|
||||||
|
await fn();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (typeof EventSource !== "undefined") {
|
if (typeof EventSource !== "undefined") {
|
||||||
let es;
|
let es;
|
||||||
let retryMs = 2000;
|
let retryMs = 2000;
|
||||||
const retryMax = 60000;
|
const retryMax = 60000;
|
||||||
let hadStream = false;
|
let hadStream = false;
|
||||||
|
let retryTimer = 0;
|
||||||
|
|
||||||
function connectStream() {
|
function connectStream() {
|
||||||
|
if (es) {
|
||||||
|
try { es.close(); } catch (e) {}
|
||||||
|
}
|
||||||
es = new EventSource("/api/v1/stream", { withCredentials: true });
|
es = new EventSource("/api/v1/stream", { withCredentials: true });
|
||||||
es.addEventListener("open", () => {
|
es.addEventListener("open", () => {
|
||||||
|
streamLive = true;
|
||||||
retryMs = 2000;
|
retryMs = 2000;
|
||||||
if (hadStream) setConnectionBanner(true, "Live-Verbindung wieder da.");
|
if (hadStream) setConnectionBanner(true, "Live-Verbindung wieder da.");
|
||||||
hadStream = true;
|
hadStream = true;
|
||||||
});
|
});
|
||||||
es.addEventListener("error", () => {
|
es.addEventListener("error", () => {
|
||||||
es.close();
|
streamLive = false;
|
||||||
|
try { es.close(); } catch (e) {}
|
||||||
setConnectionBanner(false, "Live-Verbindung unterbrochen — Fallback-Polling läuft.");
|
setConnectionBanner(false, "Live-Verbindung unterbrochen — Fallback-Polling läuft.");
|
||||||
setTimeout(connectStream, retryMs);
|
clearTimeout(retryTimer);
|
||||||
|
retryTimer = setTimeout(connectStream, retryMs);
|
||||||
retryMs = Math.min(retryMax, retryMs * 2);
|
retryMs = Math.min(retryMax, retryMs * 2);
|
||||||
});
|
});
|
||||||
Object.keys(sseKinds).forEach((kind) => {
|
Object.keys(sseKinds).forEach((kind) => {
|
||||||
@@ -568,12 +654,21 @@ window.refreshTasks = refreshTasks;
|
|||||||
es.addEventListener("ping", () => {});
|
es.addEventListener("ping", () => {});
|
||||||
}
|
}
|
||||||
connectStream();
|
connectStream();
|
||||||
|
window.addEventListener("pagehide", () => {
|
||||||
|
streamLive = false;
|
||||||
|
clearTimeout(retryTimer);
|
||||||
|
if (es) {
|
||||||
|
try { es.close(); } catch (e) {}
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
createPoller(refreshPrintQueue, 8000).start();
|
// Poller nur als Fallback, wenn SSE tot ist — sonst staut Chrome die Verbindungen.
|
||||||
createPoller(refreshSendQueue, 4000).start();
|
createPoller(whenStreamDown(refreshPrintQueue), 8000).start();
|
||||||
createPoller(refreshEvents, 12000).start();
|
createPoller(whenStreamDown(refreshSendQueue), 4000).start();
|
||||||
createPoller(refreshTasks, 12000).start();
|
createPoller(whenStreamDown(refreshEvents), 12000).start();
|
||||||
|
createPoller(whenStreamDown(refreshTasks), 12000).start();
|
||||||
|
createPoller(whenStreamDown(refreshBedarf), 30000).start();
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
{% block script %}{% endblock %}
|
{% block script %}{% endblock %}
|
||||||
|
|||||||
@@ -14,6 +14,20 @@
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<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" 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>
|
<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();
|
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() {
|
async function load() {
|
||||||
[spools, printers, materials, palette] = await Promise.all([
|
const [spoolData, printerData, materialData, paletteData, bedarfData] = await Promise.all([
|
||||||
api("/spulen"),
|
api("/spulen"),
|
||||||
api("/drucker?live=0"),
|
api("/drucker?live=0"),
|
||||||
api("/materialien"),
|
api("/materialien"),
|
||||||
api("/farben"),
|
api("/farben"),
|
||||||
|
api("/bedarf").catch(() => ({ needed: [], shortfall: [] })),
|
||||||
]);
|
]);
|
||||||
|
spools = spoolData;
|
||||||
|
printers = printerData;
|
||||||
|
materials = materialData;
|
||||||
|
palette = paletteData;
|
||||||
render();
|
render();
|
||||||
renderPalette();
|
renderPalette();
|
||||||
|
renderBedarf(bedarfData);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
window.onBedarfChanged = renderBedarf;
|
||||||
|
|
||||||
function setScanNote(text, ok) {
|
function setScanNote(text, ok) {
|
||||||
const note = document.getElementById("scan-note");
|
const note = document.getElementById("scan-note");
|
||||||
if (!text) {
|
if (!text) {
|
||||||
|
|||||||
@@ -110,6 +110,21 @@ function fillPrinters(rows) {
|
|||||||
if ([...select.options].some((o) => o.value === current)) select.value = current;
|
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) {
|
function render(data) {
|
||||||
fillPrinters(data.by_printer);
|
fillPrinters(data.by_printer);
|
||||||
const o = data.overview || {};
|
const o = data.overview || {};
|
||||||
@@ -120,6 +135,16 @@ function render(data) {
|
|||||||
? (data.estimate_ratio * 100).toFixed(0) + " %"
|
? (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) => `
|
const printers = (data.by_printer || []).map((p) => `
|
||||||
<tr>
|
<tr>
|
||||||
<td>${escapeHtml(p.printer_name || p.printer_host)}</td>
|
<td>${escapeHtml(p.printer_name || p.printer_host)}</td>
|
||||||
@@ -145,6 +170,15 @@ function render(data) {
|
|||||||
<td class="num">${m.jobs}</td>
|
<td class="num">${m.jobs}</td>
|
||||||
</tr>`).join("") || '<tr><td class="empty" colspan="5">Kein Spulenabzug im Zeitraum.</td></tr>';
|
</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 = `
|
document.getElementById("stat").innerHTML = `
|
||||||
<div class="kpi-row">
|
<div class="kpi-row">
|
||||||
${kpi("Läufe", String(jobs))}
|
${kpi("Läufe", String(jobs))}
|
||||||
@@ -155,6 +189,16 @@ function render(data) {
|
|||||||
${kpi("Ausschuss", scrap + " %", (o.scrap || 0) + " mit Urteil Ausschuss")}
|
${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")}
|
${kpi("Schätztreue", ratio, data.estimate_count ? data.estimate_count + " Läufe mit Soll-Zeit" : "keine Soll-Zeit")}
|
||||||
</div>
|
</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">
|
<div class="card">
|
||||||
<h2>Nach Drucker</h2>
|
<h2>Nach Drucker</h2>
|
||||||
<div class="table-wrap"><table>
|
<div class="table-wrap"><table>
|
||||||
@@ -222,6 +266,17 @@ document.getElementById("reload").addEventListener("click", () => {
|
|||||||
document.getElementById("host").addEventListener("change", () => {
|
document.getElementById("host").addEventListener("change", () => {
|
||||||
load().catch((error) => showNote("stat-note", error));
|
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));
|
load().catch((error) => showNote("stat-note", error));
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -1337,6 +1337,7 @@ async function refresh() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
window.onQueueChanged = () => refresh().catch(() => {});
|
window.onQueueChanged = () => refresh().catch(() => {});
|
||||||
|
window.onDruckerLive = () => refresh().catch(() => {});
|
||||||
|
|
||||||
document.getElementById("hide-offline").addEventListener("change", (event) => {
|
document.getElementById("hide-offline").addEventListener("change", (event) => {
|
||||||
hideOffline = event.target.checked;
|
hideOffline = event.target.checked;
|
||||||
@@ -1627,7 +1628,7 @@ deskEl.addEventListener("keydown", async (event) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
refresh().catch((error) => showNote("note", error));
|
refresh().catch((error) => showNote("note", error));
|
||||||
setInterval(() => refresh().catch((error) => showNote("note", error)), 5000);
|
createPoller(() => refresh().catch((error) => showNote("note", error)), 12000).start();
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
api("/dateien").then((rows) => { libraryFiles = rows || []; }).catch(() => {});
|
api("/dateien").then((rows) => { libraryFiles = rows || []; }).catch(() => {});
|
||||||
api("/spulen").then((rows) => { wallSpools = rows || []; }).catch(() => {});
|
api("/spulen").then((rows) => { wallSpools = rows || []; }).catch(() => {});
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
<p class="tiny">Wartende und laufende Aufträge. Direktstarts erscheinen unter Läuft. Priorität mit den Pfeilen ändern.</p>
|
<p class="tiny">Wartende und laufende Aufträge. Direktstarts erscheinen unter Läuft. Priorität mit den Pfeilen ändern.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="page-head-side">
|
<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>
|
<button type="button" class="ghost" id="q-refresh"><svg class="ico"><use href="#i-refresh"/></svg>Neu laden</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -212,7 +213,18 @@ document.getElementById("q-rows").addEventListener("click", async (event) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
window.onQueueChanged = load;
|
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();
|
load();
|
||||||
setInterval(load, 5000);
|
if (typeof refreshBedarf === "function") refreshBedarf();
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -5,7 +5,11 @@
|
|||||||
<h1>Wartung</h1>
|
<h1>Wartung</h1>
|
||||||
<p class="tiny">Laufstunden und Filament je Drucker — Verschleißteile im Blick.</p>
|
<p class="tiny">Laufstunden und Filament je Drucker — Verschleißteile im Blick.</p>
|
||||||
</div>
|
</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>
|
||||||
|
<div id="wartung-summary" class="kpi-row" hidden></div>
|
||||||
<div id="wartung-root"></div>
|
<div id="wartung-root"></div>
|
||||||
<p id="wartung-note"></p>
|
<p id="wartung-note"></p>
|
||||||
{% endblock %}
|
{% 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">${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_hours ? part.life_hours + " h" : "—"}</td>
|
||||||
<td class="num">${part.life_meters ? Math.round(part.life_meters) + " mm" : "—"}</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>`;
|
</tr>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function printerCard(block) {
|
function printerCard(block) {
|
||||||
const c = block.counters || {};
|
const c = block.counters || {};
|
||||||
|
const overdue = (block.parts || []).filter((p) => p.overdue_hours || p.overdue_meters).length;
|
||||||
const parts = (block.parts || []).map(partRow).join("")
|
const parts = (block.parts || []).map(partRow).join("")
|
||||||
|| '<tr><td colspan="6" class="empty">Noch keine Teile.</td></tr>';
|
|| '<tr><td colspan="6" class="empty">Noch keine Teile — unten anlegen.</td></tr>';
|
||||||
return `<section class="card">
|
return `<section class="card wartung-card">
|
||||||
|
<div class="lib-head">
|
||||||
<h2>${escapeHtml(block.name || block.host)}</h2>
|
<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>
|
<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>
|
<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>
|
<tbody>${parts}</tbody>
|
||||||
</table></div>
|
</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>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>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>
|
<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="field" style="grid-column:1/-1"><label>Notiz</label><input name="note"></div>
|
||||||
<div class="actions"><button type="submit">Teil anlegen</button></div>
|
<div class="actions"><button type="submit">Teil anlegen</button></div>
|
||||||
</form>
|
</form>
|
||||||
|
</details>
|
||||||
</section>`;
|
</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() {
|
async function load() {
|
||||||
const data = await api("/wartung");
|
const data = await api("/wartung");
|
||||||
|
renderSummary(data || []);
|
||||||
document.getElementById("wartung-root").innerHTML = (data || []).map(printerCard).join("")
|
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) => {
|
document.getElementById("wartung-root").addEventListener("submit", async (event) => {
|
||||||
const form = event.target.closest("form.wartung-add");
|
const form = event.target.closest("form.wartung-add");
|
||||||
if (!form) return;
|
if (!form) return;
|
||||||
@@ -70,24 +113,28 @@ document.getElementById("wartung-root").addEventListener("submit", async (event)
|
|||||||
});
|
});
|
||||||
form.reset();
|
form.reset();
|
||||||
showNote("wartung-note", null, "Teil gespeichert.");
|
showNote("wartung-note", null, "Teil gespeichert.");
|
||||||
|
toast("Wartungsteil angelegt.");
|
||||||
load();
|
load();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showNote("wartung-note", 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]");
|
const btn = event.target.closest("[data-reset]");
|
||||||
if (!btn) return;
|
if (!btn) return;
|
||||||
|
armButton(btn, "Wirklich zurücksetzen?", async () => {
|
||||||
try {
|
try {
|
||||||
await api("/wartung/teile/" + btn.dataset.reset + "/reset", { method: "POST", json: {} });
|
await api("/wartung/teile/" + btn.dataset.reset + "/reset", { method: "POST", json: {} });
|
||||||
showNote("wartung-note", null, "Teil zurückgesetzt.");
|
showNote("wartung-note", null, "Teil zurückgesetzt.");
|
||||||
|
toast("Zähler zurückgesetzt.");
|
||||||
load();
|
load();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showNote("wartung-note", error);
|
showNote("wartung-note", error);
|
||||||
}
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
load();
|
load().catch((error) => showNote("wartung-note", error));
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
Reference in new issue
Block a user