Grotti 3.2.0: Jetzt-Liste, Filament-Pause, Strom-Torte, UI-Polish.

Jetzt/Topbar und Material-Routing, Pause bei Filament-Ende, Druckbericht mit Stromanteilen, Design- und Kamera-Stream-Polish; Bedarf-Alert-Spam und Platte-belegt-Bugs behoben.
This commit is contained in:
Dominic Gundel committed 2026-10-18 14:58:32 +02:00
1 parent 6e59df22e5
commit 70b501ce1b
68 files changed
+4348 -1215

No files matched your search

+1 -1
View File
@@ -9,7 +9,7 @@ Grotti ist ein eigenständiges Repo. Alles, was das Tool betrifft, bleibt hier.
- Sprache: Deutsch, in Oberfläche, Meldungen, Doku und Commits. - Sprache: Deutsch, in Oberfläche, Meldungen, Doku und Commits.
- Jede Änderung bekommt einen Eintrag in `CHANGELOG.md` unter `[Unreleased]` (Neu, Geändert, Behoben). - Jede Änderung bekommt einen Eintrag in `CHANGELOG.md` unter `[Unreleased]` (Neu, Geändert, Behoben).
- Version in `grotti/version.py` nach SemVer: Patch für Fehlerbehebung, Minor für Funktion, Major für Bruch an API oder Datenbankschema. Beim Release `[Unreleased]` in `[x.y.z] – Datum` umbenennen. Git-Tag `vX.Y.Z`. - **Version/`version.py` nur erhöhen, wenn der Nutzer explizit Release sagt.** Keine Zwischen-Patches von allein. Beim Release: SemVer (Patch/Minor/Major), `[Unreleased]` → `[x.y.z] – Datum`, Git-Tag `vX.Y.Z`.
- Nutzer-Anleitung: `docs/Anleitung-*.md` unter `/doku`. Technik/Debug: `docs/internal/` — nicht in der App. - Nutzer-Anleitung: `docs/Anleitung-*.md` unter `/doku`. Technik/Debug: `docs/internal/` — nicht in der App.
- CSS-Version in `web/templates/layout.html` (`grotti.css?v=`) erhöhen, wenn sich `grotti.css` ändert. - CSS-Version in `web/templates/layout.html` (`grotti.css?v=`) erhöhen, wenn sich `grotti.css` ändert.
- Keine freie G-code-Konsole, kein `FIRMWARE_RESTART` (GoKlipper deadlockt). Druckstarts brauchen `confirm: true`. - Keine freie G-code-Konsole, kein `FIRMWARE_RESTART` (GoKlipper deadlockt). Druckstarts brauchen `confirm: true`.
+1 -1
View File
@@ -4,7 +4,7 @@ Grotti ist ein eigenständiges Repo. Alles, was das Tool betrifft, bleibt hier.
- Sprache: Deutsch, in Oberfläche, Meldungen, Doku und Commits. - Sprache: Deutsch, in Oberfläche, Meldungen, Doku und Commits.
- Jede Änderung bekommt einen Eintrag in `CHANGELOG.md` unter `[Unreleased]` (Neu, Geändert, Behoben). - Jede Änderung bekommt einen Eintrag in `CHANGELOG.md` unter `[Unreleased]` (Neu, Geändert, Behoben).
- Version in `grotti/version.py` nach SemVer: Patch für Fehlerbehebung, Minor für Funktion, Major für Bruch an API oder Datenbankschema. Beim Release `[Unreleased]` in `[x.y.z] – Datum` umbenennen. Git-Tag `vX.Y.Z`. - **Version/`version.py` nur erhöhen, wenn der Nutzer explizit Release sagt.** Keine Zwischen-Patches von allein. Beim Release: SemVer (Patch/Minor/Major), `[Unreleased]` → `[x.y.z] – Datum`, Git-Tag `vX.Y.Z`.
- Nutzer-Anleitung: `docs/Anleitung-*.md`, gerendert unter `/doku`. Technik und Debug: `docs/internal/` — erscheint **nicht** in der App (inkl. MCP.md, Anycubic-Stack, …). - Nutzer-Anleitung: `docs/Anleitung-*.md`, gerendert unter `/doku`. Technik und Debug: `docs/internal/` — erscheint **nicht** in der App (inkl. MCP.md, Anycubic-Stack, …).
- CSS-Version in `web/templates/layout.html` (`grotti.css?v=`) erhöhen, wenn sich `grotti.css` ändert. - CSS-Version in `web/templates/layout.html` (`grotti.css?v=`) erhöhen, wenn sich `grotti.css` ändert.
- Keine freie G-code-Konsole, kein `FIRMWARE_RESTART` (GoKlipper deadlockt). Druckstarts brauchen `confirm: true`. - Keine freie G-code-Konsole, kein `FIRMWARE_RESTART` (GoKlipper deadlockt). Druckstarts brauchen `confirm: true`.
+40
View File
@@ -4,6 +4,46 @@ Alle Änderungen an Grotti. Format nach [Keep a Changelog](https://keepachangelo
## [Unreleased] ## [Unreleased]
## [3.2.0] – 2026-10-18
### Neu
- **Jetzt-Liste** / Topbar-Chips / Mobile-Tab `/jetzt`: Platte räumen, Queue-Fehler, Bedarf, Wartung, Aufträge, Material-Wartegründe; Farm-ETA; Filament-Pause → „Material nachlegen“.
- **`GET /api/v1/jetzt`**, **`GET /api/v1/prognose`**, **`GET /api/v1/suche`** (Ctrl+K).
- **Filament-Pause:** Pause + ACE/`mmu`/Runout-Hinweis → Event `spule`, Jetzt-Eintrag, Alert.
- **Druckbericht:** Strom-Aufschlüsselung (Grundlast / Düse / Bett / Trockner) mit Tortendiagramm.
- **Material-Routing:** „Nächster freier“ bevorzugt passende Rolle/Farbe/Rest; sonst Wartegrund in der Queue.
- **Termine** (`due_at`) sortieren die Warteschlange vor der Priorität.
- **Auto-Retry** (einmal, Einstellung) bei Queue-/Ablauf-Fehler.
- **Gleich nochmal drucken** im Abräumen-Dialog.
- **Tagesbericht** 7 Uhr (Telegram/ntfy) und Push bei Wartung fällig, Bedarf, Auftrag überfällig.
- **Kosten pro Teil** und Stück-Yield in Bericht, Auftrag und Statistik.
- **Undo-Toast** beim Entfernen aus der Warteschlange; **Ctrl+K**-Suche.
- **Design-Polish:** Lucide-Icons (`icons.svg`), Schrift Inter, Tokens/Komponenten; Topbar und Sidebar-Gruppen.
- **Kamera-Stream:** Snapshot oder MJPEG in Einstellungen und auf der Kameras-Seite.
- **Community-Screenshots** unter `docs/community/`.
- Einstellungen: Abschnitts-Navigation, Autosave, Drucker als Karten mit Dialog.
- Cockpit: Alltag oben, **Technik**-Fold (Steuerung, Verlauf, 3D, Bettnetz) standardmäßig zu.
### Geändert
- **Live-Status** in der Topbar (Chips + Popover); Mobile-Tab **Jetzt**.
- **PA12-CF** in Grotti als PA12-CF; am ACE als PA6-CF (ACE kennt den Typ nicht).
- Falsche Sorte warnt in Dateien, hält die Queue nicht an; zu wenig Rest blockiert weiter.
- Alert-Hygiene: `spule`/`start`/`auth` quiet; Sidebar-Label „Meldungen“.
- Status als Chip; Einstellungen-Nav mit Icons; Autosave; Light-Theme; `tabular-nums`.
- Begriffe: Queue-Eintrag = Druck; Empty-States mit Handlung; Primär-/Danger-Buttons.
- Cool-Park blockiert den Queue-Tick nicht mehr (Worker).
- Kunden und Positionen bearbeiten/löschen unter Aufträge.
- `projekte.html` / `ideen.html` entfernt (Redirect bleibt).
### Behoben
- **Bedarf/Wartung/Auftrag-Spam:** Ops-Alerts Dedup-Art und Event-`kind` stimmten nicht überein (jede Minute derselbe Hinweis).
- **Platte belegt nach Neustart:** Watcher setzte bereits Quittiertes wieder auf `needs_clear`.
- **„Pausiert“ nach Abbruch/Offline:** Live-Lauf blieb offen bei Offline→standby.
- **Regal-Modus:** Sidebar-Menü unter Desktop-Breite ausgeblendet.
## [3.1.0] – 2026-10-18 ## [3.1.0] – 2026-10-18
### Neu ### Neu
+1
View File
@@ -8,6 +8,7 @@
"secret_key": "", "secret_key": "",
"fqdn": "", "fqdn": "",
"auto_queue": true, "auto_queue": true,
"auto_retry": true,
"oauth": { "oauth": {
"enabled": false, "enabled": false,
"issuer": "", "issuer": "",
+2 -2
View File
@@ -1,6 +1,6 @@
# ACE und Spulen # ACE und Spulen
Stand **17.10.2026**. Mehrfarb-Drucke brauchen vor dem Start eine Zuordnung Datei-Farbe → ACE-Platz und eine passende Spule am Drucker. Stand **18.10.2026**. Mehrfarb-Drucke brauchen vor dem Start eine Zuordnung Datei-Farbe → ACE-Platz und eine passende Spule am Drucker.
![ACE-Zuordnung beim Senden](../web/static/doku/ace-zuordnung.png) ![ACE-Zuordnung beim Senden](../web/static/doku/ace-zuordnung.png)
@@ -30,7 +30,7 @@ Externe Spule / keine ACE-Slots: keine `.acm` nötig.
| Symptom | Check | | Symptom | Check |
| --- | --- | | --- | --- |
| „cannot parse the file“ | ACE-Zuordnung vollständig? Farben richtig? | | „cannot parse the file“ | ACE-Zuordnung vollständig? Farben richtig? |
| Material-Warnung | Eingelegte Sorte ≠ Datei — Spule wechseln oder Slice prüfen | | Material-Warnung | Eingelegte Sorte ≠ Datei. Der Start geht trotzdem. PA12-CF steht in Grotti so, an den ACE geht PA6-CF. |
| Rest zu gering | Andere Spule oder leichteres Teil | | Rest zu gering | Andere Spule oder leichteres Teil |
| ACE blinkt nicht / kein Wechsel | Spule und Platz prüfen, ggf. Druck neu starten | | ACE blinkt nicht / kein Wechsel | Spule und Platz prüfen, ggf. Druck neu starten |
+4 -2
View File
@@ -26,7 +26,9 @@ Unter **Aufträge** Kunden, Positionen und Fortschritt. Dateien lassen sich eine
## 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, Strom-Aufschlüsselung mit Tortendiagramm, Charge). **Nochmal** startet denselben Job inkl. gespeicherter ACE-Map und Bettnetz-Option.
Nach Filament-Ende: neue Spule vom Regal dem Drucker/ACE-Platz zuweisen, am Gerät laden, an der Wand **Weiter**.
Unter **Statistik** KPIs, Balkendiagramme (Druckzeit je Drucker, Verbrauch je Material), Tabellen und CSV-Export. Zeitraum per Knopf oder Von/Bis. Unter **Statistik** KPIs, Balkendiagramme (Druckzeit je Drucker, Verbrauch je Material), Tabellen und CSV-Export. Zeitraum per Knopf oder Von/Bis.
@@ -36,7 +38,7 @@ Abzug nach dem Druck nimmt das Ist aus `filament_used` (Abbruch nur anteilig). M
## Kameras ## Kameras
`/kameras` — alle Snapshots nebeneinander mit Live-Zustand. Praktisch auf dem Regal-Tablet. `/kameras` — alle Kameras nebeneinander mit Live-Zustand. Oben **Snapshot** oder **Stream** wählbar (gilt für diesen Browser, auch im Cockpit). Stream ist flüssiger, hält aber je sichtbarer Kamera eine Verbindung.
## Aktivität ## Aktivität
+22 -14
View File
@@ -1,39 +1,47 @@
# Oberfläche # Oberfläche
Stand **18.10.2026**. Wie die App aufgebaut ist — Menü, Live-Leiste, Desktop und Regal. Stand **18.10.2026**. Wie die App aufgebaut ist — Menü, Jetzt-Liste, Desktop und Regal.
## Navigation ## Navigation
Links (Desktop): **Drucker**, **Dateien**, **Kameras**, **Warteschlange**, **Aufträge**, **Protokoll**, **Statistik**, **Aktivität**, **Material**, **Wartung**. Darunter **Einstellungen**, **Doku**. Fuß: Version (öffnet die Doku). Links (Desktop), gruppiert: **Live** (Drucker, Dateien, Kameras, Warteschlange, Aufträge), **Rückschau** (Protokoll, Statistik, Aktivität), **Verwalten** (Material, Wartung). Darüber im Inhaltsbereich die **Topbar**: Suche, Live-Chips, Einstellungen, Doku und Benutzer-Menü (Abmelden, Version).
Unter ca. 760 px: untere Leiste plus **Mehr**-Sheet. Das Seitenmenü lässt sich zur Icon-Rail einklappen. Unter ca. 760 px: untere Leiste (Drucker, Dateien, Warteschlange, **Jetzt**, Mehr) plus **Mehr**-Sheet (inkl. Aktivität und Abmelden). Das Seitenmenü lässt sich zur Icon-Rail einklappen.
![Seitenleiste mit Live-Boxen](../web/static/doku/sidebar-live.png) Shortcut **Ctrl+K** (Mac: Cmd+K) oder der Suche-Knopf in der Topbar: Suche über Drucker, Dateien, Spulen, Chargen und Aufträge.
## Live-Leiste ## Jetzt
Unter dem Menü, eigene Scrollfläche. Boxen auf- und zuklappbar (Merker im Browser). Aktualisierung über Live-Stream (SSE), sonst Polling. Desktop: Chips in der Topbar (**Jetzt**, laufende Drucker, Senden, Meldungen). Klick öffnet ein Popover mit Liste und Aktionen. Bei mehreren Läufen zusätzlich **Alles fertig um …**.
| Box | Inhalt | Mobile / Touch: Tab **Jetzt** in der unteren Leiste (mit Badge) öffnet `/jetzt` — dieselbe Liste als volle Seite. Aktivität liegt unter **Mehr**.
| Priorität | Inhalt |
| --- | --- | | --- | --- |
| **Läuft jetzt** | Eine Zeile pro Drucker (Name · % · Fertig-Uhr); Klick öffnet die Warteschlange, × bricht ab. Auch Direktstarts ohne Queue-Zeile. | | Platte räumen | Quittieren mit Urteil (optional „gleich nochmal“) |
| **Wartet** | Wartende Aufträge: Priorität, Abbrechen. Gestoppte Abläufe mit Fortsetzen. | | Material nachlegen | Druck pausiert, Filament vermutlich leer (ACE/Runout) — Spule zuweisen, dann an der Wand **Weiter** |
| **Aufgaben** | Platte belegt ohne laufenden Ablauf — **geräumt** (Zwei-Klick). | | Druck fehlgeschlagen | Fortsetzen / Ausblenden |
| **Senden** | Uploads mit MB-Fortschritt. | | Spule fehlt | Link zu Material / Bedarf |
| **Alerts** | Ungelesene wichtige Ereignisse; als gelesen markieren oder zur Aktivität. | | Wartung fällig | Link zu Wartung |
| **Bedarf** | Fehlende Filamentmenge für die Queue (Link zu Material). | | Auftrag überfällig | Link zu Aufträgen |
| Wartet auf Material | Wartegrund aus dem Routing |
Aktualisierung über Live-Stream (SSE), sonst Polling.
## Desktop und Regal ## Desktop und Regal
Unter **Einstellungen → Dieser Browser**: 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, Sidebar oft eingeklappt; auf schmalem Viewport die untere Leiste. Alle Menüpunkte bleiben in der Sidebar sichtbar.
- **Darstellung** — Dunkel, Hell oder System - **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
- **Kamera** — Snapshot (schonend, alle paar Sekunden) oder Stream (flüssig, hält eine Verbindung je Kamera). Auch umschaltbar unter Kameras.
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`).
Drucker-Cockpit: Alltag (Job, ETA, Rolle, Steuerung der Aktionen) oben; **Technik** (Steuerung, Verlauf, 3D-Pfad, Bettnetz) zugeklappt.
## Weiter ## Weiter
- [Druckerwand](Anleitung-Wand.md) - [Druckerwand](Anleitung-Wand.md)
+4 -4
View File
@@ -1,6 +1,6 @@
# Druckerwand # Druckerwand
Stand **15.10.2026**. Startseite `/` — eine Karte je Drucker. Klick öffnet das Cockpit. Stand **18.10.2026**. Startseite `/` — eine Karte je Drucker. Klick öffnet das Cockpit.
![Druckerwand](../web/static/doku/wand-uebersicht.png) ![Druckerwand](../web/static/doku/wand-uebersicht.png)
@@ -18,7 +18,7 @@ Aktionen auf der Karte brauchen oft **zwei Klicks** (erster Klick „scharf“,
| Aktion | Wann | | Aktion | Wann |
| --- | --- | | --- | --- |
| **Pause** / **Weiter** | Während des Drucks | | **Pause** / **Weiter** | Während des Drucks; nach Materialwechsel Spule in Grotti an den Drucker hängen, dann **Weiter** |
| **Abbrechen** | Druck oder Pause | | **Abbrechen** | Druck oder Pause |
| **Fehler löschen** | Zustand Fehler — räumt den hängenden Fehler, Heizung aus | | **Fehler löschen** | Zustand Fehler — räumt den hängenden Fehler, Heizung aus |
| **Platte geräumt** | Nach Fertig, wenn die Platte wieder frei ist | | **Platte geräumt** | Nach Fertig, wenn die Platte wieder frei ist |
@@ -34,11 +34,11 @@ URL mit Hash: `/#<host>` (z. B. nach Klick auf die Karte).
Inhalt: Inhalt:
- **Kamera** — nur wenn eine Webcam erreichbar ist; sonst bleibt der Platz weg - **Kamera** — nur wenn eine Webcam erreichbar ist; sonst bleibt der Platz weg. Modus Snapshot/Stream unter Einstellungen → Dieser Browser (oder Kameras-Seite)
- Status, Temps inkl. Bauraum wo verfügbar, Lüfter - Status, Temps inkl. Bauraum wo verfügbar, Lüfter
- **Steuerung:** Zieltemperaturen, Speed, Flow, Fan — fest vorgegebene Befehle, keine freie Konsole. Mit ACE zusätzlich Trockner (Temp, Dauer, Start/Stopp) und pro bestücktem Platz **an den Drucker** (Sorte und Farbe aus der Grotti-Spule) - **Steuerung:** Zieltemperaturen, Speed, Flow, Fan — fest vorgegebene Befehle, keine freie Konsole. Mit ACE zusätzlich Trockner (Temp, Dauer, Start/Stopp) und pro bestücktem Platz **an den Drucker** (Sorte und Farbe aus der Grotti-Spule)
- **E-Stop** nur bei printing/paused/heating (Zwei-Klick). Danach oft Strom aus/an - **E-Stop** nur bei printing/paused/heating (Zwei-Klick). Danach oft Strom aus/an
- **Verlauf** Temperatur und ED des aktuellen oder letzten Drucks; Link zum Druckbericht - **Verlauf** Temperatur und ED des aktuellen oder letzten Drucks; Link zum Druckbericht (dort Strom nach Grundlast/Düse/Bett)
- **3D-Pfad** der laufenden oder gewählten Datei; Objekte ausschließen (mit Bestätigung) - **3D-Pfad** der laufenden oder gewählten Datei; Objekte ausschließen (mit Bestätigung)
- **Bettnetz**-Heatmap, wenn vorhanden - **Bettnetz**-Heatmap, wenn vorhanden
Binary file not shown.

After

Width:  |  Height:  |  Size: 213 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 204 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 245 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 219 KiB

+45
View File
@@ -0,0 +1,45 @@
# Grotti — Community-Vorstellung
Screenshots aus dem laufenden Betrieb (v3.2.0, Okt. 2026). Zielgruppe: mittlere bis große Printfarmen.
---
## Kurztext (zum Posten)
**Grotti** ist ein Farm-Manager für Moonraker/Klipper-Drucker — gebaut für den Alltag mit mehreren Maschinen, nicht für den Einzelplatz.
Statt zehn Tabs und Excel: eine Wand, eine Warteschlange, Material und Abräumen an einem Ort. Orca/AnycubicSlicerNext schicken den G-code per Nachbearbeitungsskript rein; Grotti wählt den nächsten freien Drucker (inkl. ACE-Farbe/Rest), startet im Zeitfenster und meldet, wenn die Platte geräumt werden muss oder Spule fehlt.
**Was schon geht**
- **Druckerwand + Cockpit** — Live-Status, Temps, Pause/Cancel/E-Stop, Farm-ETA („Alles fertig um …“)
- **Jetzt-Liste** — Platte räumen, Materialbedarf, Wartung, überfällige Aufträge als To-do oben
- **Warteschlange** — Priorität, Termine, Auto-Retry, Routing „nächster freier“ mit Material-Match
- **Kobra-3-Ablauf** — Druck → Abkühlen → Schieben → Tasten (unbeaufsichtigte Serie)
- **Material** — Spulen am Drucker/Regal, Chargen, Restgewicht, Fehlmenge aus der Queue
- **Aufträge** — Kunden, Positionen, Einreihen, Lieferschein, Stück-Fortschritt aus dem Abräumen
- **Kosten & Statistik** — Material + Strom (auch HT/NT), €/Teil, Yield, Ausschussgründe
- **Wartung** — Laufstunden/Filamentmeter, Verschleißteile mit Intervallen
- **Betrieb** — Auth (Session/API-Token), Telegram/ntfy, PWA/Regal-Touch, Ctrl+K-Suche
Stack: Python, PostgreSQL, Moonraker. UI auf Deutsch. Self-hosted.
Fragen und Feedback willkommen.
---
## Screenshots
| Datei | Inhalt |
| --- | --- |
| [01-druckerwand.png](01-druckerwand.png) | Farm-Übersicht, gruppiert nach Modell |
| [06-jetzt.png](06-jetzt.png) | Jetzt-Popover: Abräumen + Materialbedarf |
| [07-cockpit.png](07-cockpit.png) | Einzeldrucker mit Job, Spule, Steuerung |
| [02-warteschlange.png](02-warteschlange.png) | Queue mit Status und Fehlmenge |
| [09-dateien.png](09-dateien.png) | Bibliothek + Senden an mehrere Drucker |
| [03-material.png](03-material.png) | Spulenbestand und Bedarf aus der Queue |
| [04-auftraege.png](04-auftraege.png) | Kundenaufträge und Positionen |
| [05-statistik.png](05-statistik.png) | KPIs: Läufe, Kosten, Erfolg, Schätztreue |
| [08-wartung.png](08-wartung.png) | Laufzeit und Filament je Maschine |
Empfohlene Reihenfolge im Post: Wand → Jetzt → Warteschlange → Material → Statistik.
+1 -1
View File
@@ -6,7 +6,7 @@ Nach dem Druck kann Grotti auf der **Kobra 3** Bett und Düse abkühlen und die
## Quittierung vs. Schieben ## Quittierung vs. Schieben
- **Wand „Platte geräumt“:** Nur Quittierung. Tabelle `bed_status`, API `POST /api/v1/drucker/<host>/abraeumen` mit `confirm`. Fährt keine Bahn. `needs_clear` setzt der Watcher bei Druckende/Abbruch/Fehler (nicht schon beim Start). Solange gesetzt, startet „wenn frei“ den nächsten Job nicht. - **Wand „Platte geräumt“:** Nur Quittierung. Tabelle `bed_status`, API `POST /api/v1/drucker/<host>/abraeumen` mit `confirm`. Fährt keine Bahn. `needs_clear` setzt der Watcher beim Live-Übergang Druckende/Abbruch/Fehler (nicht schon beim Start; nicht blind beim ersten Blick nach Neustart). Solange gesetzt, startet „wenn frei“ den nächsten Job nicht.
- **Ablauf (Dateien):** Drucken → Abkühlen → Schieben → Tasten, dann der nächste Druck mit LeviQ. Eine Bestätigung beim Einreihen; Schieben läuft ohne zweite Rückfrage. Nach erfolgreichem Schieben ist die Platte automatisch quittiert. - **Ablauf (Dateien):** Drucken → Abkühlen → Schieben → Tasten, dann der nächste Druck mit LeviQ. Eine Bestätigung beim Einreihen; Schieben läuft ohne zweite Rückfrage. Nach erfolgreichem Schieben ist die Platte automatisch quittiert.
- **Normale Drucke** und „Nochmal“ schieben nicht — nach dem Druck muss man auf der Wand quittieren. - **Normale Drucke** und „Nochmal“ schieben nicht — nach dem Druck muss man auf der Wand quittieren.
+20 -13
View File
@@ -1,33 +1,40 @@
# Grotti: Alltag und Oberfläche # Grotti: Alltag und Oberfläche
Stand **18.10.2026**, v3.0.0. Stand **18.10.2026**, v3.2.0.
## Design ## Design
- Dunkles UI mit Orange-Akzent, Lucide-ähnliche Stroke-Icons (SVG-Sprite in `layout.html`). - Dunkles UI mit Orange-Akzent; Light-Theme über dieselben Tokens (`html[data-theme]`).
- Tokens in `grotti.css`; `--faint` lesbar; `prefers-reduced-motion`; Touch-Ziele bei `pointer: coarse`. - Schrift **Inter** (variable, lokal unter `web/static/fonts/`).
- Icons: **Lucide** als Sprite `web/static/icons.svg` (bauen mit `python scripts/build_icons.py`), IDs `i-<name>`.
- Komponenten: Toolbar, Card-Head, KPI-Karten, Chips (Zustände + Queue + Urteile), Dialog-Kopf/Fuß, Empty-States, Skeleton.
- Primär: `btn-primary` / `go-btn`; Danger: `btn-danger` / `warn`; Icon-Buttons mit `title`/`aria-label`.
- **Anzeige pro Browser**: Desktop oder Regal (Touch); Zeitzone; Nav einklappbar. - **Anzeige pro Browser**: Desktop oder Regal (Touch); Zeitzone; Nav einklappbar.
- PWA: `manifest.webmanifest` + Service-Worker (Shell-Cache). - PWA: `manifest.webmanifest` + Service-Worker (Shell-Cache inkl. Icons/Font).
- Live: SSE `/api/v1/stream` plus Poll-Fallback; Verbindungsbanner. - Live: SSE `/api/v1/stream` plus Poll-Fallback; Verbindungsbanner.
- Ctrl+K: globale Suche (`/api/v1/suche`); Knopf in der Topbar.
- Kamera: Snapshot (gecacht) oder MJPEG-Stream (`/api/v1/kamera/stream`), Browser-Einstellung `grotti-camera`.
## Seitenleiste ## Seitenleiste und Topbar
- **Live:** Drucker, Dateien, Kameras, Warteschlange, Aufträge. - Sidebar: Abschnittslabels **Live** / **Rückschau** / **Verwalten** (ohne Live-Panel).
- **Rückschau:** Protokoll, Statistik, Aktivität. - Topbar-Live-Chips (**Jetzt** · Läufe · Senden · Meldungen) mit Popover; Farm-ETA nur bei mehreren Läufen.
- **Verwalten:** Material, Wartung; Einstellungen, Doku. - Mobile: Tab **Jetzt** (`/jetzt`) statt Aktivität in der unteren Leiste; Aktivität unter Mehr.
- Live-Panel: Läuft / Wartet / Aufgaben / Senden / Alerts. - Topbar: Suche, Einstellungen, Doku, Benutzer-Menü (Avatar → Abmelden, Version, Copyright).
- Mobile: Bottom-Nav Drucker · Dateien · Queue · Aktivität · **Mehr** (Sheet). - Mobile: Bottom-Nav Drucker · Dateien · Warteschlange · Aktivität · **Mehr** (Sheet inkl. Abmelden); Topbar ausgeblendet, Suche als Icon in der Brand-Zeile.
## Wand und Abräumen ## Wand und Abräumen
- Karten + Cockpit wie bisher. - Karten + Cockpit; Technik-Fold mit Icons je Abschnitt.
- **Platte geräumt** öffnet Dialog: gut/Ausschuss, Grund, Stückzahlen. - **Platte geräumt** öffnet Dialog: gut/Ausschuss, Grund, Stückzahlen.
## Aufträge und Wartung ## Aufträge und Wartung
- `/auftraege`: Kunden, Positionen, Einreihen, Lieferschein. `/projekte` und `/ideen` leiten um. - `/auftraege`: Kunden, Positionen, Einreihen, Lieferschein. Status als Chip.
- `/wartung`: Stunden/Meter und Verschleißteile. - `/wartung`: Stunden/Meter und Verschleißteile.
## Dateien, Material, Statistik ## Dateien, Material, Statistik
- Unverändert im Kern (Bibliothek, Send-Flow, Spulen/Sorten, KPIs + Ausschuss-Gründe). - Bibliothek mit Toolbar/Suche; Senden-Schritte nummeriert.
- Material: Spulen/Sorten-Tabs, Filter-Toolbar.
- Statistik: KPI-Karten mit Icons.
+9 -1
View File
@@ -136,7 +136,7 @@ MATERIALS = [
{ {
"name": "Sunlu PA12-CF", "name": "Sunlu PA12-CF",
"brand": "Sunlu", "brand": "Sunlu",
"material_type": "PA", "material_type": "PA12-CF",
"density": 1.08, "density": 1.08,
"volumetric": 8, "volumetric": 8,
"volumetric_s1": 8, "volumetric_s1": 8,
@@ -157,6 +157,14 @@ MATERIALS = [
}, },
] ]
def ace_material_type(material_type):
"""Typ für den ACE. PA12-CF gibt es dort nicht, der Platz heißt PA6-CF."""
text = (material_type or "").strip()
if text.lower() == "pa12-cf":
return "PA6-CF"
return text
SOLIDS = [ SOLIDS = [
{"id": "schwarz", "name": "Schwarz", "hex": "#1c1c1c"}, {"id": "schwarz", "name": "Schwarz", "hex": "#1c1c1c"},
{"id": "weiss", "name": "Weiß", "hex": "#f4f4f4"}, {"id": "weiss", "name": "Weiß", "hex": "#f4f4f4"},
+8
View File
@@ -155,6 +155,7 @@ def load():
"ntfy": _ntfy(raw), "ntfy": _ntfy(raw),
"alerts": _alerts(raw), "alerts": _alerts(raw),
"auto_queue": bool(raw.get("auto_queue", True)), "auto_queue": bool(raw.get("auto_queue", True)),
"auto_retry": bool(raw.get("auto_retry", True)),
"_raw": raw, "_raw": raw,
"_path": path, "_path": path,
} }
@@ -210,6 +211,7 @@ def settings_public(cfg):
"watch_dir": str(cfg.get("watch_dir") or ""), "watch_dir": str(cfg.get("watch_dir") or ""),
"watch_dir_default": default_watch_suggestion(), "watch_dir_default": default_watch_suggestion(),
"auto_queue": bool(cfg.get("auto_queue", True)), "auto_queue": bool(cfg.get("auto_queue", True)),
"auto_retry": bool(cfg.get("auto_retry", True)),
"fqdn": str(cfg.get("fqdn") or ""), "fqdn": str(cfg.get("fqdn") or ""),
"legacy_token_set": bool(cfg.get("token")), "legacy_token_set": bool(cfg.get("token")),
"energy": dict(cfg.get("energy") or {}), "energy": dict(cfg.get("energy") or {}),
@@ -243,6 +245,7 @@ def save_settings(
ntfy=None, ntfy=None,
alerts=None, alerts=None,
auto_queue=None, auto_queue=None,
auto_retry=None,
fqdn=None, fqdn=None,
watch_dir=None, watch_dir=None,
): ):
@@ -255,6 +258,7 @@ def save_settings(
ntfy=ntfy, ntfy=ntfy,
alerts=alerts, alerts=alerts,
auto_queue=auto_queue, auto_queue=auto_queue,
auto_retry=auto_retry,
fqdn=fqdn, fqdn=fqdn,
watch_dir=watch_dir, watch_dir=watch_dir,
) )
@@ -267,6 +271,7 @@ def _save_settings_locked(
ntfy=None, ntfy=None,
alerts=None, alerts=None,
auto_queue=None, auto_queue=None,
auto_retry=None,
fqdn=None, fqdn=None,
watch_dir=None, watch_dir=None,
): ):
@@ -280,6 +285,9 @@ def _save_settings_locked(
if auto_queue is not None: if auto_queue is not None:
raw["auto_queue"] = bool(auto_queue) raw["auto_queue"] = bool(auto_queue)
cfg["auto_queue"] = bool(auto_queue) cfg["auto_queue"] = bool(auto_queue)
if auto_retry is not None:
raw["auto_retry"] = bool(auto_retry)
cfg["auto_retry"] = bool(auto_retry)
if watch_dir is not None: if watch_dir is not None:
resolved, stored = parse_watch_dir(watch_dir, create=True, missing_ok=False) resolved, stored = parse_watch_dir(watch_dir, create=True, missing_ok=False)
raw["watch_dir"] = stored raw["watch_dir"] = stored
+60 -12
View File
@@ -62,11 +62,13 @@ def _avg_power(a, b):
return max(0.0, min(1.0, sum(vals) / len(vals))) return max(0.0, min(1.0, sum(vals) / len(vals)))
def _ed_print_kwh(samples, base_w, nozzle_w, bed_w): def _ed_print_parts(samples, base_w, nozzle_w, bed_w):
"""Trapez aus Grundlast + ED×Heizerleistung. None wenn zu wenig ED-Proben.""" """Trapez: Grundlast / Düse / Bett getrennt. None wenn zu wenig ED-Proben."""
if not samples or len(samples) < 2: if not samples or len(samples) < 2:
return None return None
total = 0.0 base_kwh = 0.0
nozzle_kwh = 0.0
bed_kwh = 0.0
ed_steps = 0 ed_steps = 0
# Max. Intervall: 3× Normal-Sample (90 s), damit Lücken nicht die ED fortschreiben # Max. Intervall: 3× Normal-Sample (90 s), damit Lücken nicht die ED fortschreiben
max_dt = 90.0 max_dt = 90.0
@@ -77,6 +79,7 @@ def _ed_print_kwh(samples, base_w, nozzle_w, bed_w):
if t0 is None or t1 is None or t1 <= t0: if t0 is None or t1 is None or t1 <= t0:
continue continue
dt = min(t1 - t0, max_dt) dt = min(t1 - t0, max_dt)
hours = dt / 3600.0
pn0, pn1 = a.get("nozzle_power"), b.get("nozzle_power") pn0, pn1 = a.get("nozzle_power"), b.get("nozzle_power")
pb0, pb1 = a.get("bed_power"), b.get("bed_power") pb0, pb1 = a.get("bed_power"), b.get("bed_power")
has_ed = any( has_ed = any(
@@ -87,11 +90,17 @@ def _ed_print_kwh(samples, base_w, nozzle_w, bed_w):
ed_steps += 1 ed_steps += 1
ed_n = _avg_power(pn0, pn1) ed_n = _avg_power(pn0, pn1)
ed_b = _avg_power(pb0, pb1) ed_b = _avg_power(pb0, pb1)
watts = float(base_w) + float(nozzle_w) * ed_n + float(bed_w) * ed_b base_kwh += (float(base_w) / 1000.0) * hours
total += (watts / 1000.0) * (dt / 3600.0) nozzle_kwh += (float(nozzle_w) * ed_n / 1000.0) * hours
bed_kwh += (float(bed_w) * ed_b / 1000.0) * hours
if ed_steps < 1: if ed_steps < 1:
return None return None
return total return {
"base_kwh": base_kwh,
"nozzle_kwh": nozzle_kwh,
"bed_kwh": bed_kwh,
"print_kwh": base_kwh + nozzle_kwh + bed_kwh,
}
def _is_nt_moment(moment, tariff): def _is_nt_moment(moment, tariff):
@@ -130,10 +139,21 @@ def effective_euro_per_kwh(duration_s, energy, end_dt=None):
return nt_frac * nt + (1.0 - nt_frac) * ht return nt_frac * nt + (1.0 - nt_frac) * ht
def _part(key, label, kwh, euro):
kwh = float(kwh or 0)
return {
"key": key,
"label": label,
"kwh": kwh,
"cost": kwh * float(euro or 0),
}
def energy_estimate(duration_s, energy, samples=None, end_dt=None): def energy_estimate(duration_s, energy, samples=None, end_dt=None):
"""Strom aus ED-Proben oder pauschal Dauer × Watt. """Strom aus ED-Proben oder pauschal Dauer × Watt.
Quelle ``ed`` nur wenn Düse-/Bett-Watt gesetzt und Proben mit power vorliegen. Quelle ``ed`` nur wenn Düse-/Bett-Watt gesetzt und Proben mit power vorliegen.
``parts`` listet Grundlast / Düse / Bett / Trockner (kWh + €).
""" """
energy = energy or {} energy = energy or {}
euro = effective_euro_per_kwh(duration_s, energy, end_dt=end_dt) euro = effective_euro_per_kwh(duration_s, energy, end_dt=end_dt)
@@ -142,25 +162,43 @@ def energy_estimate(duration_s, energy, samples=None, end_dt=None):
nozzle_w = float(energy.get("nozzle_watts") or 0) nozzle_w = float(energy.get("nozzle_watts") or 0)
bed_w = float(energy.get("bed_watts") or 0) bed_w = float(energy.get("bed_watts") or 0)
print_kwh = None parts_map = None
source = "flat" source = "flat"
if (nozzle_w > 0 or bed_w > 0) and samples: if (nozzle_w > 0 or bed_w > 0) and samples:
print_kwh = _ed_print_kwh(samples, base_w, nozzle_w, bed_w) parts_map = _ed_print_parts(samples, base_w, nozzle_w, bed_w)
if print_kwh is not None: if parts_map is not None:
source = "ed" source = "ed"
if print_kwh is None: if parts_map is None:
hours = float(duration_s or 0) / 3600 hours = float(duration_s or 0) / 3600
print_kwh = (base_w / 1000) * hours parts_map = {
"base_kwh": (base_w / 1000) * hours,
"nozzle_kwh": 0.0,
"bed_kwh": 0.0,
"print_kwh": (base_w / 1000) * hours,
}
source = "flat" source = "flat"
print_kwh = float(parts_map["print_kwh"])
kwh = print_kwh + dry_kwh kwh = print_kwh + dry_kwh
parts = [
_part("base", "Grundlast", parts_map["base_kwh"], euro),
_part("nozzle", "Düse", parts_map["nozzle_kwh"], euro),
_part("bed", "Bett", parts_map["bed_kwh"], euro),
_part("dryer", "Trockner", dry_kwh, euro),
]
parts = [p for p in parts if p["kwh"] > 1e-6]
return { return {
"kwh": kwh, "kwh": kwh,
"print_kwh": print_kwh, "print_kwh": print_kwh,
"dry_kwh": dry_kwh, "dry_kwh": dry_kwh,
"base_kwh": float(parts_map["base_kwh"]),
"nozzle_kwh": float(parts_map["nozzle_kwh"]),
"bed_kwh": float(parts_map["bed_kwh"]),
"cost": kwh * euro, "cost": kwh * euro,
"euro_per_kwh": euro,
"source": source, "source": source,
"parts": parts,
} }
@@ -199,6 +237,7 @@ def order_actuals(conn, order_id):
SELECT SELECT
COALESCE(SUM(r.filament_cost + r.energy_cost), 0) AS cost_total, COALESCE(SUM(r.filament_cost + r.energy_cost), 0) AS cost_total,
COALESCE(SUM(r.duration_s), 0) AS duration_s, COALESCE(SUM(r.duration_s), 0) AS duration_s,
COALESCE(SUM(r.pieces_ok), 0) AS pieces_ok,
COALESCE(SUM(r.pieces_bad), 0) AS pieces_bad, COALESCE(SUM(r.pieces_bad), 0) AS pieces_bad,
COALESCE(SUM(r.weight_g) FILTER (WHERE r.verdict = 'ausschuss'), 0) AS scrap_g, COALESCE(SUM(r.weight_g) FILTER (WHERE r.verdict = 'ausschuss'), 0) AS scrap_g,
COUNT(*) FILTER (WHERE r.verdict = 'ausschuss') AS scrap_runs COUNT(*) FILTER (WHERE r.verdict = 'ausschuss') AS scrap_runs
@@ -208,10 +247,19 @@ def order_actuals(conn, order_id):
""", """,
(order_id,), (order_id,),
).fetchone() ).fetchone()
pieces_ok = int(row["pieces_ok"] or 0)
cost_total = float(row["cost_total"] or 0)
return { return {
"cost_total": float(row["cost_total"] or 0), "cost_total": cost_total,
"duration_s": float(row["duration_s"] or 0), "duration_s": float(row["duration_s"] or 0),
"pieces_ok": pieces_ok,
"pieces_bad": int(row["pieces_bad"] or 0), "pieces_bad": int(row["pieces_bad"] or 0),
"scrap_g": float(row["scrap_g"] or 0), "scrap_g": float(row["scrap_g"] or 0),
"scrap_runs": int(row["scrap_runs"] or 0), "scrap_runs": int(row["scrap_runs"] or 0),
"cost_per_part": (cost_total / pieces_ok) if pieces_ok > 0 else None,
"yield_percent": (
100.0 * pieces_ok / (pieces_ok + int(row["pieces_bad"] or 0))
if (pieces_ok + int(row["pieces_bad"] or 0)) > 0
else None
),
} }
+150
View File
@@ -0,0 +1,150 @@
"""Tagesbericht und Push für Wartung/Bedarf/Termine."""
from __future__ import annotations
import threading
import time
from datetime import date, datetime, timedelta, timezone
from grotti import bedarf as bedarf_mod
from grotti import jetzt as jetzt_mod
from grotti import notify
from grotti import store
from grotti.db import connect
def _yesterday_stats(conn):
today = date.today()
yesterday = today - timedelta(days=1)
start = datetime.combine(yesterday, datetime.min.time(), tzinfo=timezone.utc)
end = datetime.combine(today, datetime.min.time(), tzinfo=timezone.utc)
row = conn.execute(
"""
SELECT
COUNT(*) AS runs,
COALESCE(SUM(duration_s), 0) AS duration_s,
COALESCE(SUM(weight_used_g), 0) AS grams,
COALESCE(SUM(CASE WHEN verdict = 'ausschuss' THEN 1 ELSE 0 END), 0) AS scrap,
COALESCE(SUM(pieces_ok), 0) AS pieces_ok,
COALESCE(SUM(pieces_bad), 0) AS pieces_bad
FROM runs
WHERE started_at >= %s AND started_at < %s
""",
(start, end),
).fetchone()
return {
"date": yesterday.isoformat(),
"runs": int(row["runs"] or 0),
"duration_s": float(row["duration_s"] or 0),
"grams": float(row["grams"] or 0),
"scrap": int(row["scrap"] or 0),
"pieces_ok": int(row["pieces_ok"] or 0),
"pieces_bad": int(row["pieces_bad"] or 0),
}
def build_text(conn, watcher_latest=None, printers=None):
stats = _yesterday_stats(conn)
waiting = len([j for j in store.list_print_queue(conn, limit=200) if j.get("status") == "waiting"])
eta = jetzt_mod.farm_eta([
{"remain": (live or {}).get("remain") or 0}
for live in (watcher_latest or [])
if (live or {}).get("state") in ("printing", "paused", "heating")
])
lines = [
"Grotti Tagesbericht %s" % stats["date"],
"Läufe: %d · Druckzeit: %.1f h · Material: %.0f g" % (
stats["runs"], stats["duration_s"] / 3600.0, stats["grams"],
),
"Ausschuss-Läufe: %d · Stück gut/schlecht: %d/%d" % (
stats["scrap"], stats["pieces_ok"], stats["pieces_bad"],
),
"Heute in Warteschlange: %d" % waiting,
]
if eta.get("all_done_in_s"):
at = datetime.now(timezone.utc) + timedelta(seconds=eta["all_done_in_s"])
lines.append("Laufende Drucke fertig gegen %s Uhr" % at.astimezone().strftime("%H:%M"))
overdue = jetzt_mod.overdue_orders(conn, limit=5)
if overdue:
lines.append("Überfällig: " + ", ".join(o["title"] for o in overdue))
short = (bedarf_mod.compute(conn).get("shortfall") or [])[:3]
if short:
bits = []
for item in short:
label = " ".join(x for x in [item.get("material_type"), item.get("color")] if x) or "Filament"
bits.append("%s (−%sg)" % (label, int(item.get("shortfall_g") or 0)))
lines.append("Bedarf: " + ", ".join(bits))
parts = jetzt_mod.overdue_maintenance(conn, printers or store.list_printers(conn), limit=5)
if parts:
lines.append("Wartung: " + ", ".join("%s/%s" % (p["printer"], p["label"]) for p in parts))
return "\n".join(lines)
def push_ops_alerts(conn, cfg, printers=None):
"""Einmalige Push-Hinweise für Bedarf, Wartung, Termine (mit Dedup über Events).
kind beim Speichern muss zum Dedup-Check passen — sonst jede Minute ein neuer Eintrag
(DailyReportThread tickt alle 60 s).
"""
printers = printers or store.list_printers(conn)
for order in jetzt_mod.overdue_orders(conn, limit=10):
msg = "Auftrag überfällig: %s (fällig %s)" % (order["title"], order.get("due_at") or "")
key = "order:%s" % order["id"]
if not store.recent_event_kind(conn, key, "auftrag", within_s=86400):
notify.record(conn, cfg, key, order["title"], "auftrag", msg, dedupe_s=86400)
for part in jetzt_mod.overdue_maintenance(conn, printers, limit=10):
msg = "Wartung fällig: %s · %s" % (part["printer"], part["label"])
key = "maint:%s" % part["id"]
if not store.recent_event_kind(conn, key, "wartung", within_s=86400):
notify.record(conn, cfg, key, part["printer"], "wartung", msg, dedupe_s=86400)
for item in (bedarf_mod.compute(conn).get("shortfall") or [])[:5]:
label = " ".join(x for x in [item.get("material_type"), item.get("color")] if x) or "Filament"
msg = "Bedarf: %s — %s g fehlen" % (label, int(item.get("shortfall_g") or 0))
key = "bedarf:%s:%s" % (item.get("material_type") or "", item.get("color") or "")
if not store.recent_event_kind(conn, key, "bedarf", within_s=21600):
notify.record(conn, cfg, key, label, "bedarf", msg, dedupe_s=21600)
class DailyReportThread:
"""Sendet um 7:00 Ortszeit den Tagesbericht und prüft periodisch Ops-Alerts."""
def __init__(self, database_url, cfg, watcher=None):
self.database_url = database_url
self.cfg = cfg
self.watcher = watcher
self._stop = threading.Event()
self._thread = None
self._last_report_day = None
def start(self):
if self._thread and self._thread.is_alive():
return
self._thread = threading.Thread(target=self._loop, name="grotti-daily", daemon=True)
self._thread.start()
def stop(self):
self._stop.set()
def _loop(self):
while not self._stop.wait(60):
try:
self._tick()
except Exception:
pass
def _tick(self):
now = datetime.now().astimezone()
with connect(self.database_url) as conn:
push_ops_alerts(conn, self.cfg, store.list_printers(conn))
conn.commit()
if now.hour == 7 and now.minute < 2:
day = now.date().isoformat()
if self._last_report_day == day:
return
latest = (self.watcher.latest if self.watcher else None) or []
text = build_text(conn, latest, store.list_printers(conn))
notify.send_telegram(self.cfg, text)
notify.send_ntfy(self.cfg, text)
store.add_event(conn, "", "Grotti", "fertig", text.split("\n")[0])
conn.commit()
self._last_report_day = day
+289
View File
@@ -0,0 +1,289 @@
"""Aggregierte „Jetzt“-Handlungsliste für Sidebar und Mobile."""
from __future__ import annotations
from datetime import date, datetime, timezone
from grotti import bedarf as bedarf_mod
from grotti import maintenance
from grotti import store
def _filename_label(filename):
name = (filename or "").strip()
for suffix in (".gcode.3mf", ".gcode", ".3mf"):
if name.lower().endswith(suffix):
name = name[: -len(suffix)]
break
while name and name[0].isdigit():
parts = name.split("-", 1)
if len(parts) == 2 and parts[0].isdigit():
name = parts[1]
else:
break
return name or filename or ""
def _iso_remain(remain_s):
remain = float(remain_s or 0)
if remain <= 0:
return None
at = datetime.now(timezone.utc).timestamp() + remain
return datetime.fromtimestamp(at, tz=timezone.utc).isoformat()
def farm_eta(running_items):
"""Früheste und späteste Fertig-Zeit aus laufenden Drucken."""
remains = [
float(item.get("remain") or 0)
for item in (running_items or [])
if float(item.get("remain") or 0) > 0
]
if not remains:
return {"all_done_at": None, "all_done_in_s": None, "next_free_in_s": None}
return {
"all_done_at": _iso_remain(max(remains)),
"all_done_in_s": max(remains),
"next_free_in_s": min(remains),
}
def overdue_orders(conn, limit=8):
today = date.today()
rows = conn.execute(
"""
SELECT o.*, c.name AS customer_name
FROM orders o
LEFT JOIN customers c ON c.id = o.customer_id
WHERE o.due_at IS NOT NULL
AND o.due_at < %s
AND o.status NOT IN ('fertig', 'geliefert', 'bezahlt')
ORDER BY o.due_at ASC, o.id ASC
LIMIT %s
""",
(today, int(limit)),
).fetchall()
out = []
for row in rows:
out.append({
"id": row["id"],
"title": row["title"],
"status": row["status"],
"due_at": row["due_at"].isoformat() if row.get("due_at") else None,
"customer": row.get("customer_name") or "",
})
return out
def overdue_maintenance(conn, printers, limit=8):
items = []
for printer in printers or []:
host = printer.get("host") or ""
counters = maintenance.counters_for_host(conn, host)
rows = conn.execute(
"SELECT * FROM maintenance_parts WHERE printer_host = %s ORDER BY label, part_key, id",
(host,),
).fetchall()
for row in rows:
part = maintenance.part_out(row, counters, conn=conn)
if not (part.get("overdue_hours") or part.get("overdue_meters")):
continue
items.append({
"id": part["id"],
"host": host,
"printer": printer.get("name") or host,
"label": part.get("label") or part.get("part_key") or "Teil",
"overdue_hours": bool(part.get("overdue_hours")),
"overdue_meters": bool(part.get("overdue_meters")),
})
if len(items) >= limit:
return items
return items
def build(conn, watcher_latest, printers=None, send_jobs=None):
"""Priorisierte Handlungsliste + laufende Drucke + Farm-ETA."""
printers = printers if printers is not None else store.list_printers(conn)
clears = store.bed_clear_map(conn)
busy_hosts = {
row["host"]
for row in conn.execute(
"SELECT DISTINCT host FROM print_queue WHERE status = 'running'"
).fetchall()
}
queue = store.list_print_queue(conn, limit=80)
waiting = [j for j in queue if j.get("status") == "waiting"]
errors = [j for j in queue if j.get("status") == "error"]
running_q = [j for j in queue if j.get("status") == "running"]
actions = []
for live in watcher_latest or []:
host = live.get("host") or ""
state = (live.get("state") or "").lower()
if state == "paused" and (live.get("filament_empty") or (live.get("pause_reason") or "").strip()):
actions.append({
"kind": "filament_pause",
"priority": 12,
"host": host,
"printer": live.get("name") or host,
"title": "Material nachlegen",
"detail": (live.get("pause_reason") or "Drucker pausiert — Filament leer?").strip(),
"filename": live.get("filename") or "",
"href": "/#" + host,
})
for live in watcher_latest or []:
host = live.get("host") or ""
state = (live.get("state") or "").lower()
if not clears.get(host) or host in busy_hosts or state in ("printing", "paused", "heating"):
continue
actions.append({
"kind": "clear",
"priority": 10,
"host": host,
"printer": live.get("name") or host,
"title": "Platte räumen",
"detail": _filename_label(live.get("filename") or ""),
"filename": live.get("filename") or "",
"href": "/#" + host,
})
for job in errors[:6]:
actions.append({
"kind": "queue_error",
"priority": 20,
"id": job.get("id"),
"host": job.get("host") or "",
"printer": job.get("printer_name") or job.get("host") or "",
"title": "Druck fehlgeschlagen",
"detail": job.get("message") or _filename_label(job.get("filename") or ""),
"filename": job.get("filename") or "",
"is_ablauf": bool(job.get("is_ablauf")),
"step_index": job.get("step_index"),
"step_label_current": job.get("step_label_current") or "",
"href": "/warteschlange",
})
bedarf = bedarf_mod.compute(conn)
for item in (bedarf.get("shortfall") or [])[:5]:
label = " · ".join(
x for x in [item.get("material_type"), item.get("color")] if x
) or "Filament"
actions.append({
"kind": "bedarf",
"priority": 30,
"title": "Spule fehlt",
"detail": "%s — %s g fehlen" % (label, int(item.get("shortfall_g") or 0)),
"material_type": item.get("material_type") or "",
"color": item.get("color") or "",
"shortfall_g": item.get("shortfall_g") or 0,
"href": "/material#bedarf",
})
for part in overdue_maintenance(conn, printers):
actions.append({
"kind": "wartung",
"priority": 40,
"id": part["id"],
"host": part["host"],
"printer": part["printer"],
"title": "Wartung fällig",
"detail": "%s · %s" % (part["printer"], part["label"]),
"href": "/wartung",
})
for order in overdue_orders(conn):
actions.append({
"kind": "auftrag",
"priority": 50,
"id": order["id"],
"title": "Auftrag überfällig",
"detail": "%s · fällig %s" % (order["title"], order.get("due_at") or ""),
"due_at": order.get("due_at"),
"href": "/auftraege",
})
shortfall = bedarf.get("shortfall") or []
for job in waiting[:8]:
tip = (job.get("wait_reason") or "").strip()
if not tip and shortfall:
# Fallback ohne Routing-Hinweis
tip = ""
if not tip:
continue
actions.append({
"kind": "waiting",
"priority": 60,
"id": job.get("id"),
"host": job.get("host") or "",
"printer": job.get("printer_name") or job.get("host") or "",
"title": "Wartet auf Material",
"detail": tip,
"filename": job.get("filename") or "",
"reason": tip,
"href": "/warteschlange",
})
actions.sort(key=lambda a: (a.get("priority") or 99, a.get("id") or 0))
live_by_host = {}
running = []
for live in watcher_latest or []:
state = (live.get("state") or "").lower()
if state not in ("printing", "paused", "heating"):
continue
item = {
"host": live.get("host") or "",
"name": live.get("name") or "",
"state": state,
"progress": live.get("progress") or 0,
"remain": live.get("remain") or 0,
"filename": live.get("filename") or "",
"filament_empty": bool(live.get("filament_empty")),
"pause_reason": (live.get("pause_reason") or "").strip(),
"queue_id": None,
}
live_by_host[item["host"]] = item
running.append(item)
for job in running_q:
host = job.get("host") or ""
if host in live_by_host:
live_by_host[host]["queue_id"] = job.get("id")
live_by_host[host]["is_ablauf"] = bool(job.get("is_ablauf"))
live_by_host[host]["step_labels"] = job.get("step_labels")
live_by_host[host]["display_step_index"] = job.get("display_step_index")
live_by_host[host]["step_index"] = job.get("step_index")
else:
running.append({
"host": host,
"name": job.get("printer_name") or host,
"state": "running",
"progress": job.get("progress") or 0,
"remain": job.get("remain") or 0,
"filename": job.get("filename") or "",
"queue_id": job.get("id"),
"is_ablauf": bool(job.get("is_ablauf")),
})
sends = []
for job in send_jobs or []:
if job.get("status") in ("pending", "running"):
sends.append(job)
elif job.get("status") == "error" and len(sends) < 2:
sends.append(job)
alert_kinds = list(store.ALERT_EVENT_KINDS)
alert_items = store.list_events(conn, limit=5, unread_only=True, kinds=alert_kinds)
unread = store.unread_event_count(conn, kinds=alert_kinds)
return {
"actions": actions,
"running": running,
"waiting_count": len(waiting),
"sends": sends[:5],
"alerts": alert_items,
"unread_alerts": unread,
"eta": farm_eta(running),
"bedarf_shortfall": len(shortfall),
}
+4 -17
View File
@@ -5,7 +5,7 @@ import urllib.error
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
from datetime import datetime, timezone from datetime import datetime, timezone
from grotti import acm, eject, library, moonraker, store, usage from grotti import acm, catalog, eject, library, moonraker, store, usage
from grotti.cost import energy_cost from grotti.cost import energy_cost
from grotti.errors import GrottiError from grotti.errors import GrottiError
from grotti.parse import content_has_ace_trailer, prepare_rinkhals_gcode from grotti.parse import content_has_ace_trailer, prepare_rinkhals_gcode
@@ -127,21 +127,8 @@ def resolve_spools_at_host(conn, host, ace_map=None):
def assert_material_match(file_row, spools): def assert_material_match(file_row, spools):
"""Datei-Material gegen eingelegte Rollen — warnt nicht bei leerer Liste.""" """Falsche Sorte bricht den Start nicht ab. Die Oberfläche warnt vorher."""
want = {t.lower() for t in file_material_types(file_row)} return
if not want or not spools:
return
have = {(row.get("material_type") or "").strip().lower() for row in spools}
have.discard("")
if not have:
return
if want.isdisjoint(have):
raise GrottiError(
"Material passt nicht: Datei braucht %s, im Drucker steckt %s."
% (", ".join(sorted(want)), ", ".join(sorted(have)) or "unbekannt"),
"material",
409,
)
def assert_spool_at_host(conn, spool_id, host): def assert_spool_at_host(conn, spool_id, host):
@@ -686,7 +673,7 @@ def steuern(conn, host, action, params=None):
continue continue
if not spool: if not spool:
raise GrottiError("Auf diesem Platz liegt keine Spule in Grotti.", "spule") raise GrottiError("Auf diesem Platz liegt keine Spule in Grotti.", "spule")
material = (spool.get("material_type") or "").strip() material = catalog.ace_material_type(spool.get("material_type"))
if not material: if not material:
raise GrottiError("Die Spule braucht eine Sorte.", "material") raise GrottiError("Die Spule braucht eine Sorte.", "material")
color = moonraker.spool_rgb(spool) color = moonraker.spool_rgb(spool)
+3
View File
@@ -0,0 +1,3 @@
-- Auto-Retry und Routing-Hinweise in der Warteschlange.
ALTER TABLE print_queue ADD COLUMN IF NOT EXISTS retry_count integer NOT NULL DEFAULT 0;
ALTER TABLE print_queue ADD COLUMN IF NOT EXISTS wait_reason text NOT NULL DEFAULT '';
+8
View File
@@ -0,0 +1,8 @@
-- PA12-CF bleibt in Grotti die echte Sorte. Der ACE kennt nur PA6-CF.
UPDATE materials
SET material_type = 'PA12-CF'
WHERE name = 'Sunlu PA12-CF' AND material_type = 'PA';
UPDATE spools
SET material_type = 'PA12-CF'
WHERE name = 'Sunlu PA12-CF' AND material_type = 'PA';
+100 -9
View File
@@ -889,6 +889,81 @@ def enrich_error_message(host, stats, status):
return " · ".join(bits) if bits else (raw or "Unbekannter Fehler — in Mainsail prüfen") return " · ".join(bits) if bits else (raw or "Unbekannter Fehler — in Mainsail prüfen")
_FILAMENT_HINT_RE = re.compile(
r"filament|runout|out of fil|no fil|fil.?empty|10107|"
r"material.?leer|spule.?leer|filament.?gerissen|filament.?fehlt|"
r"filament.?sensor|out of material",
re.I,
)
def last_filament_gcode_hint(host, limit=40):
"""Letzte G-code-Zeile, die auf Filament-Ende / Runout hindeutet."""
try:
payload = fetch_json("http://%s/server/gcode_store?count=%d" % (host, int(limit)), timeout=3)
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, KeyError, OSError):
return ""
items = ((payload.get("result") or {}).get("gcode_store") or [])
for item in reversed(items):
if not isinstance(item, dict):
continue
text = (item.get("message") or "").strip()
if not text or not _FILAMENT_HINT_RE.search(text):
continue
low = text.lower()
if "10107" in low:
return "Filament gerissen oder leer (10107)"
return text[:160]
return ""
def filament_out_hint(mmu, message="", host=None):
"""Vermutetes Material-Ende bei Pause: (code, Text) oder (None, None).
Quellen: print_stats.message, ACE/mmu (active_filament.empty, gate_status),
optional G-code-Store (nur wenn host gesetzt).
"""
text = (message or "").strip()
if text and _FILAMENT_HINT_RE.search(text):
if "10107" in text.lower():
return "message", "Filament gerissen oder leer (10107)"
return "message", text[:160]
if isinstance(mmu, dict) and mmu:
active = mmu.get("active_filament")
if isinstance(active, dict) and (
active.get("empty") is True
or str(active.get("empty") or "").lower() in ("1", "true")
):
return "active_empty", "Aktives Filament leer (ACE)"
tool = mmu.get("tool")
if not isinstance(tool, (int, float)) or isinstance(tool, bool):
tool = mmu.get("gate")
if isinstance(tool, (int, float)) and not isinstance(tool, bool) and int(tool) >= 0:
tool_i = int(tool)
states = mmu.get("gate_status")
if isinstance(states, list) and tool_i < len(states) and states[tool_i] == 0:
return "gate_empty", "ACE-Platz %d leer" % (tool_i + 1)
# ACE pausiert und kein aktives Filament → oft Runout ohne leeren gate_status
if mmu.get("is_paused") and isinstance(active, dict):
kind = (
active.get("filament_type")
or active.get("type")
or active.get("material")
or ""
)
if not str(kind).strip():
return "mmu_paused", "ACE pausiert — kein Filament aktiv"
if host:
hint = last_filament_gcode_hint(host)
if hint:
return "gcode", hint
return None, None
def exclude_status(host): def exclude_status(host):
"""Klipper [exclude_object] — leer, wenn Modul fehlt oder Query scheitert.""" """Klipper [exclude_object] — leer, wenn Modul fehlt oder Query scheitert."""
empty = {"objects": [], "excluded": [], "current": None} empty = {"objects": [], "excluded": [], "current": None}
@@ -1055,6 +1130,15 @@ def query(host):
message = stats.get("message") or "" message = stats.get("message") or ""
if (state or "").lower() == "error": if (state or "").lower() == "error":
message = enrich_error_message(host, stats, status) message = enrich_error_message(host, stats, status)
pause_code, pause_reason = None, ""
filament_empty = False
if (state or "").lower() == "paused":
# G-code-Store nur bei Pause abfragen — sonst unnötige Last.
pause_code, pause_reason = filament_out_hint(mmu, message, host=host)
filament_empty = bool(pause_code)
if filament_empty and pause_reason:
if not message or message.lower() in ("paused", "pause", "pausiert"):
message = pause_reason
toolhead = status.get("toolhead") or {} toolhead = status.get("toolhead") or {}
axis_max = toolhead.get("axis_maximum") or [] axis_max = toolhead.get("axis_maximum") or []
z_max = None z_max = None
@@ -1064,6 +1148,8 @@ def query(host):
"ok": True, "ok": True,
"state": state, "state": state,
"message": message, "message": message,
"filament_empty": filament_empty,
"pause_reason": pause_reason or "",
"homed_axes": toolhead.get("homed_axes") or "", "homed_axes": toolhead.get("homed_axes") or "",
"position": toolhead.get("position") or [], "position": toolhead.get("position") or [],
"z_max": z_max, "z_max": z_max,
@@ -1142,6 +1228,8 @@ def snapshot(printers):
"ok": False, "ok": False,
"state": "offline", "state": "offline",
"message": str(error), "message": str(error),
"filament_empty": False,
"pause_reason": "",
"camera": "", "camera": "",
"has_camera": False, "has_camera": False,
"fan": None, "fan": None,
@@ -1185,20 +1273,23 @@ def camera_image(host):
return CAMERA_CACHE[host][2] return CAMERA_CACHE[host][2]
def open_camera_stream(host):
"""Öffnet den MJPEG-Stream am Drucker. Aufrufer schließt die Response."""
url = "http://%s/webcam/?action=stream" % host
request = urllib.request.Request(url)
return urllib.request.urlopen(request, timeout=30)
def _camera_fields(host, quoted_host): def _camera_fields(host, quoted_host):
"""Kamera-URL nur, wenn nicht als tot bekannt (Cache-Miss = erst versuchen).""" """Kamera-URL nur, wenn nicht als tot bekannt (Cache-Miss = erst versuchen)."""
snap = "/api/v1/kamera?host=%s" % quoted_host
stream = "/api/v1/kamera/stream?host=%s" % quoted_host
cached = CAMERA_CACHE.get(host) cached = CAMERA_CACHE.get(host)
if cached is not None and cached[2] is None: if cached is not None and cached[2] is None:
return {"camera": "", "has_camera": False} return {"camera": "", "camera_stream": "", "has_camera": False}
if cached is not None and cached[2] is not None: if cached is not None and cached[2] is not None:
return { return {"camera": snap, "camera_stream": stream, "has_camera": True}
"camera": "/api/v1/kamera?host=%s" % quoted_host, return {"camera": snap, "camera_stream": stream, "has_camera": None}
"has_camera": True,
}
return {
"camera": "/api/v1/kamera?host=%s" % quoted_host,
"has_camera": None,
}
def embedded_bytes(host, name): def embedded_bytes(host, name):
+6 -1
View File
@@ -34,7 +34,12 @@ def send_telegram(cfg, text):
return False return False
ALERT_GROUP = {"abbruch": "fehler"} ALERT_GROUP = {
"abbruch": "fehler",
"bedarf": "queue",
"wartung": "queue",
"auftrag": "queue",
}
DEFAULT_OFF = {"ablauf", "start"} DEFAULT_OFF = {"ablauf", "start"}
+99 -36
View File
@@ -144,14 +144,46 @@ class PrintQueueDispatcher:
return (datetime.now(timezone.utc) - updated).total_seconds() return (datetime.now(timezone.utc) - updated).total_seconds()
return 0 return 0
def _auto_retry_enabled(self):
return bool((self.cfg or {}).get("auto_retry", True))
def _try_auto_retry(self, conn, item, message):
"""Ein automatischer Neuversuch; sonst False."""
if not self._auto_retry_enabled():
return False
retries = int(item.get("retry_count") or 0)
if retries >= 1:
return False
store.update_print_queue(
conn,
item["id"],
{
"status": "waiting",
"message": "Nochmal (%s)" % (message or "Fehler"),
"retry_count": retries + 1,
"phase": "",
"phase_started_at": None,
"host": item.get("host") or "",
},
)
return True
def _fail_stuck_simple_start(self, conn, item, printer): def _fail_stuck_simple_start(self, conn, item, printer):
host = printer["host"] host = printer["host"]
msg = "Start abgebrochen (Neustart)"
if self._try_auto_retry(conn, item, msg):
self._event(
conn, host, printer["name"], "queue",
"%s: Nochmal — %s" % (printer["name"], msg),
item.get("filename") or "",
)
return
store.update_print_queue( store.update_print_queue(
conn, conn,
item["id"], item["id"],
{ {
"status": "error", "status": "error",
"message": "Start abgebrochen (Neustart)", "message": msg,
"phase": "", "phase": "",
"phase_started_at": None, "phase_started_at": None,
}, },
@@ -828,43 +860,64 @@ class PrintQueueDispatcher:
return state return state
def _start_cool_step(self, conn, item, printer, step): def _start_cool_step(self, conn, item, printer, step):
# TODO: _try_cool_park (bis 60 s G-code) blockiert tick() — ggf. in Worker auslagern. # Park (bis 60 s G-code) läuft im Worker, damit tick() nicht blockiert.
host = printer["host"] host = printer["host"]
cool = eject.normalize_cool(step) cool = eject.normalize_cool(step)
try: item_id = item["id"]
moonraker.gcode_script(host, eject.cool_script(cool), timeout=30) printer_copy = dict(printer)
# Idle zuerst verlängern, dann Park (sonst Stepper aus → must home). item_copy = dict(item)
try:
moonraker.gcode_script(host, eject.idle_timeout_script(cool), timeout=15) def work():
except Exception: with connect(self.database_url) as wconn:
pass try:
park = self._try_cool_park(host, item["id"], force=True) moonraker.gcode_script(host, eject.cool_script(cool), timeout=30)
self._keepalive[item["id"]] = time.monotonic() try:
park_note = "" moonraker.gcode_script(host, eject.idle_timeout_script(cool), timeout=15)
if not park["ok"] and park["error"]: except Exception:
park_note = " — Mitte fehlgeschlagen: %s" % park["error"] pass
elif park["ok"]: park = self._try_cool_park(host, item_id, force=True)
park_note = " · Düse Mitte" self._keepalive[item_id] = time.monotonic()
store.update_print_queue( park_note = ""
conn, if not park["ok"] and park["error"]:
item["id"], park_note = " — Mitte fehlgeschlagen: %s" % park["error"]
{ elif park["ok"]:
"phase": "cooling", park_note = " · Düse Mitte"
"phase_started_at": "now", row = store.get_print_queue_row(wconn, item_id) or item_copy
"message": self._ablauf_message( store.update_print_queue(
item, wconn,
"kühlt ab (Bett ≤ %.0f °C, Düse ≤ %.0f °C)%s" item_id,
% (cool["bed_max"], cool["nozzle_max"], park_note), {
), "message": self._ablauf_message(row, "kühlt%s" % park_note),
}, "phase": "cooling",
) "phase_started_at": datetime.now(timezone.utc),
self._step_event( },
conn, item, printer, )
"kühlt ab bis Bett ≤ %.0f °C, Düse ≤ %.0f °C%s" wconn.commit()
% (cool["bed_max"], cool["nozzle_max"], park_note), except GrottiError as exc:
) self._fail_ablauf(wconn, item_copy, printer_copy, "Abkühlen: %s" % exc.message)
except Exception as exc: wconn.commit()
self._fail_ablauf(conn, item, printer, "Abkühlen: %s" % exc) except Exception as exc:
self._fail_ablauf(wconn, item_copy, printer_copy, "Abkühlen: %s" % exc)
wconn.commit()
store.update_print_queue(
conn,
item_id,
{
"message": self._ablauf_message(
item,
"kühlt ab (Bett ≤ %.0f °C, Düse ≤ %.0f °C)"
% (cool["bed_max"], cool["nozzle_max"]),
),
"phase": "cooling",
"phase_started_at": datetime.now(timezone.utc),
},
)
self._step_event(
conn, item, printer,
"kühlt ab bis Bett ≤ %.0f °C, Düse ≤ %.0f °C" % (cool["bed_max"], cool["nozzle_max"]),
)
self._spawn_worker(item_id, work)
def _poll_cool(self, conn, item, printer, live, step): def _poll_cool(self, conn, item, printer, live, step):
cool = eject.normalize_cool(step) cool = eject.normalize_cool(step)
@@ -1214,6 +1267,16 @@ class PrintQueueDispatcher:
self._fail_ablauf(conn, item, printer, "Unbekannter Schritt: %s" % kind) self._fail_ablauf(conn, item, printer, "Unbekannter Schritt: %s" % kind)
def _fail_ablauf(self, conn, item, printer, message): def _fail_ablauf(self, conn, item, printer, message):
if self._try_auto_retry(conn, item, message):
self._event(
conn,
printer["host"],
printer["name"],
"queue",
"%s Nochmal — %s" % (printer["name"], message),
item.get("filename") or "",
)
return
store.update_print_queue( store.update_print_queue(
conn, conn,
item["id"], item["id"],
+2
View File
@@ -172,6 +172,8 @@ ALTER TABLE print_queue ADD COLUMN IF NOT EXISTS repeat_total integer NOT NULL D
ALTER TABLE print_queue ADD COLUMN IF NOT EXISTS repeat_done integer NOT NULL DEFAULT 0; ALTER TABLE print_queue ADD COLUMN IF NOT EXISTS repeat_done integer NOT NULL DEFAULT 0;
ALTER TABLE print_queue ADD COLUMN IF NOT EXISTS phase text NOT NULL DEFAULT ''; ALTER TABLE print_queue ADD COLUMN IF NOT EXISTS phase text NOT NULL DEFAULT '';
ALTER TABLE print_queue ADD COLUMN IF NOT EXISTS phase_started_at timestamptz; ALTER TABLE print_queue ADD COLUMN IF NOT EXISTS phase_started_at timestamptz;
ALTER TABLE print_queue ADD COLUMN IF NOT EXISTS retry_count integer NOT NULL DEFAULT 0;
ALTER TABLE print_queue ADD COLUMN IF NOT EXISTS wait_reason text NOT NULL DEFAULT '';
CREATE INDEX IF NOT EXISTS print_queue_waiting ON print_queue (status, priority, id); CREATE INDEX IF NOT EXISTS print_queue_waiting ON print_queue (status, priority, id);
CREATE INDEX IF NOT EXISTS print_queue_running_host ON print_queue (host, status) WHERE status = 'running'; CREATE INDEX IF NOT EXISTS print_queue_running_host ON print_queue (host, status) WHERE status = 'running';
+258 -16
View File
@@ -176,6 +176,12 @@ def run_out(row):
"chargen_nr": row.get("chargen_nr") or "", "chargen_nr": row.get("chargen_nr") or "",
"message": row.get("message") or "", "message": row.get("message") or "",
"objects": _json_list(row.get("objects")), "objects": _json_list(row.get("objects")),
"cost_per_part": (
(float(row.get("filament_cost") or 0) + float(row.get("energy_cost") or 0))
/ int(row["pieces_ok"])
if int(row.get("pieces_ok") or 0) > 0
else None
),
} }
@@ -1833,6 +1839,16 @@ def statistics(conn, since=None, until=None, host="", material="", brand="", pro
run_where.append("r.project_id = %s") run_where.append("r.project_id = %s")
run_values.append(project_id) run_values.append(project_id)
run_clause = " AND ".join(run_where) run_clause = " AND ".join(run_where)
pieces_row = conn.execute(
"""
SELECT
COALESCE(SUM(r.pieces_ok), 0) AS pieces_ok,
COALESCE(SUM(r.pieces_bad), 0) AS pieces_bad
FROM runs r
WHERE %s
""" % run_clause,
run_values,
).fetchone()
by_printer = conn.execute( by_printer = conn.execute(
""" """
@@ -1936,6 +1952,9 @@ def statistics(conn, since=None, until=None, host="", material="", brand="", pro
).fetchone() ).fetchone()
ov = overview2 or ov ov = overview2 or ov
pieces_ok = int((pieces_row or {}).get("pieces_ok") or 0)
pieces_bad = int((pieces_row or {}).get("pieces_bad") or 0)
pieces_total = pieces_ok + pieces_bad
return { return {
"overview": { "overview": {
"jobs": int(ov.get("jobs") or 0), "jobs": int(ov.get("jobs") or 0),
@@ -1946,6 +1965,10 @@ def statistics(conn, since=None, until=None, host="", material="", brand="", pro
"failed": int(ov.get("failed") or 0), "failed": int(ov.get("failed") or 0),
"scrap": int(ov.get("scrap") or 0), "scrap": int(ov.get("scrap") or 0),
"good": int(ov.get("good") or 0), "good": int(ov.get("good") or 0),
"pieces_ok": pieces_ok,
"pieces_bad": pieces_bad,
"yield_percent": (100.0 * pieces_ok / pieces_total) if pieces_total else None,
"cost_per_part": (float(ov.get("cost") or 0) / pieces_ok) if pieces_ok else None,
}, },
"by_printer": [ "by_printer": [
{ {
@@ -2186,7 +2209,11 @@ def event_out(row):
# Protokoll-Ereignisse, die kein Alert sind: gleich als gelesen speichern. # Protokoll-Ereignisse, die kein Alert sind: gleich als gelesen speichern.
QUIET_EVENT_KINDS = {"ablauf", "start", "fertig"} QUIET_EVENT_KINDS = {"ablauf", "start", "fertig", "spule", "auth"}
# Was in der Sidebar als „Meldungen“ zählt.
ALERT_EVENT_KINDS = frozenset({
"fehler", "abbruch", "offline", "queue", "spule", "bedarf", "wartung", "auftrag",
})
def add_event(conn, host, printer, kind, message, filename="", run_id=None): def add_event(conn, host, printer, kind, message, filename="", run_id=None):
@@ -2232,19 +2259,29 @@ def list_events(conn, limit=40, unread_only=False, host=None, kinds=None, since=
return [event_out(row) for row in rows] return [event_out(row) for row in rows]
def unread_event_count(conn): def unread_event_count(conn, kinds=None):
if kinds:
return conn.execute(
"SELECT COUNT(*) AS n FROM events WHERE read_at IS NULL AND kind = ANY(%s)",
(list(kinds),),
).fetchone()["n"]
return conn.execute("SELECT COUNT(*) AS n FROM events WHERE read_at IS NULL").fetchone()["n"] return conn.execute("SELECT COUNT(*) AS n FROM events WHERE read_at IS NULL").fetchone()["n"]
def mark_events_read(conn, event_ids=None): def mark_events_read(conn, event_ids=None, kinds=None):
if event_ids: if event_ids:
conn.execute( conn.execute(
"UPDATE events SET read_at = now() WHERE read_at IS NULL AND id = ANY(%s)", "UPDATE events SET read_at = now() WHERE read_at IS NULL AND id = ANY(%s)",
(list(event_ids),), (list(event_ids),),
) )
elif kinds:
conn.execute(
"UPDATE events SET read_at = now() WHERE read_at IS NULL AND kind = ANY(%s)",
(list(kinds),),
)
else: else:
conn.execute("UPDATE events SET read_at = now() WHERE read_at IS NULL") conn.execute("UPDATE events SET read_at = now() WHERE read_at IS NULL")
return unread_event_count(conn) return unread_event_count(conn, kinds=kinds or list(ALERT_EVENT_KINDS))
def recent_event_kind(conn, host, kind, within_s=60): def recent_event_kind(conn, host, kind, within_s=60):
@@ -2323,6 +2360,8 @@ def print_queue_out(row, file_name=None):
"phase_started_at": iso(row.get("phase_started_at")), "phase_started_at": iso(row.get("phase_started_at")),
"is_ablauf": bool(steps), "is_ablauf": bool(steps),
"order_item_id": row.get("order_item_id"), "order_item_id": row.get("order_item_id"),
"retry_count": int(row.get("retry_count") or 0),
"wait_reason": row.get("wait_reason") or "",
"created_at": iso(row["created_at"]), "created_at": iso(row["created_at"]),
"updated_at": iso(row["updated_at"]), "updated_at": iso(row["updated_at"]),
} }
@@ -2498,6 +2537,8 @@ def update_print_queue(conn, item_id, fields):
"spool_id": "spool_id", "spool_id": "spool_id",
"bed_leveling": "bed_leveling", "bed_leveling": "bed_leveling",
"override_machine": "override_machine", "override_machine": "override_machine",
"retry_count": "retry_count",
"wait_reason": "wait_reason",
} }
sets = [] sets = []
values = [] values = []
@@ -2538,32 +2579,156 @@ def update_print_queue(conn, item_id, fields):
return print_queue_out(fresh, fresh.get("filename")) if fresh else None return print_queue_out(fresh, fresh.get("filename")) if fresh else None
def _spool_material_set(conn, host):
rows = conn.execute(
"""
SELECT lower(coalesce(material_type, '')) AS mt,
lower(coalesce(color, '')) AS color,
coalesce(remaining_g, 0) AS remaining_g
FROM spools
WHERE printer_host = %s
""",
(host or "",),
).fetchall()
return [
{
"material_type": (r["mt"] or "").strip(),
"color": (r["color"] or "").strip(),
"remaining_g": float(r["remaining_g"] or 0),
}
for r in rows
]
def _file_needs(conn, file_id):
if not file_id:
return [], 0.0
row = conn.execute(
"SELECT filaments, weight_g FROM files WHERE id = %s",
(file_id,),
).fetchone()
if not row:
return [], 0.0
filaments = row.get("filaments") or []
if isinstance(filaments, str):
try:
import json
filaments = json.loads(filaments)
except Exception:
filaments = []
types = []
for item in filaments if isinstance(filaments, list) else []:
if isinstance(item, dict):
t = (item.get("type") or item.get("material_type") or "").strip().lower()
c = (item.get("color") or "").strip().lower()
if t:
types.append({"material_type": t, "color": c})
elif isinstance(item, str) and item.strip():
types.append({"material_type": item.strip().lower(), "color": ""})
return types, float(row.get("weight_g") or 0)
def material_fit_score(conn, host, file_id):
"""Höher = besser. -1 nur bei zu wenig Rest. Falsche Sorte startet trotzdem."""
need, weight = _file_needs(conn, file_id)
have = _spool_material_set(conn, host)
if not need:
return 0, ""
if not have:
return 0, ""
have_types = {h["material_type"] for h in have if h["material_type"]}
need_types = {n["material_type"] for n in need if n["material_type"]}
# Falsche Sorte hält den Job nicht fest. Dateien warnt, der Start geht trotzdem.
score = 0 if (need_types and have_types and need_types.isdisjoint(have_types)) else 10
# Farbe + Rest
for n in need:
matched = [
h for h in have
if h["material_type"] == n["material_type"]
and (not n["color"] or h["color"] == n["color"] or not h["color"])
]
if matched:
score += 5
if weight and any(h["remaining_g"] + 1e-6 >= weight for h in matched):
score += 5
elif weight and all(h["remaining_g"] + 1e-6 < weight for h in matched):
return -1, "braucht %s (%s g fehlen)" % (
n["material_type"] + ((" " + n["color"]) if n["color"] else ""),
int(max(0, weight - max(h["remaining_g"] for h in matched))),
)
return score, ""
def next_waiting_print(conn, host=None, family=None): def next_waiting_print(conn, host=None, family=None):
"""Nächster wartender Druck: Termin (due_at) vor Priorität, Material-Match bevorzugt."""
if host: if host:
row = conn.execute( rows = conn.execute(
""" """
SELECT q.*, f.name AS filename SELECT q.*, f.name AS filename, o.due_at AS order_due_at
FROM print_queue q FROM print_queue q
LEFT JOIN files f ON f.id = q.file_id LEFT JOIN files f ON f.id = q.file_id
LEFT JOIN order_items oi ON oi.id = q.order_item_id
LEFT JOIN orders o ON o.id = oi.order_id
WHERE q.status = 'waiting' WHERE q.status = 'waiting'
AND (q.host = %s OR (q.host = '' AND (q.family = '' OR q.family = %s))) AND (q.host = %s OR (q.host = '' AND (q.family = '' OR q.family = %s)))
ORDER BY q.priority ASC, q.id ASC ORDER BY
LIMIT 1 CASE WHEN o.due_at IS NULL THEN 1 ELSE 0 END,
o.due_at ASC NULLS LAST,
q.priority ASC,
q.id ASC
LIMIT 20
""", """,
(host, family or ""), (host, family or ""),
).fetchone() ).fetchall()
else: else:
row = conn.execute( rows = conn.execute(
""" """
SELECT q.*, f.name AS filename SELECT q.*, f.name AS filename, o.due_at AS order_due_at
FROM print_queue q FROM print_queue q
LEFT JOIN files f ON f.id = q.file_id LEFT JOIN files f ON f.id = q.file_id
LEFT JOIN order_items oi ON oi.id = q.order_item_id
LEFT JOIN orders o ON o.id = oi.order_id
WHERE q.status = 'waiting' WHERE q.status = 'waiting'
ORDER BY q.priority ASC, q.id ASC ORDER BY
LIMIT 1 CASE WHEN o.due_at IS NULL THEN 1 ELSE 0 END,
o.due_at ASC NULLS LAST,
q.priority ASC,
q.id ASC
LIMIT 20
""", """,
).fetchone() ).fetchall()
return row if not rows:
return None
if not host:
return rows[0]
best = None
best_score = None
for row in rows:
score, reason = material_fit_score(conn, host, row.get("file_id"))
if score < 0:
# Wartegrund setzen, aber anderen Job bevorzugen
if (row.get("wait_reason") or "") != reason or (row.get("message") or "") != reason:
conn.execute(
"UPDATE print_queue SET wait_reason = %s, message = %s, updated_at = now() WHERE id = %s",
(reason, reason, row["id"]),
)
continue
if reason == "" and row.get("wait_reason"):
conn.execute(
"""
UPDATE print_queue
SET message = CASE WHEN message = wait_reason THEN '' ELSE message END,
wait_reason = '',
updated_at = now()
WHERE id = %s
""",
(row["id"],),
)
if best is None or score > best_score:
best = row
best_score = score
return best
def running_print_on_host(conn, host): def running_print_on_host(conn, host):
@@ -2633,7 +2798,10 @@ def ensure_bed_after_print(conn, host, ended_at=None, message=""):
Deckt verpasste Watcher-Übergänge ab (Neustart, Poll-Lücke): History und Deckt verpasste Watcher-Übergänge ab (Neustart, Poll-Lücke): History und
erster Blick auf state=complete setzen needs_clear nur, wenn cleared_at erster Blick auf state=complete setzen needs_clear nur, wenn cleared_at
vor dem Druckende liegt oder noch fehlt. vor dem Druckende liegt oder noch fehlt. Ohne Endzeit und mit bestehender
Quittierung nicht neu belegen — sonst setzt ein Neustart bereits
Quittiertes wieder (cleared_at geht verloren). Live-Übergänge setzen
needs_clear direkt per set_bed_needs_clear.
""" """
host = (host or "").strip() host = (host or "").strip()
if not host: if not host:
@@ -2647,6 +2815,9 @@ def ensure_bed_after_print(conn, host, ended_at=None, message=""):
cleared = row["cleared_at"] if row else None cleared = row["cleared_at"] if row else None
if ended_at is not None and cleared is not None and cleared >= ended_at: if ended_at is not None and cleared is not None and cleared >= ended_at:
return False return False
# Neustart / erster Blick ohne Endzeit: schon quittiert → stehen lassen
if ended_at is None and cleared is not None:
return False
set_bed_needs_clear(conn, host, True, message or "Druckende") set_bed_needs_clear(conn, host, True, message or "Druckende")
return True return True
@@ -2778,3 +2949,74 @@ def revoke_api_token(conn, token_id, user_id=None):
(token_id, user_id), (token_id, user_id),
).fetchone() ).fetchone()
def global_search(conn, query, limit=20):
"""Drucker, Dateien, Spulen, Chargen, Aufträge für Ctrl+K."""
q = (query or "").strip()
if not q:
return []
like = "%" + q + "%"
items = []
for row in conn.execute(
"SELECT host, name FROM printers WHERE name ILIKE %s OR host ILIKE %s ORDER BY name LIMIT 5",
(like, like),
).fetchall():
items.append({
"kind": "drucker",
"title": row["name"] or row["host"],
"subtitle": row["host"],
"href": "/#" + row["host"],
})
for row in conn.execute(
"SELECT id, name FROM files WHERE name ILIKE %s ORDER BY mtime DESC NULLS LAST, id DESC LIMIT 6",
(like,),
).fetchall():
items.append({
"kind": "datei",
"title": row["name"],
"subtitle": "Datei",
"href": "/dateien?file=" + str(row["id"]),
})
for row in conn.execute(
"""
SELECT id, name, chargen_nr, code, material_type, color
FROM spools
WHERE name ILIKE %s OR chargen_nr ILIKE %s OR code ILIKE %s
OR material_type ILIKE %s OR color ILIKE %s
ORDER BY id DESC LIMIT 6
""",
(like, like, like, like, like),
).fetchall():
items.append({
"kind": "spule",
"title": row["name"],
"subtitle": " · ".join(
x for x in [row.get("chargen_nr") or row.get("code"), row.get("material_type"), row.get("color")] if x
),
"href": "/material?spool=" + str(row["id"]),
})
if q.upper().startswith("D-") or q.upper().startswith("S-"):
code = q.upper()
run = conn.execute(
"SELECT id, chargen_nr, filename FROM runs WHERE chargen_nr ILIKE %s ORDER BY id DESC LIMIT 3",
(code + "%",),
).fetchall()
for row in run:
items.append({
"kind": "charge",
"title": row["chargen_nr"],
"subtitle": row.get("filename") or "Druck",
"href": "/protokoll/" + str(row["id"]),
})
for row in conn.execute(
"SELECT id, title, status FROM orders WHERE title ILIKE %s ORDER BY updated_at DESC LIMIT 5",
(like,),
).fetchall():
items.append({
"kind": "auftrag",
"title": row["title"],
"subtitle": row["status"],
"href": "/auftraege",
})
return items[: int(limit)]
+1 -1
View File
@@ -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.1.0" __version__ = "3.2.0"
+60 -20
View File
@@ -139,11 +139,22 @@ class StateWatcher:
) )
self._ensure_start_segment(conn, host, filename, live, run_id) self._ensure_start_segment(conn, host, filename, live, run_id)
elif prev_state == "printing" and state == "paused": elif prev_state == "printing" and state == "paused":
self._emit(conn, host, name, "start", "%s pausiert." % name, filename, run_id) reason = (live.get("pause_reason") or "").strip()
if live.get("filament_empty") or reason:
detail = reason or "Filament leer oder gerissen"
self._emit(
conn, host, name, "spule",
"%s pausiert — Material nachlegen (%s)." % (name, detail),
filename, run_id,
)
else:
self._emit(conn, host, name, "start", "%s pausiert." % name, filename, run_id)
elif prev_state == "paused" and state == "printing": elif prev_state == "paused" and state == "printing":
self._emit(conn, host, name, "start", "%s druckt weiter." % name, filename, run_id) self._emit(conn, host, name, "start", "%s druckt weiter." % name, filename, run_id)
elif prev_state == "printing" and state == "complete": elif prev_state == "printing" and state == "complete":
store.ensure_bed_after_print(conn, host, message=filename or "fertig") # Live-Übergang: immer belegen (ensure_bed_after_print ohne Endzeit
# würde nach Quittierung eines älteren Drucks stehen bleiben).
store.set_bed_needs_clear(conn, host, True, filename or "fertig")
self._emit( self._emit(
conn, conn,
host, host,
@@ -159,26 +170,37 @@ class StateWatcher:
jobs.sync_history(conn, self.cfg) jobs.sync_history(conn, self.cfg)
except Exception as exc: except Exception as exc:
print("Watcher Abgleich %s: %s" % (host, exc), flush=True) print("Watcher Abgleich %s: %s" % (host, exc), flush=True)
elif prev_state in ("printing", "paused") and state in ("cancelled", "standby"): elif state in ("cancelled", "standby"):
store.ensure_bed_after_print(conn, host, message=filename or state) # Live-Lauf schließen: normaler Abbruch, oder nach Offline/Neustart
self._emit( # (prev wird „offline“, der Übergang paused→standby geht sonst verloren).
conn, dangling = store.find_live_run(conn, host)
host, from_active = prev_state in ("printing", "paused")
name, recover = bool(dangling) and (prev_offline or not prev_state)
"abbruch", if from_active or recover:
"%s: Druck abgebrochen (%s)" % (name, filename or "ohne Datei"), file_label = filename or (dangling or {}).get("filename") or state
filename, store.set_bed_needs_clear(conn, host, True, file_label)
run_id or prev.get("run_id"), store.abandon_live_runs(conn, host)
) self._emit(
try: conn,
from grotti import jobs host,
name,
"abbruch",
"%s: Druck abgebrochen (%s)" % (
name, file_label if file_label != state else "ohne Datei",
),
file_label if file_label != state else "",
run_id or prev.get("run_id") or (dangling or {}).get("id"),
)
try:
from grotti import jobs
jobs.sync_history(conn, self.cfg) jobs.sync_history(conn, self.cfg)
except Exception as exc: except Exception as exc:
print("Watcher Abgleich %s: %s" % (host, exc), flush=True) print("Watcher Abgleich %s: %s" % (host, exc), flush=True)
elif state == "error" and prev_state != "error": elif state == "error" and prev_state != "error":
if prev_state == "printing" or prev_state == "paused": if prev_state == "printing" or prev_state == "paused":
store.ensure_bed_after_print(conn, host, message=filename or "Fehler") store.set_bed_needs_clear(conn, host, True, filename or "Fehler")
store.abandon_live_runs(conn, host)
msg = (live.get("message") or "").strip() msg = (live.get("message") or "").strip()
if not msg or msg.lower() in ("error", "fehler"): if not msg or msg.lower() in ("error", "fehler"):
msg = "Ursache unbekannt — in Mainsail prüfen" msg = "Ursache unbekannt — in Mainsail prüfen"
@@ -202,7 +224,25 @@ class StateWatcher:
if (live.get("nozzle_target") or 0) > 0 or (live.get("bed_target") or 0) > 0: if (live.get("nozzle_target") or 0) > 0 or (live.get("bed_target") or 0) > 0:
self._cool_heaters(host) self._cool_heaters(host)
elif state == "complete" and (not prev_state or prev_offline): elif state == "complete" and (not prev_state or prev_offline):
store.ensure_bed_after_print(conn, host, message=filename or "fertig") # Erster Blick nach Neustart: nur mit Endzeit des letzten Laufs,
# sonst wird bereits Quittiertes wieder „Platte belegt“.
ended = conn.execute(
"""
SELECT ended_at FROM runs
WHERE printer_host = %s
AND state IN ('completed', 'cancelled', 'error', 'interrupted')
AND ended_at IS NOT NULL
ORDER BY ended_at DESC
LIMIT 1
""",
(host,),
).fetchone()
store.ensure_bed_after_print(
conn,
host,
ended_at=ended["ended_at"] if ended else None,
message=filename or "fertig",
)
spool_warn = bool(prev.get("spool_warn")) spool_warn = bool(prev.get("spool_warn"))
if state == "printing" and not spool_warn: if state == "printing" and not spool_warn:
+207
View File
@@ -0,0 +1,207 @@
"""Lucide-Icons als lokales SVG-Sprite bauen.
python scripts/build_icons.py
Schreibt web/static/icons.svg. IDs bleiben i-<grotti-name> (bestehende Templates).
"""
from __future__ import annotations
import re
import urllib.error
import urllib.request
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
OUT = ROOT / "web" / "static" / "icons.svg"
CDN = "https://unpkg.com/[email protected]/icons/{name}.svg"
# grotti-id → lucide dateiname (ohne .svg)
ICONS: dict[str, str] = {
# bestehend
"printer": "printer",
"camera": "camera",
"file": "file",
"spool": "disc-3",
"folder": "folder",
"folder-open": "folder-open",
"list": "list",
"activity": "activity",
"clock": "clock",
"check": "check",
"alert": "triangle-alert",
"flame": "flame",
"pause": "pause",
"play": "play",
"stop": "square",
"layers": "layers",
"x": "x",
"up": "chevron-up",
"down": "chevron-down",
"upload": "upload",
"refresh": "refresh-cw",
"queue": "list-ordered",
"send": "send",
"book": "book-open",
"cube": "box",
"bell": "bell",
"sweep": "brush",
"thermo": "thermometer",
"settings": "settings",
"bulb": "lightbulb",
"search": "search",
"filter": "filter",
"info": "info",
"scan": "scan-line",
"trash": "trash-2",
"inbox": "inbox",
"image": "image",
"grid": "layout-grid",
"menu": "menu",
"panel": "panel-left",
"nozzle": "pipette",
"bed": "rectangle-horizontal",
"chamber": "box",
"fan": "fan",
"fan-chamber": "air-vent",
"fan-aux": "wind",
# neu für Polish
"plus": "plus",
"minus": "minus",
"edit": "pencil",
"save": "save",
"copy": "copy",
"external": "external-link",
"user": "user",
"users": "users",
"key": "key",
"lock": "lock",
"unlock": "lock-open",
"wifi": "wifi",
"wifi-off": "wifi-off",
"power": "power",
"zap": "zap",
"gauge": "gauge",
"chart": "chart-column",
"calendar": "calendar",
"timer": "timer",
"weight": "weight",
"package": "package",
"tag": "tag",
"hash": "hash",
"link": "link",
"download": "download",
"more": "ellipsis",
"chevron-right": "chevron-right",
"chevron-left": "chevron-left",
"home": "house",
"wrench": "wrench",
"hard-drive": "hard-drive",
"server": "server",
"message": "message-circle",
"mail": "mail",
"shield": "shield",
"sliders": "sliders-horizontal",
"palette": "palette",
"moon": "moon",
"sun": "sun",
"monitor": "monitor",
"smartphone": "smartphone",
"keyboard": "keyboard",
"undo": "undo-2",
"redo": "redo-2",
"circle-check": "circle-check",
"circle-x": "circle-x",
"circle-alert": "circle-alert",
"loader": "loader-circle",
"sparkles": "sparkles",
"clipboard": "clipboard-list",
"receipt": "receipt",
"truck": "truck",
"droplet": "droplet",
"percent": "percent",
"euro": "euro",
"arrow-up": "arrow-up",
"arrow-down": "arrow-down",
"history": "history",
"eye": "eye",
"eye-off": "eye-off",
"star": "star",
"heart": "heart",
"ban": "ban",
"rotate-ccw": "rotate-ccw",
"brush": "brush",
}
def fetch(name: str) -> str:
url = CDN.format(name=name)
req = urllib.request.Request(url, headers={"User-Agent": "Grotti-icon-build/1"})
with urllib.request.urlopen(req, timeout=30) as resp:
return resp.read().decode("utf-8")
def to_symbol(grotti: str, svg: str) -> str:
# Lucide: optional comment + <svg ...>paths</svg>
text = re.sub(r"<!--.*?-->", "", svg, flags=re.DOTALL).strip()
match = re.search(r"<svg\b[^>]*>(.*)</svg>\s*$", text, flags=re.DOTALL | re.IGNORECASE)
if not match:
raise ValueError("kein SVG-Root")
inner = match.group(1).strip()
# verschachtelte svg-Attribute/Klassen aus Kind-Elementen nicht nötig
return (
f' <symbol id="i-{grotti}" viewBox="0 0 24 24" fill="none" '
f'stroke="currentColor" stroke-width="2" stroke-linecap="round" '
f'stroke-linejoin="round">\n {inner}\n </symbol>'
)
def main() -> None:
symbols = []
failed = []
for grotti, lucide in sorted(ICONS.items()):
try:
raw = fetch(lucide)
symbols.append(to_symbol(grotti, raw))
print("ok", grotti, "<-", lucide)
except urllib.error.HTTPError as exc:
failed.append((grotti, lucide, str(exc)))
print("FAIL", grotti, lucide, exc.code)
except Exception as exc:
failed.append((grotti, lucide, str(exc)))
print("FAIL", grotti, lucide, type(exc).__name__)
if failed and len(failed) == len(ICONS):
raise SystemExit("Keine Icons geladen.")
# Fallbacks für fehlgeschlagene: leeres symbol vermeiden — check als Ersatz
ok_ids = {s.split('id="i-')[1].split('"')[0] for s in symbols}
for grotti, lucide, _ in failed:
if grotti in ok_ids:
continue
# einfaches X als Platzhalter
symbols.append(
f' <symbol id="i-{grotti}" viewBox="0 0 24 24" fill="none" '
f'stroke="currentColor" stroke-width="2" stroke-linecap="round" '
f'stroke-linejoin="round">\n'
f' <path d="M18 6 6 18"/><path d="m6 6 12 12"/>\n </symbol>'
)
print("placeholder", grotti)
body = "\n".join(symbols)
OUT.parent.mkdir(parents=True, exist_ok=True)
OUT.write_text(
'<?xml version="1.0" encoding="UTF-8"?>\n'
f'<!-- Lucide icons, built by scripts/build_icons.py — {len(symbols)} symbols -->\n'
'<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">\n'
f"{body}\n"
"</svg>\n",
encoding="utf-8",
)
print("wrote", OUT, "symbols", len(symbols))
if failed:
print("warnings:", len(failed))
if __name__ == "__main__":
main()
+8
View File
@@ -64,6 +64,10 @@ def main():
dispatcher.start() dispatcher.start()
if hasattr(send_queue, "start"): if hasattr(send_queue, "start"):
send_queue.start() send_queue.start()
from grotti.daily_report import DailyReportThread
daily = DailyReportThread(cfg["database_url"], cfg, watcher=watcher)
daily.start()
app.extensions["daily_report"] = daily
stop_event = threading.Event() stop_event = threading.Event()
retention = threading.Thread( retention = threading.Thread(
@@ -90,6 +94,10 @@ def main():
send_queue.stop() send_queue.stop()
except Exception: except Exception:
pass pass
try:
daily.stop()
except Exception:
pass
db.close_pool() db.close_pool()
atexit.register(shutdown) atexit.register(shutdown)
+2
View File
@@ -48,6 +48,7 @@ class CameraFieldsTest(unittest.TestCase):
moonraker.CAMERA_CACHE.clear() moonraker.CAMERA_CACHE.clear()
fields = moonraker._camera_fields("10.0.0.1", "10.0.0.1") fields = moonraker._camera_fields("10.0.0.1", "10.0.0.1")
self.assertTrue(fields["camera"]) self.assertTrue(fields["camera"])
self.assertIn("/api/v1/kamera/stream?", fields["camera_stream"])
self.assertIsNone(fields["has_camera"]) self.assertIsNone(fields["has_camera"])
def test_known_dead_hides_url(self): def test_known_dead_hides_url(self):
@@ -56,6 +57,7 @@ class CameraFieldsTest(unittest.TestCase):
moonraker.CAMERA_CACHE["10.0.0.2"] = (time.time(), 30, None) moonraker.CAMERA_CACHE["10.0.0.2"] = (time.time(), 30, None)
fields = moonraker._camera_fields("10.0.0.2", "10.0.0.2") fields = moonraker._camera_fields("10.0.0.2", "10.0.0.2")
self.assertEqual(fields["camera"], "") self.assertEqual(fields["camera"], "")
self.assertEqual(fields["camera_stream"], "")
self.assertFalse(fields["has_camera"]) self.assertFalse(fields["has_camera"])
moonraker.CAMERA_CACHE.pop("10.0.0.2", None) moonraker.CAMERA_CACHE.pop("10.0.0.2", None)
+16
View File
@@ -46,6 +46,22 @@ class EnergyEdTests(unittest.TestCase):
self.assertAlmostEqual(est["print_kwh"], 0.2, places=3) self.assertAlmostEqual(est["print_kwh"], 0.2, places=3)
self.assertAlmostEqual(est["cost"], 0.4, places=3) self.assertAlmostEqual(est["cost"], 0.4, places=3)
def test_ed_parts_split_base_nozzle_bed(self):
samples = [sample_at(i * 30, np=1.0, bp=0.5) for i in range(0, 121)]
est = energy_estimate(3600, {
"watts": 40,
"nozzle_watts": 60,
"bed_watts": 220,
"euro_per_kwh": 0.35,
}, samples)
self.assertEqual(est["source"], "ed")
self.assertAlmostEqual(est["base_kwh"], 0.04, places=3)
self.assertAlmostEqual(est["nozzle_kwh"], 0.06, places=3)
self.assertAlmostEqual(est["bed_kwh"], 0.11, places=3)
keys = {p["key"] for p in est["parts"]}
self.assertEqual(keys, {"base", "nozzle", "bed"})
self.assertAlmostEqual(sum(p["kwh"] for p in est["parts"]), est["kwh"], places=6)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+48
View File
@@ -0,0 +1,48 @@
"""Ops-Alerts: Dedup-Art muss zum gespeicherten Event-kind passen."""
import unittest
from unittest.mock import MagicMock, patch
from grotti.daily_report import push_ops_alerts
class OpsAlertDedupeTests(unittest.TestCase):
def test_bedarf_records_matching_kind(self):
conn = MagicMock()
cfg = {"alerts": {"queue": True}}
shortfall = [{
"material_type": "PETG-CF",
"color": "#161616",
"shortfall_g": 15,
}]
with patch("grotti.daily_report.jetzt_mod.overdue_orders", return_value=[]), \
patch("grotti.daily_report.jetzt_mod.overdue_maintenance", return_value=[]), \
patch("grotti.daily_report.bedarf_mod.compute", return_value={"shortfall": shortfall}), \
patch("grotti.daily_report.store.list_printers", return_value=[]), \
patch("grotti.daily_report.store.recent_event_kind", return_value=False) as recent, \
patch("grotti.daily_report.notify.record") as record:
push_ops_alerts(conn, cfg, printers=[])
recent.assert_called_with(
conn, "bedarf:PETG-CF:#161616", "bedarf", within_s=21600,
)
record.assert_called_once()
args = record.call_args
self.assertEqual(args.args[4], "bedarf")
self.assertIn("15 g fehlen", args.args[5])
def test_bedarf_skips_when_recent(self):
conn = MagicMock()
cfg = {"alerts": {"queue": True}}
shortfall = [{"material_type": "PLA", "color": "", "shortfall_g": 10}]
with patch("grotti.daily_report.jetzt_mod.overdue_orders", return_value=[]), \
patch("grotti.daily_report.jetzt_mod.overdue_maintenance", return_value=[]), \
patch("grotti.daily_report.bedarf_mod.compute", return_value={"shortfall": shortfall}), \
patch("grotti.daily_report.store.list_printers", return_value=[]), \
patch("grotti.daily_report.store.recent_event_kind", return_value=True), \
patch("grotti.daily_report.notify.record") as record:
push_ops_alerts(conn, cfg, printers=[])
record.assert_not_called()
if __name__ == "__main__":
unittest.main()
+43
View File
@@ -0,0 +1,43 @@
"""Filament-Pause-Heuristik aus ACE/mmu und Meldungen."""
import unittest
from grotti.moonraker import filament_out_hint
class FilamentOutHintTests(unittest.TestCase):
def test_message_10107(self):
code, text = filament_out_hint({}, "Report code 10107")
self.assertEqual(code, "message")
self.assertIn("10107", text)
def test_active_filament_empty(self):
code, text = filament_out_hint({"active_filament": {"empty": True}})
self.assertEqual(code, "active_empty")
self.assertIn("leer", text.lower())
def test_gate_status_zero_for_tool(self):
code, text = filament_out_hint({
"tool": 2,
"gate_status": [1, 1, 0, 1],
"active_filament": {"type": "PLA"},
})
self.assertEqual(code, "gate_empty")
self.assertIn("3", text)
def test_mmu_paused_without_active_type(self):
code, text = filament_out_hint({
"is_paused": True,
"active_filament": {},
})
self.assertEqual(code, "mmu_paused")
self.assertTrue(text)
def test_no_hint_for_plain_pause(self):
code, text = filament_out_hint({"tool": 0, "gate_status": [1, 1]}, "paused")
self.assertIsNone(code)
self.assertIsNone(text)
if __name__ == "__main__":
unittest.main()
+7 -3
View File
@@ -38,12 +38,16 @@ class DispatcherHelpers(unittest.TestCase):
class ResolveSpoolTests(unittest.TestCase): class ResolveSpoolTests(unittest.TestCase):
def test_material_match(self): def test_material_match(self):
from grotti.jobs import assert_material_match from grotti.jobs import assert_material_match
from grotti.errors import GrottiError
file_row = {"filaments": [{"type": "PLA"}]} file_row = {"filaments": [{"type": "PLA"}]}
assert_material_match(file_row, [{"material_type": "PLA"}]) assert_material_match(file_row, [{"material_type": "PLA"}])
with self.assertRaises(GrottiError): assert_material_match(file_row, [{"material_type": "PETG"}])
assert_material_match(file_row, [{"material_type": "PETG"}])
def test_ace_type_for_pa12(self):
from grotti.catalog import ace_material_type
self.assertEqual(ace_material_type("PA12-CF"), "PA6-CF")
self.assertEqual(ace_material_type("PETG"), "PETG")
if __name__ == "__main__": if __name__ == "__main__":
+154 -8
View File
@@ -11,7 +11,7 @@ from urllib.parse import quote
import markdown import markdown
import psycopg import psycopg
from flask import Flask, abort, g, jsonify, redirect, render_template, request, send_file, session, url_for from flask import Flask, Response, abort, g, jsonify, redirect, render_template, request, send_file, session, stream_with_context, url_for
from markupsafe import Markup from markupsafe import Markup
from psycopg.rows import dict_row from psycopg.rows import dict_row
from werkzeug.middleware.proxy_fix import ProxyFix from werkzeug.middleware.proxy_fix import ProxyFix
@@ -44,6 +44,7 @@ UPLOAD_SUFFIXES = (".gcode", ".3mf", ".gcode.3mf")
HOST_RE = re.compile(r"^[A-Za-z0-9.\-]+(?::\d{1,5})?$") HOST_RE = re.compile(r"^[A-Za-z0-9.\-]+(?::\d{1,5})?$")
TOKEN_QUERY_PATHS = ( TOKEN_QUERY_PATHS = (
"/api/v1/kamera", "/api/v1/kamera",
"/api/v1/kamera/stream",
"/api/v1/vorschau", "/api/v1/vorschau",
) )
STEER_ACTIONS = { STEER_ACTIONS = {
@@ -243,10 +244,10 @@ def create_app(cfg):
if path == "/api/v1/einstellungen" and request.method == "GET": if path == "/api/v1/einstellungen" and request.method == "GET":
need_admin = False need_admin = False
elif path == "/api/v1/einstellungen" and request.method == "PATCH": elif path == "/api/v1/einstellungen" and request.method == "PATCH":
# Operator darf nur auto_queue schalten # Operator darf nur Automatik-Schalter setzen
payload = request.get_json(silent=True) or {} payload = request.get_json(silent=True) or {}
keys = {k for k, v in payload.items() if v is not None} keys = {k for k, v in payload.items() if v is not None}
need_admin = keys - {"auto_queue"} != set() need_admin = keys - {"auto_queue", "auto_retry"} != set()
elif path.startswith("/api/v1/users"): elif path.startswith("/api/v1/users"):
need_admin = True need_admin = True
elif request.method != "GET": elif request.method != "GET":
@@ -340,6 +341,24 @@ def create_app(cfg):
require_host(host) require_host(host)
return host return host
_icons_cache = {"html": "", "mtime": None}
def icons_sprite_html():
path = ROOT / "static" / "icons.svg"
try:
mtime = path.stat().st_mtime
except OSError:
return ""
if _icons_cache["html"] and _icons_cache["mtime"] == mtime:
return _icons_cache["html"]
text = path.read_text(encoding="utf-8")
text = re.sub(r"<\?xml[^?]*\?>\s*", "", text)
text = re.sub(r"<!--.*?-->\s*", "", text, count=1, flags=re.DOTALL)
text = text.replace("<svg ", '<svg width="0" height="0" style="position:absolute" aria-hidden="true" ', 1)
_icons_cache["html"] = text
_icons_cache["mtime"] = mtime
return text
@app.context_processor @app.context_processor
def inject_globals(): def inject_globals():
user = auth_user() user = auth_user()
@@ -347,6 +366,7 @@ def create_app(cfg):
"version": __version__, "version": __version__,
"csrf": session.get("csrf") or "", "csrf": session.get("csrf") or "",
"current_user": user, "current_user": user,
"icons_sprite": Markup(icons_sprite_html()),
} }
def page(name, title): def page(name, title):
@@ -732,6 +752,10 @@ def create_app(cfg):
def ideen(): def ideen():
return redirect("/auftraege", code=301) return redirect("/auftraege", code=301)
@app.get("/jetzt")
def jetzt_page():
return page("jetzt.html", "Jetzt")
@app.get("/aktivitaet") @app.get("/aktivitaet")
def aktivitaet(): def aktivitaet():
return page("aktivitaet.html", "Aktivität") return page("aktivitaet.html", "Aktivität")
@@ -791,6 +815,7 @@ def create_app(cfg):
ntfy = data.get("ntfy") if isinstance(data.get("ntfy"), dict) else None ntfy = data.get("ntfy") if isinstance(data.get("ntfy"), dict) else None
alerts = data.get("alerts") if isinstance(data.get("alerts"), dict) else None alerts = data.get("alerts") if isinstance(data.get("alerts"), dict) else None
auto_queue = data.get("auto_queue") if "auto_queue" in data else None auto_queue = data.get("auto_queue") if "auto_queue" in data else None
auto_retry = data.get("auto_retry") if "auto_retry" in data else None
fqdn = data.get("fqdn") if "fqdn" in data else None fqdn = data.get("fqdn") if "fqdn" in data else None
watch_dir = data.get("watch_dir") if "watch_dir" in data else None watch_dir = data.get("watch_dir") if "watch_dir" in data else None
return jsonify(grotti_config.save_settings( return jsonify(grotti_config.save_settings(
@@ -800,6 +825,7 @@ def create_app(cfg):
ntfy=ntfy, ntfy=ntfy,
alerts=alerts, alerts=alerts,
auto_queue=auto_queue, auto_queue=auto_queue,
auto_retry=auto_retry,
fqdn=fqdn, fqdn=fqdn,
watch_dir=watch_dir, watch_dir=watch_dir,
)) ))
@@ -956,6 +982,40 @@ def create_app(cfg):
abort(404) abort(404)
return image[1], 200, {"Content-Type": image[0], "Cache-Control": "no-store"} return image[1], 200, {"Content-Type": image[0], "Cache-Control": "no-store"}
@app.get("/api/v1/kamera/stream")
def camera_stream():
"""MJPEG vom Drucker durchreichen (kein Frame-Cache)."""
host = require_host(request.args.get("host", ""))
try:
upstream = moonraker.open_camera_stream(host)
except Exception:
abort(404)
ctype = upstream.headers.get("Content-Type") or "multipart/x-mixed-replace; boundary=--"
@stream_with_context
def generate():
try:
while True:
chunk = upstream.read(16384)
if not chunk:
break
yield chunk
finally:
try:
upstream.close()
except Exception:
pass
return Response(
generate(),
status=200,
headers={
"Content-Type": ctype,
"Cache-Control": "no-store",
"X-Accel-Buffering": "no",
},
)
@app.get("/api/v1/vorschau") @app.get("/api/v1/vorschau")
def live_preview(): def live_preview():
host = require_host(request.args.get("host", "")) host = require_host(request.args.get("host", ""))
@@ -1371,6 +1431,62 @@ def create_app(cfg):
}) })
return jsonify(items) return jsonify(items)
@app.get("/api/v1/jetzt")
def jetzt_api():
"""Priorisierte Handlungsliste, laufende Drucke und Farm-ETA."""
from grotti import jetzt as jetzt_mod
send_jobs = []
try:
send_jobs = app.extensions["send_queue"].list_jobs() or []
except Exception:
send_jobs = []
return jsonify(jetzt_mod.build(
conn(),
app.extensions["watcher"].latest or [],
printers=store.list_printers(conn()),
send_jobs=send_jobs,
))
@app.get("/api/v1/prognose")
def prognose_api():
"""Wann welcher Drucker frei ist und wann alles fertig ist."""
from grotti import jetzt as jetzt_mod
running = []
for live in app.extensions["watcher"].latest or []:
state = (live.get("state") or "").lower()
if state not in ("printing", "paused", "heating"):
continue
running.append({
"host": live.get("host") or "",
"name": live.get("name") or "",
"remain": live.get("remain") or 0,
"progress": live.get("progress") or 0,
"filename": live.get("filename") or "",
"state": state,
"free_at": None,
})
eta = jetzt_mod.farm_eta(running)
for item in running:
rem = float(item.get("remain") or 0)
item["free_at"] = jetzt_mod._iso_remain(rem) if rem > 0 else None
waiting = [j for j in store.list_print_queue(conn(), limit=80) if j.get("status") == "waiting"]
return jsonify({
"running": running,
"waiting_count": len(waiting),
"eta": eta,
})
@app.get("/api/v1/suche")
def suche_api():
"""Globale Suche für Ctrl+K."""
q = (request.args.get("q") or "").strip()
if len(q) < 1:
return jsonify({"items": []})
items = store.global_search(conn(), q, limit=20)
return jsonify({"items": items})
@app.post("/api/v1/druck-queue") @app.post("/api/v1/druck-queue")
def print_queue_create(): def print_queue_create():
data = body() data = body()
@@ -1445,8 +1561,8 @@ def create_app(cfg):
c = conn() c = conn()
store.set_bed_needs_clear(c, host, False, "quittiert") store.set_bed_needs_clear(c, host, False, "quittiert")
run_json = None run_json = None
run_row = store.run_for_abraeumen_verdict(c, host)
if any(k in data for k in ("verdict", "verdict_reason", "pieces_ok", "pieces_bad")): if any(k in data for k in ("verdict", "verdict_reason", "pieces_ok", "pieces_bad")):
run_row = store.run_for_abraeumen_verdict(c, host)
if not run_row: if not run_row:
raise GrottiError("Kein Lauf zum Bewerten — Protokoll prüfen.", "lauf", 404) raise GrottiError("Kein Lauf zum Bewerten — Protokoll prüfen.", "lauf", 404)
try: try:
@@ -1459,12 +1575,31 @@ def create_app(cfg):
except ValueError as exc: except ValueError as exc:
raise GrottiError(str(exc), "urteil") from exc raise GrottiError(str(exc), "urteil") from exc
run_json = store.set_verdict(c, run_row["id"], verdict, reason, ok_n, bad_n) run_json = store.set_verdict(c, run_row["id"], verdict, reason, ok_n, bad_n)
reprint = None
if bool(data.get("reprint")) and run_row and run_row.get("file_id"):
reprint = store.enqueue_print(
c,
run_row["file_id"],
host,
"",
50,
run_row.get("spool_id"),
[],
False,
False,
)
store.add_event(
c, host, "", "queue",
"Nochmal eingereiht: %s" % (run_row.get("filename") or run_row["file_id"]),
run_row.get("filename") or "",
)
return jsonify({ return jsonify({
"ok": True, "ok": True,
"host": host, "host": host,
"needs_clear": False, "needs_clear": False,
"message": "Platte quittiert.", "message": "Platte quittiert." + (" Nochmal in der Warteschlange." if reprint else ""),
"run": run_json, "run": run_json,
"reprint": reprint,
"verdict_reasons": VERDICT_REASONS, "verdict_reasons": VERDICT_REASONS,
}) })
@@ -1499,7 +1634,12 @@ def create_app(cfg):
if status == "running" and not data.get("resume"): if status == "running" and not data.get("resume"):
raise GrottiError("Nur ein gestoppter Ablauf lässt sich fortsetzen.", "status") raise GrottiError("Nur ein gestoppter Ablauf lässt sich fortsetzen.", "status")
if status == "waiting" and current_row.get("status") not in ("waiting",): if status == "waiting" and current_row.get("status") not in ("waiting",):
raise GrottiError("Status lässt sich so nicht setzen.", "status") # Undo: abgebrochen/entfernt wieder einreihen
if not (data.get("undo") and current_row.get("status") in ("cancelled", "dismissed")):
raise GrottiError("Status lässt sich so nicht setzen.", "status")
fields["message"] = ""
fields["phase"] = ""
fields["phase_started_at"] = None
fields["status"] = status fields["status"] = status
if status == "running" and data.get("resume"): if status == "running" and data.get("resume"):
row = current_row row = current_row
@@ -1557,11 +1697,14 @@ def create_app(cfg):
@app.get("/api/v1/ereignisse") @app.get("/api/v1/ereignisse")
def events_list(): def events_list():
unread = request.args.get("unread") == "1" unread = request.args.get("unread") == "1"
alerts_only = request.args.get("alerts") == "1"
try: try:
limit = max(1, min(1000, int(request.args.get("limit") or 40))) limit = max(1, min(1000, int(request.args.get("limit") or 40)))
except (TypeError, ValueError): except (TypeError, ValueError):
limit = 40 limit = 40
kinds = [k for k in (request.args.get("kind") or "").split(",") if k.strip()] kinds = [k for k in (request.args.get("kind") or "").split(",") if k.strip()]
if alerts_only and not kinds:
kinds = list(store.ALERT_EVENT_KINDS)
since = None since = None
if request.args.get("since"): if request.args.get("since"):
try: try:
@@ -1577,7 +1720,10 @@ def create_app(cfg):
kinds=kinds or None, kinds=kinds or None,
since=since, since=since,
), ),
"unread": store.unread_event_count(conn()), "unread": store.unread_event_count(
conn(),
kinds=list(store.ALERT_EVENT_KINDS) if alerts_only or unread else None,
),
}) })
@app.post("/api/v1/ereignisse/gelesen") @app.post("/api/v1/ereignisse/gelesen")
@@ -1589,7 +1735,7 @@ def create_app(cfg):
ids = [int(item) for item in ids] ids = [int(item) for item in ids]
except (TypeError, ValueError) as exc: except (TypeError, ValueError) as exc:
raise GrottiError("IDs müssen Zahlen sein.", "id") from exc raise GrottiError("IDs müssen Zahlen sein.", "id") from exc
unread = store.mark_events_read(conn(), ids) unread = store.mark_events_read(conn(), ids, kinds=list(store.ALERT_EVENT_KINDS) if not ids else None)
return jsonify({"unread": unread}) return jsonify({"unread": unread})
@app.post("/api/v1/dateien/von-drucker") @app.post("/api/v1/dateien/von-drucker")
+31 -2
View File
@@ -79,6 +79,34 @@ function withToken(url) {
return url; return url;
} }
const CAMERA_MODE_KEY = "grotti-camera";
function cameraMode() {
try {
return localStorage.getItem(CAMERA_MODE_KEY) === "stream" ? "stream" : "snapshot";
} catch (error) {
return "snapshot";
}
}
function setCameraMode(mode) {
const value = mode === "stream" ? "stream" : "snapshot";
try { localStorage.setItem(CAMERA_MODE_KEY, value); } catch (error) {}
window.dispatchEvent(new CustomEvent("grotti-camera-mode", { detail: value }));
return value;
}
/** Snapshot oder Stream-URL je nach Browser-Einstellung. bustMs nur für Snapshot. */
function cameraUrl(printer, bustMs) {
if (!printer || !printer.camera) return "";
if (cameraMode() === "stream" && printer.camera_stream) {
return withToken(printer.camera_stream);
}
let url = withToken(printer.camera);
if (bustMs) url += "&t=" + Math.floor(Date.now() / bustMs);
return url;
}
function euro(value) { function euro(value) {
return Number(value || 0).toLocaleString("de-DE", { style: "currency", currency: "EUR" }); return Number(value || 0).toLocaleString("de-DE", { style: "currency", currency: "EUR" });
} }
@@ -247,9 +275,10 @@ function icon(name, cls) {
return `<svg class="ico${cls ? " " + cls : ""}" aria-hidden="true"><use href="#i-${name}"/></svg>`; return `<svg class="ico${cls ? " " + cls : ""}" aria-hidden="true"><use href="#i-${name}"/></svg>`;
} }
function chipHtml(state, label, small) { function chipHtml(state, label, small, iconName) {
const text = label || STATE_LABELS[state] || state || ""; const text = label || STATE_LABELS[state] || state || "";
return `<span class="chip ${escapeHtml(state || "")}${small ? " sm" : ""}">${escapeHtml(text)}</span>`; const ico = iconName ? icon(iconName, "chip-ico") : "";
return `<span class="chip ${escapeHtml(state || "")}${small ? " sm" : ""}">${ico}${escapeHtml(text)}</span>`;
} }
function shownState(printer) { function shownState(printer) {
Binary file not shown.
+92
View File
@@ -0,0 +1,92 @@
Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION AND CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+881 -148
View File
File diff suppressed because it is too large. Load diff
+528
View File
@@ -0,0 +1,528 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Lucide icons, built by scripts/build_icons.py — 111 symbols -->
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<symbol id="i-activity" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M22 12h-2.48a2 2 0 0 0-1.93 1.46l-2.35 8.36a.25.25 0 0 1-.48 0L9.24 2.18a.25.25 0 0 0-.48 0l-2.35 8.36A2 2 0 0 1 4.49 12H2" />
</symbol>
<symbol id="i-alert" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3" />
<path d="M12 9v4" />
<path d="M12 17h.01" />
</symbol>
<symbol id="i-arrow-down" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 5v14" />
<path d="m19 12-7 7-7-7" />
</symbol>
<symbol id="i-arrow-up" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="m5 12 7-7 7 7" />
<path d="M12 19V5" />
</symbol>
<symbol id="i-ban" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10" />
<path d="m4.9 4.9 14.2 14.2" />
</symbol>
<symbol id="i-bed" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect width="20" height="12" x="2" y="6" rx="2" />
</symbol>
<symbol id="i-bell" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M10.268 21a2 2 0 0 0 3.464 0" />
<path d="M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326" />
</symbol>
<symbol id="i-book" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 7v14" />
<path d="M3 18a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h5a4 4 0 0 1 4 4 4 4 0 0 1 4-4h5a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1h-6a3 3 0 0 0-3 3 3 3 0 0 0-3-3z" />
</symbol>
<symbol id="i-brush" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="m9.06 11.9 8.07-8.06a2.85 2.85 0 1 1 4.03 4.03l-8.06 8.08" />
<path d="M7.07 14.94c-1.66 0-3 1.35-3 3.02 0 1.33-2.5 1.52-2 2.02 1.08 1.1 2.49 2.02 4 2.02 2.2 0 4-1.8 4-4.04a3.01 3.01 0 0 0-3-3.02z" />
</symbol>
<symbol id="i-bulb" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M15 14c.2-1 .7-1.7 1.5-2.5 1-.9 1.5-2.2 1.5-3.5A6 6 0 0 0 6 8c0 1 .2 2.2 1.5 3.5.7.7 1.3 1.5 1.5 2.5" />
<path d="M9 18h6" />
<path d="M10 22h4" />
</symbol>
<symbol id="i-calendar" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M8 2v4" />
<path d="M16 2v4" />
<rect width="18" height="18" x="3" y="4" rx="2" />
<path d="M3 10h18" />
</symbol>
<symbol id="i-camera" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M14.5 4h-5L7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-3l-2.5-3z" />
<circle cx="12" cy="13" r="3" />
</symbol>
<symbol id="i-chamber" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z" />
<path d="m3.3 7 8.7 5 8.7-5" />
<path d="M12 22V12" />
</symbol>
<symbol id="i-chart" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 3v16a2 2 0 0 0 2 2h16" />
<path d="M18 17V9" />
<path d="M13 17V5" />
<path d="M8 17v-3" />
</symbol>
<symbol id="i-check" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M20 6 9 17l-5-5" />
</symbol>
<symbol id="i-chevron-left" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="m15 18-6-6 6-6" />
</symbol>
<symbol id="i-chevron-right" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="m9 18 6-6-6-6" />
</symbol>
<symbol id="i-circle-alert" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10" />
<line x1="12" x2="12" y1="8" y2="12" />
<line x1="12" x2="12.01" y1="16" y2="16" />
</symbol>
<symbol id="i-circle-check" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10" />
<path d="m9 12 2 2 4-4" />
</symbol>
<symbol id="i-circle-x" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10" />
<path d="m15 9-6 6" />
<path d="m9 9 6 6" />
</symbol>
<symbol id="i-clipboard" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect width="8" height="4" x="8" y="2" rx="1" ry="1" />
<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2" />
<path d="M12 11h4" />
<path d="M12 16h4" />
<path d="M8 11h.01" />
<path d="M8 16h.01" />
</symbol>
<symbol id="i-clock" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10" />
<polyline points="12 6 12 12 16 14" />
</symbol>
<symbol id="i-copy" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect width="14" height="14" x="8" y="8" rx="2" ry="2" />
<path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" />
</symbol>
<symbol id="i-cube" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z" />
<path d="m3.3 7 8.7 5 8.7-5" />
<path d="M12 22V12" />
</symbol>
<symbol id="i-down" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="m6 9 6 6 6-6" />
</symbol>
<symbol id="i-download" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7 10 12 15 17 10" />
<line x1="12" x2="12" y1="15" y2="3" />
</symbol>
<symbol id="i-droplet" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 22a7 7 0 0 0 7-7c0-2-1-3.9-3-5.5s-3.5-4-4-6.5c-.5 2.5-2 4.9-4 6.5C6 11.1 5 13 5 15a7 7 0 0 0 7 7z" />
</symbol>
<symbol id="i-edit" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z" />
<path d="m15 5 4 4" />
</symbol>
<symbol id="i-euro" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M4 10h12" />
<path d="M4 14h9" />
<path d="M19 6a7.7 7.7 0 0 0-5.2-2A7.9 7.9 0 0 0 6 12c0 4.4 3.5 8 7.8 8 2 0 3.8-.8 5.2-2" />
</symbol>
<symbol id="i-external" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M15 3h6v6" />
<path d="M10 14 21 3" />
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
</symbol>
<symbol id="i-eye" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0" />
<circle cx="12" cy="12" r="3" />
</symbol>
<symbol id="i-eye-off" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M10.733 5.076a10.744 10.744 0 0 1 11.205 6.575 1 1 0 0 1 0 .696 10.747 10.747 0 0 1-1.444 2.49" />
<path d="M14.084 14.158a3 3 0 0 1-4.242-4.242" />
<path d="M17.479 17.499a10.75 10.75 0 0 1-15.417-5.151 1 1 0 0 1 0-.696 10.75 10.75 0 0 1 4.446-5.143" />
<path d="m2 2 20 20" />
</symbol>
<symbol id="i-fan" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M10.827 16.379a6.082 6.082 0 0 1-8.618-7.002l5.412 1.45a6.082 6.082 0 0 1 7.002-8.618l-1.45 5.412a6.082 6.082 0 0 1 8.618 7.002l-5.412-1.45a6.082 6.082 0 0 1-7.002 8.618l1.45-5.412Z" />
<path d="M12 12v.01" />
</symbol>
<symbol id="i-fan-aux" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12.8 19.6A2 2 0 1 0 14 16H2" />
<path d="M17.5 8a2.5 2.5 0 1 1 2 4H2" />
<path d="M9.8 4.4A2 2 0 1 1 11 8H2" />
</symbol>
<symbol id="i-fan-chamber" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M6 12H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2" />
<path d="M6 8h12" />
<path d="M18.3 17.7a2.5 2.5 0 0 1-3.16 3.83 2.53 2.53 0 0 1-1.14-2V12" />
<path d="M6.6 15.6A2 2 0 1 0 10 17v-5" />
</symbol>
<symbol id="i-file" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" />
<path d="M14 2v4a2 2 0 0 0 2 2h4" />
</symbol>
<symbol id="i-filter" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3" />
</symbol>
<symbol id="i-flame" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M8.5 14.5A2.5 2.5 0 0 0 11 12c0-1.38-.5-2-1-3-1.072-2.143-.224-4.054 2-6 .5 2.5 2 4.9 4 6.5 2 1.6 3 3.5 3 5.5a7 7 0 1 1-14 0c0-1.153.433-2.294 1-3a2.5 2.5 0 0 0 2.5 2.5z" />
</symbol>
<symbol id="i-folder" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z" />
</symbol>
<symbol id="i-folder-open" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="m6 14 1.5-2.9A2 2 0 0 1 9.24 10H20a2 2 0 0 1 1.94 2.5l-1.54 6a2 2 0 0 1-1.95 1.5H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H18a2 2 0 0 1 2 2v2" />
</symbol>
<symbol id="i-gauge" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="m12 14 4-4" />
<path d="M3.34 19a10 10 0 1 1 17.32 0" />
</symbol>
<symbol id="i-grid" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect width="7" height="7" x="3" y="3" rx="1" />
<rect width="7" height="7" x="14" y="3" rx="1" />
<rect width="7" height="7" x="14" y="14" rx="1" />
<rect width="7" height="7" x="3" y="14" rx="1" />
</symbol>
<symbol id="i-hard-drive" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="22" x2="2" y1="12" y2="12" />
<path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z" />
<line x1="6" x2="6.01" y1="16" y2="16" />
<line x1="10" x2="10.01" y1="16" y2="16" />
</symbol>
<symbol id="i-hash" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="4" x2="20" y1="9" y2="9" />
<line x1="4" x2="20" y1="15" y2="15" />
<line x1="10" x2="8" y1="3" y2="21" />
<line x1="16" x2="14" y1="3" y2="21" />
</symbol>
<symbol id="i-heart" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.3 1.5 4.05 3 5.5l7 7Z" />
</symbol>
<symbol id="i-history" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
<path d="M3 3v5h5" />
<path d="M12 7v5l4 2" />
</symbol>
<symbol id="i-home" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8" />
<path d="M3 10a2 2 0 0 1 .709-1.528l7-5.999a2 2 0 0 1 2.582 0l7 5.999A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
</symbol>
<symbol id="i-image" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect width="18" height="18" x="3" y="3" rx="2" ry="2" />
<circle cx="9" cy="9" r="2" />
<path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21" />
</symbol>
<symbol id="i-inbox" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="22 12 16 12 14 15 10 15 8 12 2 12" />
<path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z" />
</symbol>
<symbol id="i-info" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10" />
<path d="M12 16v-4" />
<path d="M12 8h.01" />
</symbol>
<symbol id="i-key" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="m15.5 7.5 2.3 2.3a1 1 0 0 0 1.4 0l2.1-2.1a1 1 0 0 0 0-1.4L19 4" />
<path d="m21 2-9.6 9.6" />
<circle cx="7.5" cy="15.5" r="5.5" />
</symbol>
<symbol id="i-keyboard" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M10 8h.01" />
<path d="M12 12h.01" />
<path d="M14 8h.01" />
<path d="M16 12h.01" />
<path d="M18 8h.01" />
<path d="M6 8h.01" />
<path d="M7 16h10" />
<path d="M8 12h.01" />
<rect width="20" height="16" x="2" y="4" rx="2" />
</symbol>
<symbol id="i-layers" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83z" />
<path d="M2 12a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 12" />
<path d="M2 17a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 17" />
</symbol>
<symbol id="i-link" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
</symbol>
<symbol id="i-list" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 12h.01" />
<path d="M3 18h.01" />
<path d="M3 6h.01" />
<path d="M8 12h13" />
<path d="M8 18h13" />
<path d="M8 6h13" />
</symbol>
<symbol id="i-loader" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
</symbol>
<symbol id="i-lock" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect width="18" height="11" x="3" y="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</symbol>
<symbol id="i-mail" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect width="20" height="16" x="2" y="4" rx="2" />
<path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7" />
</symbol>
<symbol id="i-menu" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="4" x2="20" y1="12" y2="12" />
<line x1="4" x2="20" y1="6" y2="6" />
<line x1="4" x2="20" y1="18" y2="18" />
</symbol>
<symbol id="i-message" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M7.9 20A9 9 0 1 0 4 16.1L2 22Z" />
</symbol>
<symbol id="i-minus" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M5 12h14" />
</symbol>
<symbol id="i-monitor" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect width="20" height="14" x="2" y="3" rx="2" />
<line x1="8" x2="16" y1="21" y2="21" />
<line x1="12" x2="12" y1="17" y2="21" />
</symbol>
<symbol id="i-moon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z" />
</symbol>
<symbol id="i-more" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="1" />
<circle cx="19" cy="12" r="1" />
<circle cx="5" cy="12" r="1" />
</symbol>
<symbol id="i-nozzle" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="m2 22 1-1h3l9-9" />
<path d="M3 21v-3l9-9" />
<path d="m15 6 3.4-3.4a2.1 2.1 0 1 1 3 3L18 9l.4.4a2.1 2.1 0 1 1-3 3l-3.8-3.8a2.1 2.1 0 1 1 3-3l.4.4Z" />
</symbol>
<symbol id="i-package" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M11 21.73a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73z" />
<path d="M12 22V12" />
<path d="m3.3 7 7.703 4.734a2 2 0 0 0 1.994 0L20.7 7" />
<path d="m7.5 4.27 9 5.15" />
</symbol>
<symbol id="i-palette" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="13.5" cy="6.5" r=".5" fill="currentColor" />
<circle cx="17.5" cy="10.5" r=".5" fill="currentColor" />
<circle cx="8.5" cy="7.5" r=".5" fill="currentColor" />
<circle cx="6.5" cy="12.5" r=".5" fill="currentColor" />
<path d="M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10c.926 0 1.648-.746 1.648-1.688 0-.437-.18-.835-.437-1.125-.29-.289-.438-.652-.438-1.125a1.64 1.64 0 0 1 1.668-1.668h1.996c3.051 0 5.555-2.503 5.555-5.554C21.965 6.012 17.461 2 12 2z" />
</symbol>
<symbol id="i-panel" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect width="18" height="18" x="3" y="3" rx="2" />
<path d="M9 3v18" />
</symbol>
<symbol id="i-pause" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="14" y="4" width="4" height="16" rx="1" />
<rect x="6" y="4" width="4" height="16" rx="1" />
</symbol>
<symbol id="i-percent" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="19" x2="5" y1="5" y2="19" />
<circle cx="6.5" cy="6.5" r="2.5" />
<circle cx="17.5" cy="17.5" r="2.5" />
</symbol>
<symbol id="i-play" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polygon points="6 3 20 12 6 21 6 3" />
</symbol>
<symbol id="i-plus" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M5 12h14" />
<path d="M12 5v14" />
</symbol>
<symbol id="i-power" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 2v10" />
<path d="M18.4 6.6a9 9 0 1 1-12.77.04" />
</symbol>
<symbol id="i-printer" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2" />
<path d="M6 9V3a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v6" />
<rect x="6" y="14" width="12" height="8" rx="1" />
</symbol>
<symbol id="i-queue" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M10 12h11" />
<path d="M10 18h11" />
<path d="M10 6h11" />
<path d="M4 10h2" />
<path d="M4 6h1v4" />
<path d="M6 18H4c0-1 2-2 2-3s-1-1.5-2-1" />
</symbol>
<symbol id="i-receipt" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M4 2v20l2-1 2 1 2-1 2 1 2-1 2 1 2-1 2 1V2l-2 1-2-1-2 1-2-1-2 1-2-1-2 1Z" />
<path d="M16 8h-6a2 2 0 1 0 0 4h4a2 2 0 1 1 0 4H8" />
<path d="M12 17.5v-11" />
</symbol>
<symbol id="i-redo" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="m15 14 5-5-5-5" />
<path d="M20 9H9.5A5.5 5.5 0 0 0 4 14.5A5.5 5.5 0 0 0 9.5 20H13" />
</symbol>
<symbol id="i-refresh" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8" />
<path d="M21 3v5h-5" />
<path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16" />
<path d="M8 16H3v5" />
</symbol>
<symbol id="i-rotate-ccw" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
<path d="M3 3v5h5" />
</symbol>
<symbol id="i-save" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M15.2 3a2 2 0 0 1 1.4.6l3.8 3.8a2 2 0 0 1 .6 1.4V19a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2z" />
<path d="M17 21v-7a1 1 0 0 0-1-1H8a1 1 0 0 0-1 1v7" />
<path d="M7 3v4a1 1 0 0 0 1 1h7" />
</symbol>
<symbol id="i-scan" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 7V5a2 2 0 0 1 2-2h2" />
<path d="M17 3h2a2 2 0 0 1 2 2v2" />
<path d="M21 17v2a2 2 0 0 1-2 2h-2" />
<path d="M7 21H5a2 2 0 0 1-2-2v-2" />
<path d="M7 12h10" />
</symbol>
<symbol id="i-search" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.3-4.3" />
</symbol>
<symbol id="i-send" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M14.536 21.686a.5.5 0 0 0 .937-.024l6.5-19a.496.496 0 0 0-.635-.635l-19 6.5a.5.5 0 0 0-.024.937l7.93 3.18a2 2 0 0 1 1.112 1.11z" />
<path d="m21.854 2.147-10.94 10.939" />
</symbol>
<symbol id="i-server" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect width="20" height="8" x="2" y="2" rx="2" ry="2" />
<rect width="20" height="8" x="2" y="14" rx="2" ry="2" />
<line x1="6" x2="6.01" y1="6" y2="6" />
<line x1="6" x2="6.01" y1="18" y2="18" />
</symbol>
<symbol id="i-settings" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z" />
<circle cx="12" cy="12" r="3" />
</symbol>
<symbol id="i-shield" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z" />
</symbol>
<symbol id="i-sliders" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="21" x2="14" y1="4" y2="4" />
<line x1="10" x2="3" y1="4" y2="4" />
<line x1="21" x2="12" y1="12" y2="12" />
<line x1="8" x2="3" y1="12" y2="12" />
<line x1="21" x2="16" y1="20" y2="20" />
<line x1="12" x2="3" y1="20" y2="20" />
<line x1="14" x2="14" y1="2" y2="6" />
<line x1="8" x2="8" y1="10" y2="14" />
<line x1="16" x2="16" y1="18" y2="22" />
</symbol>
<symbol id="i-smartphone" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect width="14" height="20" x="5" y="2" rx="2" ry="2" />
<path d="M12 18h.01" />
</symbol>
<symbol id="i-sparkles" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .963 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.963 0z" />
<path d="M20 3v4" />
<path d="M22 5h-4" />
<path d="M4 17v2" />
<path d="M5 18H3" />
</symbol>
<symbol id="i-spool" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10" />
<path d="M6 12c0-1.7.7-3.2 1.8-4.2" />
<circle cx="12" cy="12" r="2" />
<path d="M18 12c0 1.7-.7 3.2-1.8 4.2" />
</symbol>
<symbol id="i-star" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M11.525 2.295a.53.53 0 0 1 .95 0l2.31 4.679a2.123 2.123 0 0 0 1.595 1.16l5.166.756a.53.53 0 0 1 .294.904l-3.736 3.638a2.123 2.123 0 0 0-.611 1.878l.882 5.14a.53.53 0 0 1-.771.56l-4.618-2.428a2.122 2.122 0 0 0-1.973 0L6.396 21.01a.53.53 0 0 1-.77-.56l.881-5.139a2.122 2.122 0 0 0-.611-1.879L2.16 9.795a.53.53 0 0 1 .294-.906l5.165-.755a2.122 2.122 0 0 0 1.597-1.16z" />
</symbol>
<symbol id="i-stop" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect width="18" height="18" x="3" y="3" rx="2" />
</symbol>
<symbol id="i-sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="4" />
<path d="M12 2v2" />
<path d="M12 20v2" />
<path d="m4.93 4.93 1.41 1.41" />
<path d="m17.66 17.66 1.41 1.41" />
<path d="M2 12h2" />
<path d="M20 12h2" />
<path d="m6.34 17.66-1.41 1.41" />
<path d="m19.07 4.93-1.41 1.41" />
</symbol>
<symbol id="i-sweep" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="m9.06 11.9 8.07-8.06a2.85 2.85 0 1 1 4.03 4.03l-8.06 8.08" />
<path d="M7.07 14.94c-1.66 0-3 1.35-3 3.02 0 1.33-2.5 1.52-2 2.02 1.08 1.1 2.49 2.02 4 2.02 2.2 0 4-1.8 4-4.04a3.01 3.01 0 0 0-3-3.02z" />
</symbol>
<symbol id="i-tag" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12.586 2.586A2 2 0 0 0 11.172 2H4a2 2 0 0 0-2 2v7.172a2 2 0 0 0 .586 1.414l8.704 8.704a2.426 2.426 0 0 0 3.42 0l6.58-6.58a2.426 2.426 0 0 0 0-3.42z" />
<circle cx="7.5" cy="7.5" r=".5" fill="currentColor" />
</symbol>
<symbol id="i-thermo" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M14 4v10.54a4 4 0 1 1-4 0V4a2 2 0 0 1 4 0Z" />
</symbol>
<symbol id="i-timer" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="10" x2="14" y1="2" y2="2" />
<line x1="12" x2="15" y1="14" y2="11" />
<circle cx="12" cy="14" r="8" />
</symbol>
<symbol id="i-trash" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 6h18" />
<path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" />
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" />
<line x1="10" x2="10" y1="11" y2="17" />
<line x1="14" x2="14" y1="11" y2="17" />
</symbol>
<symbol id="i-truck" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M14 18V6a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2v11a1 1 0 0 0 1 1h2" />
<path d="M15 18H9" />
<path d="M19 18h2a1 1 0 0 0 1-1v-3.65a1 1 0 0 0-.22-.624l-3.48-4.35A1 1 0 0 0 17.52 8H14" />
<circle cx="17" cy="18" r="2" />
<circle cx="7" cy="18" r="2" />
</symbol>
<symbol id="i-undo" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M9 14 4 9l5-5" />
<path d="M4 9h10.5a5.5 5.5 0 0 1 5.5 5.5a5.5 5.5 0 0 1-5.5 5.5H11" />
</symbol>
<symbol id="i-unlock" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect width="18" height="11" x="3" y="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0 1 9.9-1" />
</symbol>
<symbol id="i-up" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="m18 15-6-6-6 6" />
</symbol>
<symbol id="i-upload" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="17 8 12 3 7 8" />
<line x1="12" x2="12" y1="3" y2="15" />
</symbol>
<symbol id="i-user" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</symbol>
<symbol id="i-users" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M22 21v-2a4 4 0 0 0-3-3.87" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
</symbol>
<symbol id="i-weight" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="5" r="3" />
<path d="M6.5 8a2 2 0 0 0-1.905 1.46L2.1 18.5A2 2 0 0 0 4 21h16a2 2 0 0 0 1.925-2.54L19.4 9.5A2 2 0 0 0 17.48 8Z" />
</symbol>
<symbol id="i-wifi" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 20h.01" />
<path d="M2 8.82a15 15 0 0 1 20 0" />
<path d="M5 12.859a10 10 0 0 1 14 0" />
<path d="M8.5 16.429a5 5 0 0 1 7 0" />
</symbol>
<symbol id="i-wifi-off" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 20h.01" />
<path d="M8.5 16.429a5 5 0 0 1 7 0" />
<path d="M5 12.859a10 10 0 0 1 5.17-2.69" />
<path d="M19 12.859a10 10 0 0 0-2.007-1.523" />
<path d="M2 8.82a15 15 0 0 1 4.177-2.643" />
<path d="M22 8.82a15 15 0 0 0-11.288-3.764" />
<path d="m2 2 20 20" />
</symbol>
<symbol id="i-wrench" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z" />
</symbol>
<symbol id="i-x" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M18 6 6 18" />
<path d="m6 6 12 12" />
</symbol>
<symbol id="i-zap" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M4 14a1 1 0 0 1-.78-1.63l9.9-10.2a.5.5 0 0 1 .86.46l-1.92 6.02A1 1 0 0 0 13 10h7a1 1 0 0 1 .78 1.63l-9.9 10.2a.5.5 0 0 1-.86-.46l1.92-6.02A1 1 0 0 0 11 14z" />
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 34 KiB

+6 -4
View File
@@ -1,14 +1,16 @@
/* Nur Static-Assets cachen — nie HTML-Navigation abfangen (sonst falsche Seite in Chrome). */ /* Nur Static-Assets cachen — nie HTML-Navigation abfangen (sonst falsche Seite in Chrome). */
const CACHE = "grotti-shell-v2"; const CACHE = "grotti-shell-v3";
self.addEventListener("install", (event) => { self.addEventListener("install", (event) => {
self.skipWaiting(); self.skipWaiting();
event.waitUntil( event.waitUntil(
caches.open(CACHE).then((cache) => caches.open(CACHE).then((cache) =>
cache.addAll([ cache.addAll([
"/static/grotti.css?v=56", "/static/grotti.css?v=62",
"/static/api.js?v=29", "/static/api.js?v=32",
"/static/ui.js?v=3", "/static/ui.js?v=5",
"/static/icons.svg",
"/static/fonts/InterVariable.woff2",
]).catch(() => undefined) ]).catch(() => undefined)
) )
); );
+127
View File
@@ -160,6 +160,7 @@ function promptAbraeumen(hostLabel) {
<div class="field"><label for="ab-ok">Stück gut</label><input id="ab-ok" type="number" min="0" step="1" value="0"></div> <div class="field"><label for="ab-ok">Stück gut</label><input id="ab-ok" type="number" min="0" step="1" value="0"></div>
<div class="field"><label for="ab-bad">Stück schlecht</label><input id="ab-bad" type="number" min="0" step="1" value="1"></div> <div class="field"><label for="ab-bad">Stück schlecht</label><input id="ab-bad" type="number" min="0" step="1" value="1"></div>
</div> </div>
<label class="check"><input type="checkbox" id="ab-reprint"> Gleich nochmal drucken</label>
<div class="actions"> <div class="actions">
<button type="submit" value="ok" class="btn-primary">Quittieren</button> <button type="submit" value="ok" class="btn-primary">Quittieren</button>
<button type="button" class="ghost" id="ab-cancel">Abbrechen</button> <button type="button" class="ghost" id="ab-cancel">Abbrechen</button>
@@ -193,6 +194,7 @@ function promptAbraeumen(hostLabel) {
verdict_reason: verdict.value === "ausschuss" ? dlg.querySelector("#ab-reason").value : "", verdict_reason: verdict.value === "ausschuss" ? dlg.querySelector("#ab-reason").value : "",
pieces_ok: Number(dlg.querySelector("#ab-ok").value) || 0, pieces_ok: Number(dlg.querySelector("#ab-ok").value) || 0,
pieces_bad: Number(dlg.querySelector("#ab-bad").value) || 0, pieces_bad: Number(dlg.querySelector("#ab-bad").value) || 0,
reprint: !!dlg.querySelector("#ab-reprint").checked,
}; };
dlg.remove(); dlg.remove();
resolve(payload); resolve(payload);
@@ -201,6 +203,131 @@ function promptAbraeumen(hostLabel) {
}); });
} }
/**
* Toast mit Rückgängig für umkehrbare Aktionen.
* @param {string} message
* @param {() => void | Promise<void>} onUndo
* @param {number} [ttlMs]
*/
function undoToast(message, onUndo, ttlMs) {
let host = document.querySelector(".toast-host");
if (!host) {
host = document.createElement("div");
host.className = "toast-host";
host.setAttribute("role", "status");
host.setAttribute("aria-live", "polite");
document.body.appendChild(host);
}
const node = document.createElement("div");
node.className = "toast good undo-toast";
node.innerHTML = '<div></div><button type="button">Rückgängig</button>';
node.querySelector("div").textContent = String(message ?? "");
let undone = false;
const btn = node.querySelector("button");
btn.addEventListener("click", async () => {
if (undone) return;
undone = true;
node.remove();
try { await onUndo(); } catch (error) { toast(error.message || String(error), "bad"); }
});
host.appendChild(node);
setTimeout(() => {
if (undone) return;
node.classList.add("out");
setTimeout(() => node.remove(), 260);
}, ttlMs || 6000);
}
/** Globale Suche Ctrl+K / Cmd+K. */
function openCommandPalette() {
const existing = document.getElementById("cmdk-dlg");
if (existing) {
existing.showModal();
existing.querySelector("input")?.focus();
return;
}
const dlg = document.createElement("dialog");
dlg.id = "cmdk-dlg";
dlg.className = "cmdk-dlg";
dlg.innerHTML = `<div class="cmdk-wrap">
<input type="search" placeholder="Drucker, Datei, Spule, Charge, Auftrag…" autocomplete="off">
<ul class="cmdk-list"></ul>
</div>`;
document.body.appendChild(dlg);
const input = dlg.querySelector("input");
const list = dlg.querySelector(".cmdk-list");
let items = [];
let active = 0;
let timer = 0;
function paint() {
list.innerHTML = items.map((item, i) =>
`<li class="${i === active ? "on" : ""}" data-i="${i}">
<span>${escapeHtml(item.title)}</span>
<span class="tiny">${escapeHtml(item.subtitle || "")}</span>
</li>`
).join("") || '<li class="tiny">Nichts gefunden.</li>';
}
async function search(q) {
q = (q || "").trim();
if (!q) { items = []; paint(); return; }
try {
const data = await api("/suche?q=" + encodeURIComponent(q));
items = data.items || [];
} catch (error) {
items = [];
}
active = 0;
paint();
}
function go(item) {
if (!item || !item.href) return;
dlg.close();
location.href = item.href;
}
input.addEventListener("input", () => {
clearTimeout(timer);
timer = setTimeout(() => search(input.value), 180);
});
input.addEventListener("keydown", (event) => {
if (event.key === "ArrowDown") {
event.preventDefault();
active = Math.min(items.length - 1, active + 1);
paint();
} else if (event.key === "ArrowUp") {
event.preventDefault();
active = Math.max(0, active - 1);
paint();
} else if (event.key === "Enter") {
event.preventDefault();
go(items[active]);
} else if (event.key === "Escape") {
dlg.close();
}
});
list.addEventListener("click", (event) => {
const li = event.target.closest("[data-i]");
if (!li) return;
go(items[Number(li.dataset.i)]);
});
dlg.addEventListener("close", () => { /* bleibt im DOM */ });
dlg.showModal();
input.focus();
}
document.addEventListener("keydown", (event) => {
if ((event.ctrlKey || event.metaKey) && (event.key === "k" || event.key === "K")) {
event.preventDefault();
openCommandPalette();
}
});
window.undoToast = undoToast;
window.openCommandPalette = openCommandPalette;
window.RUN_STATES = RUN_STATES; window.RUN_STATES = RUN_STATES;
window.setConnectionBanner = setConnectionBanner; window.setConnectionBanner = setConnectionBanner;
window.createPoller = createPoller; window.createPoller = createPoller;
+18
View File
@@ -0,0 +1,18 @@
<div class="jetzt-eta" id="jetzt-eta" hidden></div>
<section class="queue-box is-empty" data-box="jetzt" id="jetzt-box">
<h2 class="queue-title"><svg class="ico title-ico"><use href="#i-sparkles"/></svg><span class="grow">Jetzt</span><span class="count todo" id="jetzt-count">0</span></h2>
<ul class="queue-list" id="jetzt-list"></ul>
</section>
<section class="run-stack" id="run-box" hidden>
<h2 class="queue-title"><svg class="ico title-ico"><use href="#i-play"/></svg><span class="grow">Läuft</span></h2>
<ul class="queue-list" id="run-list"></ul>
</section>
<section class="queue-box" data-box="send" id="send-box" hidden>
<h2 class="queue-title"><svg class="ico title-ico"><use href="#i-send"/></svg><span class="grow">Senden</span><span class="count" id="send-count">0</span></h2>
<ul class="queue-list" id="send-list"></ul>
</section>
<section class="queue-box" data-box="alerts" id="alert-box" hidden>
<h2 class="queue-title"><svg class="ico title-ico"><use href="#i-bell"/></svg><span class="grow">Meldungen</span><span class="count bad" id="alert-count">0</span></h2>
<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>
</section>
+8 -6
View File
@@ -11,13 +11,15 @@
</div> </div>
</div> </div>
<div class="filter-bar" id="f-range" role="group" aria-label="Zeitraum"> <div class="toolbar" id="f-range" role="group" aria-label="Zeitraum">
<button type="button" class="fchip on" data-range="day">Heute</button> <div class="fchips">
<button type="button" class="fchip" data-range="week">7 Tage</button> <button type="button" class="fchip on" data-range="day">Heute</button>
<button type="button" class="fchip" data-range="all">Alle</button> <button type="button" class="fchip" data-range="week">7 Tage</button>
<button type="button" class="fchip" data-range="all">Alle</button>
</div>
</div> </div>
<div class="filter-bar" id="f-kind" role="group" aria-label="Art"></div> <div class="toolbar" id="f-kind" role="group" aria-label="Art"></div>
<div class="filter-bar" id="f-host" role="group" aria-label="Drucker"></div> <div class="toolbar" id="f-host" role="group" aria-label="Drucker"></div>
<p id="tl-note" class="tiny"></p> <p id="tl-note" class="tiny"></p>
<div class="timeline" id="timeline"> <div class="timeline" id="timeline">
+106 -18
View File
@@ -55,9 +55,9 @@
</div> </div>
<div class="field"><label for="order-notes">Notiz</label><textarea id="order-notes"></textarea></div> <div class="field"><label for="order-notes">Notiz</label><textarea id="order-notes"></textarea></div>
<div class="actions"> <div class="actions">
<button type="submit">Merken</button> <button type="submit" class="btn-primary">Speichern</button>
<button type="button" class="ghost" id="order-new">Neu</button> <button type="button" class="ghost" id="order-new">Neu</button>
<button type="button" class="warn" id="order-delete" hidden>Löschen</button> <button type="button" class="btn-danger" id="order-delete" hidden>Löschen</button>
</div> </div>
</form> </form>
<p id="order-note" class="tiny"></p> <p id="order-note" class="tiny"></p>
@@ -81,11 +81,16 @@
<div class="actions"><button type="submit">Position hinzufügen</button></div> <div class="actions"><button type="submit">Position hinzufügen</button></div>
</form> </form>
<details class="section-gap"> <details class="section-gap">
<summary>Kunde anlegen</summary> <summary>Kunde anlegen / bearbeiten</summary>
<form id="customer-form"> <form id="customer-form">
<input type="hidden" id="cust-id">
<div class="field"><label for="cust-name">Name</label><input id="cust-name" required></div> <div class="field"><label for="cust-name">Name</label><input id="cust-name" required></div>
<div class="field"><label for="cust-email">E-Mail</label><input id="cust-email" type="email"></div> <div class="field"><label for="cust-email">E-Mail</label><input id="cust-email" type="email"></div>
<button type="submit">Kunde speichern</button> <div class="actions">
<button type="submit" class="btn-primary">Kunde speichern</button>
<button type="button" class="ghost" id="cust-edit-current">Aktuellen laden</button>
<button type="button" class="btn-danger" id="cust-delete" hidden>Kunde löschen</button>
</div>
</form> </form>
</details> </details>
</section> </section>
@@ -130,7 +135,14 @@ function paintActuals(order) {
const el = document.getElementById("order-actuals"); const el = document.getElementById("order-actuals");
const a = order.actuals; const a = order.actuals;
if (!a) { el.textContent = ""; return; } if (!a) { el.textContent = ""; return; }
el.textContent = "Ist: " + euro(a.cost_total) + " · " + (hours(a.duration_s) || "0 min") + " · Ausschuss " + a.pieces_bad + " Stk"; const bits = [
"Ist: " + euro(a.cost_total),
hours(a.duration_s) || "0 min",
"Ausschuss " + a.pieces_bad + " Stk",
];
if (a.cost_per_part != null) bits.push(euro(a.cost_per_part) + "/Teil");
if (a.yield_percent != null) bits.push("Yield " + Math.round(a.yield_percent) + " %");
el.textContent = bits.join(" · ");
} }
function fillOrder(order) { function fillOrder(order) {
@@ -169,17 +181,55 @@ function renderItems(items) {
tr.children[0].textContent = item.file_name || ("#" + (item.file_id || "—")); tr.children[0].textContent = item.file_name || ("#" + (item.file_id || "—"));
tr.children[1].textContent = item.qty; tr.children[1].textContent = item.qty;
tr.children[2].textContent = item.done_ok + (item.done_bad ? " (−" + item.done_bad + ")" : ""); tr.children[2].textContent = item.done_ok + (item.done_bad ? " (−" + item.done_bad + ")" : "");
const tools = document.createElement("div");
tools.className = "queue-tools";
const btn = document.createElement("button"); const btn = document.createElement("button");
btn.type = "button"; btn.type = "button";
btn.className = "ghost tiny-btn"; btn.className = "ghost tiny-btn";
btn.textContent = "Einreihen"; btn.textContent = "Einreihen";
btn.disabled = !item.file_id || !current; btn.disabled = !item.file_id || !current;
btn.addEventListener("click", () => einreihen(item.id)); btn.addEventListener("click", () => einreihen(item.id));
tr.children[3].appendChild(btn); const edit = document.createElement("button");
edit.type = "button";
edit.className = "ghost tiny-btn";
edit.textContent = "Ändern";
edit.addEventListener("click", () => editItem(item));
const del = document.createElement("button");
del.type = "button";
del.className = "btn-danger tiny-btn";
del.textContent = "Löschen";
del.addEventListener("click", (event) => {
armButton(event.currentTarget, "Position löschen?", async () => {
await api("/auftraege/positionen/" + item.id, { method: "DELETE" });
toast("Position gelöscht.");
const full = await api("/auftraege/" + current.id);
fillOrder(full);
});
});
tools.append(btn, edit, del);
tr.children[3].appendChild(tools);
body.appendChild(tr); body.appendChild(tr);
}); });
} }
async function editItem(item) {
const qty = prompt("Stückzahl", String(item.qty || 1));
if (qty == null) return;
const price = prompt("Preis/Stk €", String(item.unit_price || 0));
if (price == null) return;
try {
await api("/auftraege/positionen/" + item.id, {
method: "PATCH",
json: { qty: Number(qty) || 1, unit_price: Number(price) || 0 },
});
const full = await api("/auftraege/" + current.id);
fillOrder(full);
toast("Position gespeichert.");
} catch (error) {
showNote("order-note", error);
}
}
async function einreihen(itemId) { async function einreihen(itemId) {
if (!current) return; if (!current) return;
try { try {
@@ -200,7 +250,8 @@ function renderList() {
const body = document.getElementById("order-rows"); const body = document.getElementById("order-rows");
body.innerHTML = ""; body.innerHTML = "";
if (!orders.length) { if (!orders.length) {
body.innerHTML = '<tr><td colspan="3" class="empty">Noch kein Auftrag.</td></tr>'; body.innerHTML = '<tr><td colspan="3" class="empty"><div class="empty-state"><p>Noch kein Auftrag.</p><p><button type="button" class="ghost" id="order-empty-new">Ersten Auftrag anlegen →</button></p></div></td></tr>';
document.getElementById("order-empty-new")?.addEventListener("click", () => fillOrder(null));
return; return;
} }
const selected = document.getElementById("order-id").value; const selected = document.getElementById("order-id").value;
@@ -209,7 +260,9 @@ function renderList() {
tr.className = "pick" + (selected && String(order.id) === selected ? " on" : ""); 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; const st = order.status || "";
const chipState = st === "druckt" ? "printing" : (st === "fertig" || st === "geliefert" || st === "bezahlt") ? "done" : st === "bestaetigt" ? "waiting" : "idle";
tr.children[1].innerHTML = chipHtml(chipState, STATUS_LABEL[st] || st);
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 { try {
@@ -259,7 +312,7 @@ document.getElementById("order-form").addEventListener("submit", async (event) =
? await api("/auftraege/" + id, { method: "PATCH", json: payload }) ? await api("/auftraege/" + id, { method: "PATCH", json: payload })
: await api("/auftraege", { method: "POST", json: payload }); : await api("/auftraege", { method: "POST", json: payload });
showNote("order-note", null); showNote("order-note", null);
document.getElementById("order-note").textContent = "Gemerkt."; document.getElementById("order-note").textContent = "Gespeichert.";
await loadList(); await loadList();
fillOrder(saved); fillOrder(saved);
} catch (error) { } catch (error) {
@@ -302,25 +355,60 @@ document.getElementById("item-form").addEventListener("submit", async (event) =>
document.getElementById("customer-form").addEventListener("submit", async (event) => { document.getElementById("customer-form").addEventListener("submit", async (event) => {
event.preventDefault(); event.preventDefault();
const id = document.getElementById("cust-id").value;
const payload = {
name: document.getElementById("cust-name").value,
email: document.getElementById("cust-email").value,
};
try { try {
const row = await api("/kunden", { const row = id
method: "POST", ? await api("/kunden/" + id, { method: "PATCH", json: payload })
json: { : await api("/kunden", { method: "POST", json: payload });
name: document.getElementById("cust-name").value, if (id) {
email: document.getElementById("cust-email").value, customers = customers.map((c) => (c.id === row.id ? row : c));
}, } else {
}); customers.push(row);
customers.push(row); }
fillCustomers(); fillCustomers();
document.getElementById("order-customer").value = row.id; document.getElementById("order-customer").value = row.id;
document.getElementById("cust-id").value = "";
document.getElementById("cust-name").value = ""; document.getElementById("cust-name").value = "";
document.getElementById("cust-email").value = ""; document.getElementById("cust-email").value = "";
toast("Kunde angelegt."); document.getElementById("cust-delete").hidden = true;
toast(id ? "Kunde gespeichert." : "Kunde angelegt.");
} catch (error) { } catch (error) {
showNote("order-note", error); showNote("order-note", error);
} }
}); });
document.getElementById("cust-edit-current").addEventListener("click", () => {
const id = document.getElementById("order-customer").value;
const row = customers.find((c) => String(c.id) === String(id));
if (!row) {
toast("Zuerst einen Kunden wählen.", "bad");
return;
}
document.getElementById("cust-id").value = row.id;
document.getElementById("cust-name").value = row.name || "";
document.getElementById("cust-email").value = row.email || "";
document.getElementById("cust-delete").hidden = false;
});
document.getElementById("cust-delete").addEventListener("click", (event) => {
const id = document.getElementById("cust-id").value;
if (!id) return;
armButton(event.currentTarget, "Kunde löschen?", async () => {
await api("/kunden/" + id, { method: "DELETE" });
customers = customers.filter((c) => String(c.id) !== String(id));
fillCustomers();
document.getElementById("cust-id").value = "";
document.getElementById("cust-name").value = "";
document.getElementById("cust-email").value = "";
document.getElementById("cust-delete").hidden = true;
toast("Kunde gelöscht.");
});
});
loadBase().catch((error) => showNote("order-note", error)); loadBase().catch((error) => showNote("order-note", error));
</script> </script>
{% endblock %} {% endblock %}
+69 -1
View File
@@ -29,6 +29,72 @@ function sourceLabel(source, estimate) {
return source || ""; return source || "";
} }
const ENERGY_PART_COLORS = {
base: "#94a3b8",
nozzle: "#ea580c",
bed: "#2563eb",
dryer: "#16a34a",
};
function kwhLabel(value) {
return (Number(value) || 0).toLocaleString("de-DE", { maximumFractionDigits: 3 }) + " kWh";
}
function energyPieSvg(parts) {
const items = (parts || []).filter((p) => Number(p.kwh) > 0);
const total = items.reduce((sum, p) => sum + Number(p.kwh || 0), 0);
if (!items.length || total <= 0) return "";
const cx = 42;
const cy = 42;
const r = 36;
let angle = -Math.PI / 2;
const slices = items.map((p) => {
const share = Number(p.kwh) / total;
const sweep = share * Math.PI * 2;
const x1 = cx + r * Math.cos(angle);
const y1 = cy + r * Math.sin(angle);
angle += sweep;
const x2 = cx + r * Math.cos(angle);
const y2 = cy + r * Math.sin(angle);
const large = sweep > Math.PI ? 1 : 0;
const color = ENERGY_PART_COLORS[p.key] || "#64748b";
if (share >= 0.999) {
return `<circle cx="${cx}" cy="${cy}" r="${r}" fill="${color}"/>`;
}
return `<path d="M ${cx} ${cy} L ${x1.toFixed(2)} ${y1.toFixed(2)} A ${r} ${r} 0 ${large} 1 ${x2.toFixed(2)} ${y2.toFixed(2)} Z" fill="${color}"/>`;
}).join("");
return `<svg class="energy-pie" viewBox="0 0 84 84" role="img" aria-label="Stromanteile">${slices}</svg>`;
}
function energyBreakdownHtml(energy) {
const parts = energy.parts || [];
if (!parts.length) return "";
const src = energy.source === "ed" ? "aus ED" : "pauschal";
const rate = energy.euro_per_kwh != null
? Number(energy.euro_per_kwh).toLocaleString("de-DE", { style: "currency", currency: "EUR", maximumFractionDigits: 3 }) + "/kWh"
: "";
const rows = parts.map((p) => {
const color = ENERGY_PART_COLORS[p.key] || "#64748b";
return `<tr>
<td><span class="energy-swatch" style="background:${color}"></span>${escapeHtml(p.label)}</td>
<td class="num">${kwhLabel(p.kwh)}</td>
<td class="num">${euro(p.cost)}</td>
</tr>`;
}).join("");
return `<div class="energy-break">
<div class="energy-break-chart">${energyPieSvg(parts)}</div>
<div class="energy-break-table">
<div class="lib-head"><h3>Strom</h3><span class="tiny">${escapeHtml(src)}${rate ? " · " + rate : ""}</span></div>
<div class="table-wrap"><table>
<thead><tr><th>Anteil</th><th class="num">kWh</th><th class="num">€</th></tr></thead>
<tbody>${rows}
<tr class="energy-total"><td>Summe</td><td class="num">${kwhLabel(energy.kwh)}</td><td class="num">${euro(energy.cost)}</td></tr>
</tbody>
</table></div>
</div>
</div>`;
}
function render(data) { function render(data) {
const run = data.run; const run = data.run;
const name = (run.filename || "").split("/").pop() || "ohne Datei"; const name = (run.filename || "").split("/").pop() || "ohne Datei";
@@ -80,10 +146,12 @@ function render(data) {
<p>Dauer <b>${hours(run.duration_s)}</b>${run.estimated_s ? " · Soll " + hours(run.estimated_s) : ""}</p> <p>Dauer <b>${hours(run.duration_s)}</b>${run.estimated_s ? " · Soll " + hours(run.estimated_s) : ""}</p>
<p>Material <b>${grams(used)}</b> von ${grams(planned)}${planned ? " (" + (diff > 0 ? "+" : "") + diff + " g)" : ""} <p>Material <b>${grams(used)}</b> von ${grams(planned)}${planned ? " (" + (diff > 0 ? "+" : "") + diff + " g)" : ""}
${run.weight_source ? ' · <span class="tag">' + sourceLabel(run.weight_source) + "</span>" : ""}</p> ${run.weight_source ? ' · <span class="tag">' + sourceLabel(run.weight_source) + "</span>" : ""}</p>
<p>Kosten <b>${euro(cost)}</b> (Filament ${euro(run.filament_cost)} · Strom ${euro(energyEuro)}${energyHint ? " · " + energyHint : ""})</p> <p>Kosten <b>${euro(cost)}</b> (Filament ${euro(run.filament_cost)} · Strom ${euro(energyEuro)}${energyHint ? " · " + energyHint : ""})${run.cost_per_part != null ? " · <b>" + euro(run.cost_per_part) + "</b>/Teil" : ""}</p>
${run.pieces_ok || run.pieces_bad ? `<p>Stück <b>${run.pieces_ok || 0}</b> gut${run.pieces_bad ? " · " + run.pieces_bad + " schlecht" : ""}</p>` : ""}
${run.message ? "<p class=\"msg\">" + escapeHtml(run.message) + "</p>" : ""} ${run.message ? "<p class=\"msg\">" + escapeHtml(run.message) + "</p>" : ""}
${data.project ? "<p class=\"tiny\">Projekt: " + escapeHtml(data.project.name) + "</p>" : ""} ${data.project ? "<p class=\"tiny\">Projekt: " + escapeHtml(data.project.name) + "</p>" : ""}
</div> </div>
${energyBreakdownHtml(energy)}
</div> </div>
<div class="card"> <div class="card">
<h2>Spulen</h2> <h2>Spulen</h2>
+12 -8
View File
@@ -18,12 +18,16 @@
<div class="send-layout"> <div class="send-layout">
<section class="card lib-card" id="lib-card"> <section class="card lib-card" id="lib-card">
<div class="lib-head"> <div class="card-head lib-head">
<svg class="ico head-ico"><use href="#i-folder-open"/></svg>
<h2>Bibliothek</h2> <h2>Bibliothek</h2>
<span class="count" id="lib-count">0</span> <span class="count-pill" id="lib-count">0</span>
</div> </div>
<div class="lib-tools"> <div class="toolbar lib-tools">
<input id="file-search" type="search" placeholder="Name, Material, Projekt…" autocomplete="off"> <div class="search-wrap">
<svg class="ico"><use href="#i-search"/></svg>
<input id="file-search" type="search" placeholder="Name, Material, Projekt…" autocomplete="off">
</div>
<select id="file-filter-family"> <select id="file-filter-family">
<option value="">Alle Maschinen</option> <option value="">Alle Maschinen</option>
<option value="kobra_3">Kobra 3</option> <option value="kobra_3">Kobra 3</option>
@@ -63,18 +67,18 @@
</div> </div>
<div class="flow-step" id="step-file"> <div class="flow-step" id="step-file">
<h3><span class="num-dot">1</span>Datei</h3> <h3><span class="step-num">1</span>Datei</h3>
<div id="picked"><div class="picked-empty"><svg class="ico"><use href="#i-file"/></svg><span>Datei aus der Bibliothek wählen.</span></div></div> <div id="picked"><div class="picked-empty"><svg class="ico"><use href="#i-file"/></svg><span>Datei aus der Bibliothek wählen.</span></div></div>
</div> </div>
<div class="flow-step" id="step-target"> <div class="flow-step" id="step-target">
<h3><span class="num-dot">2</span>Drucker</h3> <h3><span class="step-num">2</span>Drucker</h3>
<div class="targets" id="targets"></div> <div class="targets" id="targets"></div>
<p class="tiny" id="target-hint"></p> <p class="tiny" id="target-hint"></p>
</div> </div>
<div class="flow-step ok" data-pane="send" id="step-mode"> <div class="flow-step ok" data-pane="send" id="step-mode">
<h3><span class="num-dot">3</span>Wann</h3> <h3><span class="step-num">3</span>Wann</h3>
<div class="segmented" id="mode" role="radiogroup"> <div class="segmented" id="mode" role="radiogroup">
<button type="button" data-mode="now" class="on">Jetzt<small>sofort starten</small></button> <button type="button" data-mode="now" class="on">Jetzt<small>sofort starten</small></button>
<button type="button" data-mode="free">Wenn frei<small>auf diesem Drucker</small></button> <button type="button" data-mode="free">Wenn frei<small>auf diesem Drucker</small></button>
@@ -83,7 +87,7 @@
</div> </div>
<div class="flow-step ok" data-pane="ablauf" id="step-ablauf" hidden> <div class="flow-step ok" data-pane="ablauf" id="step-ablauf" hidden>
<h3><span class="num-dot">3</span>Schritte</h3> <h3><span class="step-num">3</span>Schritte</h3>
<p class="tiny">Drucken → abkühlen → schieben → Mitte tasten. Kiste vor dem Bett.</p> <p class="tiny">Drucken → abkühlen → schieben → Mitte tasten. Kiste vor dem Bett.</p>
<div id="ablauf-preview" class="ablauf-preview"></div> <div id="ablauf-preview" class="ablauf-preview"></div>
<div class="sweep-preview-wrap" id="sweep-preview-wrap" hidden> <div class="sweep-preview-wrap" id="sweep-preview-wrap" hidden>
+234 -101
View File
@@ -5,9 +5,26 @@
<h1>Einstellungen</h1> <h1>Einstellungen</h1>
<p class="tiny">Drucker, Benachrichtigungen und Energiekosten.</p> <p class="tiny">Drucker, Benachrichtigungen und Energiekosten.</p>
</div> </div>
<p class="settings-save-hint" id="settings-autosave-hint" aria-live="polite"></p>
</div> </div>
<section class="card"> <div class="settings-layout">
<nav class="settings-nav" aria-label="Abschnitte">
<a href="#sec-browser"><svg class="ico"><use href="#i-monitor"/></svg>Browser</a>
<a href="#sec-auto"><svg class="ico"><use href="#i-zap"/></svg>Automatik</a>
<a href="#sec-slice" class="admin-only"><svg class="ico"><use href="#i-folder"/></svg>Slice</a>
<a href="#sec-fqdn" class="admin-only"><svg class="ico"><use href="#i-link"/></svg>Adresse</a>
<a href="#sec-drucker" class="admin-only"><svg class="ico"><use href="#i-printer"/></svg>Drucker</a>
<a href="#sec-telegram" class="admin-only"><svg class="ico"><use href="#i-message"/></svg>Telegram</a>
<a href="#sec-ntfy" class="admin-only"><svg class="ico"><use href="#i-bell"/></svg>ntfy</a>
<a href="#sec-backup" class="admin-only"><svg class="ico"><use href="#i-hard-drive"/></svg>Backup</a>
<a href="#sec-energie" class="admin-only"><svg class="ico"><use href="#i-zap"/></svg>Energie</a>
<a href="#sec-users" class="admin-only"><svg class="ico"><use href="#i-users"/></svg>Benutzer</a>
<a href="#sec-tokens"><svg class="ico"><use href="#i-key"/></svg>API-Tokens</a>
</nav>
<div class="settings-main">
<section class="card" id="sec-browser">
<h2>Dieser Browser</h2> <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="grid-2 narrow-fields"> <div class="grid-2 narrow-fields">
@@ -36,20 +53,28 @@
<option value="UTC">UTC</option> <option value="UTC">UTC</option>
</select> </select>
</div> </div>
<div class="field">
<label for="camera-mode">Kamera</label>
<select id="camera-mode">
<option value="snapshot">Snapshot (schonend)</option>
<option value="stream">Stream (flüssig)</option>
</select>
</div>
</div> </div>
<p class="hint">Regal: größere Bedienelemente — Shortcut <code>?shelf=1</code>. Darstellung und Zeitzone nur dieser Browser.</p> <p class="hint">Regal: größere Bedienelemente — Shortcut <code>?shelf=1</code>. Darstellung, Zeitzone und Kamera nur dieser Browser. Stream hält eine Verbindung je sichtbarer Kamera.</p>
<p id="display-note" class="ok" hidden>Gespeichert — gilt sofort.</p> <p id="display-note" class="ok" hidden>Gespeichert — gilt sofort.</p>
</section> </section>
<section class="card"> <section class="card" id="sec-auto">
<h2>Automatik</h2> <h2>Automatik</h2>
<label class="check"><input type="checkbox" id="auto-queue"> Warteschlange „wenn frei“ und Abläufe automatisch starten</label> <label class="check"><input type="checkbox" id="auto-queue"> Warteschlange „wenn frei“ und Abläufe automatisch starten</label>
<p class="hint">Aus = keine neuen Starts. Laufende Abläufe und gesperrte Drucker bleiben unberührt.</p> <label class="check"><input type="checkbox" id="auto-retry"> Automatisch einmal nochmal versuchen bei Fehler</label>
<div class="actions"><button type="button" id="auto-save">Speichern</button></div> <p class="hint">Aus = keine neuen Starts. Laufende Abläufe und gesperrte Drucker bleiben unberührt. Änderungen speichern sich selbst.</p>
<p id="auto-note"></p> <div class="actions"><button type="button" id="auto-save" hidden>Speichern</button></div>
<p id="auto-note" class="settings-save-hint"></p>
</section> </section>
<section class="card admin-only"> <section class="card admin-only" id="sec-slice">
<h2>Slice-Ordner</h2> <h2>Slice-Ordner</h2>
<p class="hint">Ordner, den Grotti für die Bibliothek beobachtet (Orca-Export, „Ordner neu lesen“). Leer = nur manueller Upload. Standard: Ordner <code>slice</code> im Grotti-Repo.</p> <p class="hint">Ordner, den Grotti für die Bibliothek beobachtet (Orca-Export, „Ordner neu lesen“). Leer = nur manueller Upload. Standard: Ordner <code>slice</code> im Grotti-Repo.</p>
<div class="field" style="max-width:36rem"> <div class="field" style="max-width:36rem">
@@ -57,13 +82,13 @@
<input id="watch-dir" type="text" autocomplete="off" placeholder="D:\Projekte\Grotti\slice" spellcheck="false"> <input id="watch-dir" type="text" autocomplete="off" placeholder="D:\Projekte\Grotti\slice" spellcheck="false">
</div> </div>
<div class="actions"> <div class="actions">
<button type="button" id="watch-save">Speichern</button> <button type="button" id="watch-save" hidden>Speichern</button>
<button type="button" class="ghost" id="watch-default">Standardordner</button> <button type="button" class="ghost" id="watch-default">Standardordner</button>
</div> </div>
<p id="watch-note"></p> <p id="watch-note"></p>
</section> </section>
<section class="card admin-only"> <section class="card admin-only" id="sec-fqdn">
<h2>Öffentliche Adresse</h2> <h2>Öffentliche Adresse</h2>
<p class="hint">FQDN bzw. Basis-URL für QR-Codes auf Spulen-Etiketten. Leer = Host der aktuellen Anfrage (z.&nbsp;B. localhost).</p> <p class="hint">FQDN bzw. Basis-URL für QR-Codes auf Spulen-Etiketten. Leer = Host der aktuellen Anfrage (z.&nbsp;B. localhost).</p>
<div class="field" style="max-width:28rem"> <div class="field" style="max-width:28rem">
@@ -71,14 +96,14 @@
<input id="fqdn" placeholder="https://grotti.example.de oder grotti.lan" autocomplete="off"> <input id="fqdn" placeholder="https://grotti.example.de oder grotti.lan" autocomplete="off">
</div> </div>
<p class="hint">Ohne Schema wird <code>https://</code> ergänzt. Mit Port: <code>http://10.10.1.5:8770</code>.</p> <p class="hint">Ohne Schema wird <code>https://</code> ergänzt. Mit Port: <code>http://10.10.1.5:8770</code>.</p>
<div class="actions"><button type="button" id="fqdn-save">Speichern</button></div> <div class="actions"><button type="button" id="fqdn-save" hidden>Speichern</button></div>
<p id="fqdn-note"></p> <p id="fqdn-note"></p>
</section> </section>
<section class="card admin-only"> <section class="card admin-only" id="sec-drucker">
<h2>Drucker</h2> <h2>Drucker</h2>
<p class="hint">Gesperrt = kein neuer „wenn frei“-Start. Zeitfenster begrenzt Starts (z. B. Nachtstrom).</p> <p class="hint">Gesperrt = kein neuer „wenn frei“-Start. Zeitfenster begrenzt Starts (z. B. Nachtstrom). Tippen öffnet den Dialog.</p>
<div id="printer-list" class="printer-edit-list"></div> <div id="printer-list" class="printer-edit-cards"></div>
<form id="printer-add" class="grid-2 section-gap"> <form id="printer-add" class="grid-2 section-gap">
<div class="field"> <div class="field">
<label for="add-name">Name</label> <label for="add-name">Name</label>
@@ -101,7 +126,7 @@
<p id="printer-note"></p> <p id="printer-note"></p>
</section> </section>
<section class="card admin-only"> <section class="card admin-only" id="sec-telegram">
<h2>Telegram</h2> <h2>Telegram</h2>
<label class="check"><input type="checkbox" id="tg-enabled"> Benachrichtigungen aktiv</label> <label class="check"><input type="checkbox" id="tg-enabled"> Benachrichtigungen aktiv</label>
<div class="grid-2" style="margin-top:0.75rem"> <div class="grid-2" style="margin-top:0.75rem">
@@ -124,13 +149,13 @@
</div> </div>
<p class="hint">Ereignisse stehen immer unter <a href="/aktivitaet">Aktivität</a>. Die Häkchen steuern nur Telegram.</p> <p class="hint">Ereignisse stehen immer unter <a href="/aktivitaet">Aktivität</a>. Die Häkchen steuern nur Telegram.</p>
<div class="actions"> <div class="actions">
<button type="button" id="tg-save">Speichern</button> <button type="button" id="tg-save" hidden>Speichern</button>
<button type="button" class="ghost" id="tg-test">Test senden</button> <button type="button" class="ghost" id="tg-test">Test senden</button>
</div> </div>
<p id="tg-note"></p> <p id="tg-note"></p>
</section> </section>
<section class="card admin-only"> <section class="card admin-only" id="sec-ntfy">
<h2>ntfy</h2> <h2>ntfy</h2>
<label class="check"><input type="checkbox" id="ntfy-enabled"> Push über ntfy</label> <label class="check"><input type="checkbox" id="ntfy-enabled"> Push über ntfy</label>
<div class="grid-2" style="margin-top:0.75rem"> <div class="grid-2" style="margin-top:0.75rem">
@@ -149,13 +174,13 @@
</div> </div>
<p class="hint">Gleiche Meldungen wie Telegram — unabhängig abschaltbar. Push in der ntfy-App auf dem Handy.</p> <p class="hint">Gleiche Meldungen wie Telegram — unabhängig abschaltbar. Push in der ntfy-App auf dem Handy.</p>
<div class="actions"> <div class="actions">
<button type="button" id="ntfy-save">Speichern</button> <button type="button" id="ntfy-save" hidden>Speichern</button>
<button type="button" class="ghost" id="ntfy-test">Test senden</button> <button type="button" class="ghost" id="ntfy-test">Test senden</button>
</div> </div>
<p id="ntfy-note"></p> <p id="ntfy-note"></p>
</section> </section>
<section class="card admin-only"> <section class="card admin-only" id="sec-backup">
<h2>Backup</h2> <h2>Backup</h2>
<p class="hint">PostgreSQL-Dump und Datenordner (ohne toolpath/ und alte Backups).</p> <p class="hint">PostgreSQL-Dump und Datenordner (ohne toolpath/ und alte Backups).</p>
<div class="actions"> <div class="actions">
@@ -165,7 +190,7 @@
<p id="backup-note"></p> <p id="backup-note"></p>
</section> </section>
<section class="card admin-only"> <section class="card admin-only" id="sec-energie">
<h2>Energie</h2> <h2>Energie</h2>
<p class="hint">Kosten im Protokoll. Mit Düse-/Bett-Watt und ED-Proben schätzt Grotti den Verbrauch; sonst Dauer × Grundlast. Filament braucht eine Spule.</p> <p class="hint">Kosten im Protokoll. Mit Düse-/Bett-Watt und ED-Proben schätzt Grotti den Verbrauch; sonst Dauer × Grundlast. Filament braucht eine Spule.</p>
<div class="grid-2"> <div class="grid-2">
@@ -188,11 +213,11 @@
</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 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" hidden>Speichern</button></div>
<p id="en-note"></p> <p id="en-note"></p>
</section> </section>
<section class="card admin-only"> <section class="card admin-only" id="sec-users">
<h2>Benutzer</h2> <h2>Benutzer</h2>
<p class="hint">Rollen: <strong>Admin</strong> (alles) und <strong>Operator</strong> (Drucker, Queue, Material — keine Benutzerverwaltung).</p> <p class="hint">Rollen: <strong>Admin</strong> (alles) und <strong>Operator</strong> (Drucker, Queue, Material — keine Benutzerverwaltung).</p>
<div class="table-wrap"> <div class="table-wrap">
@@ -213,7 +238,7 @@
<p id="user-note"></p> <p id="user-note"></p>
</section> </section>
<section class="card"> <section class="card" id="sec-tokens">
<h2>API-Tokens</h2> <h2>API-Tokens</h2>
<p class="hint">Für App, Home-Automation oder MCP. Klartext erscheint nur einmal bei der Erstellung. Authorization: <code>Bearer grt_…</code></p> <p class="hint">Für App, Home-Automation oder MCP. Klartext erscheint nur einmal bei der Erstellung. Authorization: <code>Bearer grt_…</code></p>
<div class="table-wrap"> <div class="table-wrap">
@@ -234,6 +259,38 @@
<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-<code>token</code> — der wird seit 3.1 nicht mehr akzeptiert. Bitte leeren und API-Tokens (<code>grt_…</code>) nutzen.</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>
</div>
</div>
<dialog id="printer-edit-dlg" class="abraeumen-dlg">
<form method="dialog" id="printer-edit-form" class="abraeumen-form">
<h2 id="printer-edit-title">Drucker</h2>
<input type="hidden" id="pe-id">
<div class="grid-2">
<div class="field"><label for="pe-name">Name</label><input id="pe-name" required></div>
<div class="field"><label for="pe-host">Host / IP</label><input id="pe-host" required></div>
<div class="field"><label for="pe-family">Familie</label>
<select id="pe-family">
<option value="kobra_3">Kobra 3</option>
<option value="kobra_s1">Kobra S1</option>
<option value="">—</option>
</select>
</div>
<div class="field"><label class="check" style="margin-top:1.4rem"><input type="checkbox" id="pe-locked"> Gesperrt</label></div>
</div>
<label class="check"><input type="checkbox" id="pe-win-on"> Nur in Zeitfenster starten</label>
<div class="grid-2" id="pe-win-fields">
<div class="field"><label for="pe-win-from">Von</label><input type="time" id="pe-win-from" value="22:00"></div>
<div class="field"><label for="pe-win-to">Bis</label><input type="time" id="pe-win-to" value="06:00"></div>
<div class="field"><label for="pe-win-max">Max. Stunden</label><input type="number" id="pe-win-max" min="0" step="0.5" placeholder="leer = egal"></div>
</div>
<div class="actions">
<button type="submit" value="ok" class="btn-primary">Speichern</button>
<button type="button" class="ghost" id="pe-cancel">Abbrechen</button>
<button type="button" class="btn-danger" id="pe-delete">Entfernen</button>
</div>
</form>
</dialog>
{% endblock %} {% endblock %}
{% block script %} {% block script %}
<script> <script>
@@ -261,7 +318,27 @@ function windowFrom(row) {
}; };
} }
let printerRowsCache = [];
function openPrinterDialog(row) {
const dlg = document.getElementById("printer-edit-dlg");
const win = windowFrom(row);
document.getElementById("printer-edit-title").textContent = row.name || "Drucker";
document.getElementById("pe-id").value = row.id;
document.getElementById("pe-name").value = row.name || "";
document.getElementById("pe-host").value = row.host || "";
document.getElementById("pe-family").value = row.family || "";
document.getElementById("pe-locked").checked = !!row.locked;
document.getElementById("pe-win-on").checked = win.enabled;
document.getElementById("pe-win-from").value = win.from;
document.getElementById("pe-win-to").value = win.to;
document.getElementById("pe-win-max").value = win.maxHours;
document.getElementById("pe-win-fields").hidden = !win.enabled;
dlg.showModal();
}
function renderPrinters(rows) { function renderPrinters(rows) {
printerRowsCache = rows || [];
const root = document.getElementById("printer-list"); const root = document.getElementById("printer-list");
root.innerHTML = ""; root.innerHTML = "";
if (!rows.length) { if (!rows.length) {
@@ -271,86 +348,22 @@ function renderPrinters(rows) {
rows.forEach((row) => { rows.forEach((row) => {
const win = windowFrom(row); const win = windowFrom(row);
const card = document.createElement("div"); const card = document.createElement("div");
card.className = "printer-edit"; card.className = "printer-edit-card";
card.innerHTML = ` card.innerHTML = `
<div class="grid-2"> <div class="grow">
<div class="field"><label>Name</label><input data-f="name"></div> <strong></strong>
<div class="field"><label>Host / IP</label><input data-f="host"></div> <div class="tiny"></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></div>
<div class="field"><label class="check" style="margin-top:1.4rem"><input type="checkbox" data-f="locked"> Gesperrt</label></div>
</div> </div>
<details class="mat-more" ${win.enabled ? "open" : ""}> <button type="button" class="ghost">Bearbeiten</button>`;
<summary><svg class="ico caret"><use href="#i-down"/></svg><span class="grow">Start-Zeitfenster</span> card.querySelector("strong").textContent = row.name || row.host;
<span class="tiny">${win.enabled ? escapeHtml(win.from) + "–" + escapeHtml(win.to) : "aus"}</span> card.querySelector(".tiny").textContent = [
</summary> row.host,
<div class="grid-2" style="margin-top:0.6rem"> families[row.family] || row.family || "",
<label class="check" style="grid-column:1/-1"><input type="checkbox" data-f="win-on"> Nur in Zeitfenster starten</label> row.locked ? "gesperrt" : "",
<div class="field"><label>Von</label><input type="time" data-f="win-from"></div> win.enabled ? ("Fenster " + win.from + "–" + win.to) : "",
<div class="field"><label>Bis</label><input type="time" data-f="win-to"></div> ].filter(Boolean).join(" · ");
<div class="field"><label>Max. Stunden (Nachtjob)</label><input type="number" min="0" step="0.5" data-f="win-max" placeholder="leer = egal"></div> card.querySelector("button").addEventListener("click", () => openPrinterDialog(row));
</div> card.addEventListener("dblclick", () => openPrinterDialog(row));
</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: 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: " + card.querySelector('[data-f="name"]').value);
toast("Drucker gespeichert.");
load();
} catch (error) {
showNote("printer-note", error);
}
});
const del = document.createElement("button");
del.type = "button";
del.className = "ghost warn";
del.textContent = "Entfernen";
del.addEventListener("click", () => {
armButton(del, "Wirklich entfernen?", async () => {
try {
await api("/drucker/id/" + row.id, { method: "DELETE" });
showNote("printer-note", null, "Drucker entfernt.");
load();
} catch (error) {
showNote("printer-note", error);
}
});
});
card.querySelector(".actions").append(save, del);
root.appendChild(card); root.appendChild(card);
}); });
} }
@@ -453,6 +466,8 @@ async function load() {
]); ]);
if (isAdmin) renderPrinters(printers); if (isAdmin) renderPrinters(printers);
document.getElementById("auto-queue").checked = settings.auto_queue !== false; document.getElementById("auto-queue").checked = settings.auto_queue !== false;
const ar = document.getElementById("auto-retry");
if (ar) ar.checked = settings.auto_retry !== false;
const watchDir = document.getElementById("watch-dir"); const watchDir = document.getElementById("watch-dir");
if (watchDir) { if (watchDir) {
watchDir.value = settings.watch_dir || ""; watchDir.value = settings.watch_dir || "";
@@ -551,7 +566,10 @@ document.getElementById("auto-save").addEventListener("click", async () => {
try { try {
await api("/einstellungen", { await api("/einstellungen", {
method: "PATCH", method: "PATCH",
json: { auto_queue: document.getElementById("auto-queue").checked }, json: {
auto_queue: document.getElementById("auto-queue").checked,
auto_retry: document.getElementById("auto-retry")?.checked !== false,
},
}); });
showNote( showNote(
"auto-note", "auto-note",
@@ -841,6 +859,121 @@ function flashDisplayNote() {
}); });
})(); })();
(function initCameraMode() {
const sel = document.getElementById("camera-mode");
if (!sel || typeof cameraMode !== "function") return;
sel.value = cameraMode();
sel.addEventListener("change", () => {
setCameraMode(sel.value);
flashDisplayNote();
});
window.addEventListener("grotti-camera-mode", () => {
sel.value = cameraMode();
});
})();
document.getElementById("pe-win-on")?.addEventListener("change", () => {
document.getElementById("pe-win-fields").hidden = !document.getElementById("pe-win-on").checked;
});
document.getElementById("pe-cancel")?.addEventListener("click", () => {
document.getElementById("printer-edit-dlg").close();
});
document.getElementById("printer-edit-form")?.addEventListener("submit", async (event) => {
event.preventDefault();
const id = document.getElementById("pe-id").value;
const on = document.getElementById("pe-win-on").checked;
const start_window = {};
if (on) {
start_window.allowed = [{
from: document.getElementById("pe-win-from").value || "22:00",
to: document.getElementById("pe-win-to").value || "06:00",
}];
const maxH = Number(document.getElementById("pe-win-max").value);
if (maxH > 0) start_window.max_hours_overnight = maxH;
}
try {
await api("/drucker/id/" + id, {
method: "PATCH",
json: {
name: document.getElementById("pe-name").value,
host: document.getElementById("pe-host").value,
family: document.getElementById("pe-family").value,
locked: document.getElementById("pe-locked").checked,
start_window,
},
});
document.getElementById("printer-edit-dlg").close();
toast("Drucker gespeichert.");
markAutosaved();
load();
} catch (error) {
showNote("printer-note", error);
}
});
document.getElementById("pe-delete")?.addEventListener("click", (event) => {
const id = document.getElementById("pe-id").value;
armButton(event.currentTarget, "Wirklich entfernen?", async () => {
try {
await api("/drucker/id/" + id, { method: "DELETE" });
document.getElementById("printer-edit-dlg").close();
toast("Drucker entfernt.");
load();
} catch (error) {
showNote("printer-note", error);
}
});
});
function markAutosaved() {
const el = document.getElementById("settings-autosave-hint");
if (!el) return;
el.textContent = "Gespeichert ✓";
clearTimeout(markAutosaved._t);
markAutosaved._t = setTimeout(() => { el.textContent = ""; }, 2200);
}
function wireAutosave(triggerId, noteId) {
const btn = document.getElementById(triggerId);
if (!btn) return;
const run = () => {
btn.click();
markAutosaved();
if (noteId) {
const note = document.getElementById(noteId);
if (note) note.textContent = "Gespeichert ✓";
}
};
let timer = 0;
return (event) => {
if (event && event.target && event.target.id === triggerId) return;
clearTimeout(timer);
timer = setTimeout(run, 450);
};
}
document.getElementById("auto-queue")?.addEventListener("change", wireAutosave("auto-save", "auto-note"));
document.getElementById("auto-retry")?.addEventListener("change", wireAutosave("auto-save", "auto-note"));
["watch-dir"].forEach((id) => {
document.getElementById(id)?.addEventListener("change", wireAutosave("watch-save", "watch-note"));
});
document.getElementById("fqdn")?.addEventListener("change", wireAutosave("fqdn-save", "fqdn-note"));
["tg-enabled", "tg-token", "tg-chat", "al-fertig", "al-fehler", "al-offline", "al-queue", "al-ablauf", "al-start"].forEach((id) => {
document.getElementById(id)?.addEventListener("change", wireAutosave("tg-save", "tg-note"));
});
["ntfy-enabled", "ntfy-server", "ntfy-topic", "ntfy-token"].forEach((id) => {
document.getElementById(id)?.addEventListener("change", wireAutosave("ntfy-save", "ntfy-note"));
});
["en-watts", "en-kwh", "en-nozzle", "en-bed", "en-dryer", "en-dryh", "en-waste", "en-util", "en-tariff", "en-ht", "en-nt", "en-nt-from", "en-nt-to"].forEach((id) => {
document.getElementById(id)?.addEventListener("change", wireAutosave("en-save", "en-note"));
});
document.querySelectorAll(".settings-nav a").forEach((a) => {
a.addEventListener("click", () => {
document.querySelectorAll(".settings-nav a").forEach((x) => x.classList.remove("on"));
a.classList.add("on");
});
});
load().catch((error) => showNote("printer-note", error)); load().catch((error) => showNote("printer-note", error));
</script> </script>
{% endblock %} {% endblock %}
-112
View File
@@ -1,112 +0,0 @@
{% extends "layout.html" %}
{% block body %}
<div class="page-head">
<div>
<h1>Ideen</h1>
<p class="tiny">Backlog und Notizen für später.</p>
</div>
</div>
<div class="split">
<section class="card table-wrap">
<table>
<thead><tr><th>Titel</th><th>Stand</th></tr></thead>
<tbody id="idea-rows"></tbody>
</table>
</section>
<section class="card">
<h2 id="idea-title">Neue Idee</h2>
<form id="idea-form">
<input id="idea-id" type="hidden">
<div class="field"><label for="idea-name">Titel</label><input id="idea-name" required></div>
<div class="field"><label for="idea-body">Notiz</label><textarea id="idea-body"></textarea></div>
<div class="field"><label for="idea-status">Stand</label>
<select id="idea-status">
<option value="offen">offen</option>
<option value="spaeter">später</option>
<option value="erledigt">erledigt</option>
</select></div>
<div class="actions">
<button type="submit">Merken</button>
<button type="button" class="ghost" id="idea-new">Neu</button>
<button type="button" class="warn" id="idea-delete"><svg class="ico"><use href="#i-trash"/></svg>Löschen</button>
</div>
</form>
<p id="idea-note"></p>
</section>
</div>
{% endblock %}
{% block script %}
<script>
let ideas = [];
const STATUS = { offen: "offen", spaeter: "später", erledigt: "erledigt" };
function fill(idea) {
document.getElementById("idea-id").value = idea ? idea.id : "";
document.getElementById("idea-name").value = idea ? idea.title : "";
document.getElementById("idea-body").value = idea ? idea.body : "";
document.getElementById("idea-status").value = idea ? idea.status : "offen";
document.getElementById("idea-title").textContent = idea ? idea.title : "Neue Idee";
document.getElementById("idea-delete").hidden = !idea;
}
function render() {
const body = document.getElementById("idea-rows");
body.innerHTML = "";
if (!ideas.length) {
body.innerHTML = '<tr><td class="empty" colspan="2">Noch nichts notiert.</td></tr>';
return;
}
ideas.forEach((idea) => {
const tr = document.createElement("tr");
tr.className = "pick";
tr.innerHTML = "<td></td><td></td>";
tr.children[0].textContent = idea.title;
tr.children[1].textContent = STATUS[idea.status] || idea.status;
tr.addEventListener("click", () => fill(idea));
body.appendChild(tr);
});
}
async function load() {
ideas = await api("/ideen");
render();
}
document.getElementById("idea-form").addEventListener("submit", async (event) => {
event.preventDefault();
const id = document.getElementById("idea-id").value;
const payload = {
title: document.getElementById("idea-name").value,
body: document.getElementById("idea-body").value,
status: document.getElementById("idea-status").value,
};
try {
const saved = id
? await api("/ideen/" + id, { method: "PATCH", json: payload })
: await api("/ideen", { method: "POST", json: payload });
document.getElementById("idea-note").textContent = "Gemerkt.";
document.getElementById("idea-note").className = "ok";
await load();
fill(saved);
} catch (error) {
showNote("idea-note", error);
}
});
document.getElementById("idea-new").addEventListener("click", () => fill(null));
document.getElementById("idea-delete").addEventListener("click", (event) => {
const id = document.getElementById("idea-id").value;
if (!id) return;
armButton(event.currentTarget, "Idee löschen?", async () => {
try {
await api("/ideen/" + id, { method: "DELETE" });
toast("Idee gelöscht.");
await load();
fill(null);
} catch (error) {
showNote("idea-note", error);
}
});
});
load().then(() => fill(null)).catch((error) => showNote("idea-note", error));
</script>
{% endblock %}
+12
View File
@@ -0,0 +1,12 @@
{% extends "layout.html" %}
{% block body %}
<div class="page-head">
<div>
<h1>Jetzt</h1>
<p class="tiny">Handlungen, laufende Drucke und Meldungen.</p>
</div>
</div>
<div class="live-panel live-page" id="live-panel">
{% include "_live_panel.html" %}
</div>
{% endblock %}
+36 -6
View File
@@ -5,7 +5,16 @@
<h1>Kameras</h1> <h1>Kameras</h1>
<p class="tiny">Livebilder aller Drucker.</p> <p class="tiny">Livebilder aller Drucker.</p>
</div> </div>
<span id="when"></span> <div class="page-head-side">
<label class="field cam-mode-field">
<span class="tiny">Bild</span>
<select id="cam-mode" title="Kamera-Modus">
<option value="snapshot">Snapshot</option>
<option value="stream">Stream</option>
</select>
</label>
<span id="when"></span>
</div>
</div> </div>
<div class="camera-grid" id="grid"></div> <div class="camera-grid" id="grid"></div>
{% endblock %} {% endblock %}
@@ -13,10 +22,27 @@
<script> <script>
const tiles = new Map(); const tiles = new Map();
(function initCamMode() {
const sel = document.getElementById("cam-mode");
if (!sel || typeof cameraMode !== "function") return;
sel.value = cameraMode();
sel.addEventListener("change", () => {
setCameraMode(sel.value);
for (const tile of tiles.values()) {
const img = tile.querySelector('[data-role="cam"]');
if (img) img.dataset.srcKey = "";
}
refresh().catch(() => {});
});
window.addEventListener("grotti-camera-mode", () => {
sel.value = cameraMode();
});
})();
function metaLine(printer) { function metaLine(printer) {
const state = shownState(printer); const state = shownState(printer);
const busy = state === "printing" || state === "paused"; const busy = state === "printing" || state === "paused";
const bits = [STATE_LABELS[state] || state]; const bits = [chipHtml(state, STATE_LABELS[state] || state)];
if (busy) { if (busy) {
const pct = Math.round((printer.progress || 0) * 100); const pct = Math.round((printer.progress || 0) * 100);
bits.push(pct + " %"); bits.push(pct + " %");
@@ -25,7 +51,7 @@ function metaLine(printer) {
else if (hours(printer.remain)) bits.push("Rest " + hours(printer.remain)); else if (hours(printer.remain)) bits.push("Rest " + hours(printer.remain));
} }
const job = (printer.filename || "").replace(/\.gcode$/i, "").replace(/^(\d+-)+/, ""); const job = (printer.filename || "").replace(/\.gcode$/i, "").replace(/^(\d+-)+/, "");
if (busy && job) bits.push(job); if (busy && job) bits.push(escapeHtml(job));
return bits.join(" · "); return bits.join(" · ");
} }
@@ -61,15 +87,16 @@ async function refresh() {
return; return;
} }
if (grid.querySelector(".empty-state")) grid.innerHTML = ""; if (grid.querySelector(".empty-state")) grid.innerHTML = "";
const streaming = cameraMode() === "stream";
const seen = new Set(); const seen = new Set();
for (const printer of printers) { for (const printer of printers) {
seen.add(printer.host); seen.add(printer.host);
const tile = ensureTile(printer); const tile = ensureTile(printer);
const state = shownState(printer); const state = shownState(printer);
tile.className = "camera-tile " + state; tile.className = "camera-tile " + state + (streaming ? " stream" : "");
tile.querySelector(".cam-name").textContent = printer.name; tile.querySelector(".cam-name").textContent = printer.name;
tile.querySelector(".host").textContent = printer.host; tile.querySelector(".host").textContent = printer.host;
tile.querySelector(".cam-meta").textContent = metaLine(printer); tile.querySelector(".cam-meta").innerHTML = metaLine(printer);
tile.querySelector(".cam-open").href = "/#" + encodeURIComponent(printer.host); tile.querySelector(".cam-open").href = "/#" + encodeURIComponent(printer.host);
const img = tile.querySelector('[data-role="cam"]'); const img = tile.querySelector('[data-role="cam"]');
const empty = tile.querySelector('[data-role="cam-empty"]'); const empty = tile.querySelector('[data-role="cam-empty"]');
@@ -78,10 +105,13 @@ async function refresh() {
img.hidden = true; img.hidden = true;
empty.hidden = false; empty.hidden = false;
empty.innerHTML = icon("camera") + "<span>Keine Kamera</span>"; empty.innerHTML = icon("camera") + "<span>Keine Kamera</span>";
img.dataset.srcKey = "";
continue; continue;
} }
img.alt = "Kamera " + printer.name; img.alt = "Kamera " + printer.name;
const next = withToken(printer.camera) + "&t=" + Math.floor(Date.now() / 2000); const next = cameraUrl(printer, streaming ? 0 : 2000);
if (img.dataset.srcKey === next) continue;
img.dataset.srcKey = next;
img.onerror = () => { img.onerror = () => {
img.hidden = true; img.hidden = true;
empty.hidden = false; empty.hidden = false;
+351 -356
View File
@@ -9,7 +9,7 @@
<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=56"> <link rel="stylesheet" href="{{ url_for('static', filename='grotti.css') }}?v=66">
<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" id="theme-color-meta"> <meta name="theme-color" content="#0a0a0c" id="theme-color-meta">
<script> <script>
@@ -65,119 +65,66 @@
</head> </head>
<body class="app-shell"> <body class="app-shell">
<div id="conn-banner" class="conn-banner" hidden role="status"></div> <div id="conn-banner" class="conn-banner" hidden role="status"></div>
<svg width="0" height="0" style="position:absolute" aria-hidden="true"> {{ icons_sprite }}
<symbol id="i-printer" viewBox="0 0 24 24"><path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"/><path d="M6 9V3h12v6"/><rect x="6" y="14" width="12" height="8" rx="1"/></symbol>
<symbol id="i-camera" viewBox="0 0 24 24"><path d="M14.5 4h-5L7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-3l-2.5-3z"/><circle cx="12" cy="13" r="3"/></symbol>
<symbol id="i-file" viewBox="0 0 24 24"><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/></symbol>
<symbol id="i-spool" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><circle cx="12" cy="12" r="3"/><path d="M12 3v3M12 18v3M3 12h3M18 12h3"/></symbol>
<symbol id="i-folder" viewBox="0 0 24 24"><path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2z"/></symbol>
<symbol id="i-folder-open" viewBox="0 0 24 24"><path d="m6 14 1.5-2.9A2 2 0 0 1 9.24 10H20a2 2 0 0 1 1.94 2.5l-1.54 6a2 2 0 0 1-1.95 1.5H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H18a2 2 0 0 1 2 2v2"/></symbol>
<symbol id="i-list" viewBox="0 0 24 24"><path d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01"/></symbol>
<symbol id="i-activity" viewBox="0 0 24 24"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></symbol>
<symbol id="i-clock" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></symbol>
<symbol id="i-check" viewBox="0 0 24 24"><path d="M20 6 9 17l-5-5"/></symbol>
<symbol id="i-alert" viewBox="0 0 24 24"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"/><path d="M12 9v4M12 17h.01"/></symbol>
<symbol id="i-flame" viewBox="0 0 24 24"><path d="M8.5 14.5A2.5 2.5 0 0 0 11 12c0-1.38-.5-2-1-3-1.072-2.143-.224-4.054 2-6 .5 2.5 2 4.9 4 6.5 2 1.6 3 3.5 3 5.5a7 7 0 1 1-14 0c0-1.153.433-2.294 1-3a2.5 2.5 0 0 0 2.5 2.5z"/></symbol>
<symbol id="i-pause" viewBox="0 0 24 24"><rect x="14" y="4" width="4" height="16" rx="1"/><rect x="6" y="4" width="4" height="16" rx="1"/></symbol>
<symbol id="i-play" viewBox="0 0 24 24"><path d="M5 3v18l15-9z"/></symbol>
<symbol id="i-stop" viewBox="0 0 24 24"><rect x="5" y="5" width="14" height="14" rx="2"/></symbol>
<symbol id="i-layers" viewBox="0 0 24 24"><path d="m12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83z"/><path d="m22 17.65-9.17 4.16a2 2 0 0 1-1.66 0L2 17.65"/><path d="m22 12.65-9.17 4.16a2 2 0 0 1-1.66 0L2 12.65"/></symbol>
<symbol id="i-x" viewBox="0 0 24 24"><path d="M18 6 6 18M6 6l12 12"/></symbol>
<symbol id="i-up" viewBox="0 0 24 24"><path d="m18 15-6-6-6 6"/></symbol>
<symbol id="i-down" viewBox="0 0 24 24"><path d="m6 9 6 6 6-6"/></symbol>
<symbol id="i-upload" viewBox="0 0 24 24"><path d="M12 15V3"/><path d="m7 8 5-5 5 5"/><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/></symbol>
<symbol id="i-refresh" viewBox="0 0 24 24"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/><path d="M8 16H3v5"/></symbol>
<symbol id="i-queue" viewBox="0 0 24 24"><path d="M3 5h12"/><path d="M3 12h9"/><path d="M3 19h6"/><path d="m16 11 5 3-5 3z"/></symbol>
<symbol id="i-send" viewBox="0 0 24 24"><path d="m22 2-7 20-4-9-9-4z"/><path d="M22 2 11 13"/></symbol>
<symbol id="i-book" viewBox="0 0 24 24"><path d="M4 19.5v-15A2.5 2.5 0 0 1 6.5 2H19a1 1 0 0 1 1 1v18a1 1 0 0 1-1 1H6.5A2.5 2.5 0 0 1 4 19.5"/><path d="M8 7h8M8 11h8"/></symbol>
<symbol id="i-cube" viewBox="0 0 24 24"><path d="m21 8-9-5-9 5v8l9 5 9-5z"/><path d="m3.3 7.2 8.7 4.8 8.7-4.8"/><path d="M12 22V12"/></symbol>
<symbol id="i-bell" viewBox="0 0 24 24"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/></symbol>
<symbol id="i-sweep" viewBox="0 0 24 24"><path d="M3 21h18"/><path d="M9 8v8"/><path d="M15 8v8"/><path d="M5 12h14"/><path d="M7 16h10"/><path d="M12 3v5"/></symbol>
<symbol id="i-thermo" viewBox="0 0 24 24"><path d="M14 4v10.54a4 4 0 1 1-4 0V4a2 2 0 0 1 4 0"/><path d="M12 14v3"/></symbol>
<symbol id="i-settings" viewBox="0 0 24 24"><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/></symbol>
<symbol id="i-bulb" viewBox="0 0 24 24"><path d="M15 14c.2-1 .7-1.7 1.5-2.5 1-.9 1.5-2.2 1.5-3.5A6 6 0 0 0 6 8c0 1 .2 2.2 1.5 3.5.7.7 1.3 1.5 1.5 2.5"/><path d="M9 18h6M10 22h4"/></symbol>
<symbol id="i-search" viewBox="0 0 24 24"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></symbol>
<symbol id="i-filter" viewBox="0 0 24 24"><path d="M22 3H2l8 9.46V19l4 2v-8.54z"/></symbol>
<symbol id="i-info" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4M12 8h.01"/></symbol>
<symbol id="i-scan" viewBox="0 0 24 24"><path d="M3 7V5a2 2 0 0 1 2-2h2"/><path d="M17 3h2a2 2 0 0 1 2 2v2"/><path d="M21 17v2a2 2 0 0 1-2 2h-2"/><path d="M7 21H5a2 2 0 0 1-2-2v-2"/><rect x="7" y="7" width="10" height="10" rx="1"/></symbol>
<symbol id="i-trash" viewBox="0 0 24 24"><path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M10 11v6M14 11v6"/></symbol>
<symbol id="i-inbox" viewBox="0 0 24 24"><path d="M22 12h-6l-2 3h-4l-2-3H2"/><path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/></symbol>
<symbol id="i-image" viewBox="0 0 24 24"><rect width="18" height="18" x="3" y="3" rx="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/></symbol>
<symbol id="i-grid" viewBox="0 0 24 24"><rect width="7" height="7" x="3" y="3" rx="1"/><rect width="7" height="7" x="14" y="3" rx="1"/><rect width="7" height="7" x="14" y="14" rx="1"/><rect width="7" height="7" x="3" y="14" rx="1"/></symbol>
<symbol id="i-menu" viewBox="0 0 24 24"><path d="M4 6h16M4 12h16M4 18h16"/></symbol>
<symbol id="i-panel" viewBox="0 0 24 24"><rect x="3" y="3" width="7" height="18" rx="1"/><path d="M14 8h7M14 12h7M14 16h7"/></symbol>
<symbol id="i-nozzle" viewBox="0 0 24 24"><path d="M8 3h8v4l-2 3v5l-2 6-2-6v-5L8 7z"/><path d="M9 7h6"/></symbol>
<symbol id="i-bed" viewBox="0 0 24 24"><path d="M4 16h16"/><path d="M5 16v3M19 16v3"/><path d="M6 12h12v4H6z"/><path d="M8 8h8"/></symbol>
<symbol id="i-chamber" viewBox="0 0 24 24"><rect x="4" y="3" width="16" height="18" rx="2"/><path d="M8 8h8M8 12h8M8 16h5"/></symbol>
<symbol id="i-fan" viewBox="0 0 24 24"><circle cx="12" cy="12" r="2"/><path d="M12 4c2 2 2 5 0 6-2-1-2-4 0-6zM12 14c2 1 2 4 0 6-2-2-2-5 0-6zM4 12c2-2 5-2 6 0-1 2-4 2-6 0zM14 12c1-2 4-2 6 0-2 2-5 2-6 0z"/></symbol>
<symbol id="i-fan-chamber" viewBox="0 0 24 24"><circle cx="12" cy="12" r="1.6"/><path d="M12 5.5c1.4 1.4 1.4 3.5 0 4.2-1.4-.7-1.4-2.8 0-4.2zM12 14.3c1.4.7 1.4 2.8 0 4.2-1.4-1.4-1.4-3.5 0-4.2zM5.5 12c1.4-1.4 3.5-1.4 4.2 0-.7 1.4-2.8 1.4-4.2 0zM14.3 12c.7-1.4 2.8-1.4 4.2 0-1.4 1.4-3.5 1.4-4.2 0z"/><rect x="3.5" y="3.5" width="17" height="17" rx="2" fill="none"/></symbol>
<symbol id="i-fan-aux" viewBox="0 0 24 24"><circle cx="12" cy="12" r="2"/><path d="M12 3v3M12 18v3M3 12h3M18 12h3M5.6 5.6l2.1 2.1M16.3 16.3l2.1 2.1M18.4 5.6l-2.1 2.1M7.7 16.3l-2.1 2.1"/></symbol>
</svg>
<aside class="sidebar"> <aside class="sidebar">
<div class="brand-row"> <div class="brand-row">
<a class="brand" href="/" title="Grotti"> <a class="brand" href="/" title="Grotti">
<img class="brand-mark" src="{{ url_for('static', filename='brand/logo.png') }}" alt="" width="28" height="28" decoding="async"> <img class="brand-mark" src="{{ url_for('static', filename='brand/logo.png') }}" alt="" width="28" height="28" decoding="async">
<span class="brand-name">Grotti</span> <span class="brand-name">Grotti</span>
</a> </a>
<button type="button" class="icon-btn nav-toggle" id="nav-toggle" title="Menü einklappen" aria-label="Menü einklappen" aria-pressed="false"><svg class="ico"><use href="#i-panel"/></svg></button> <div class="brand-tools">
<button type="button" class="icon-btn m-only" id="cmdk-open-m" title="Suche (Ctrl+K)" aria-label="Suche öffnen"><svg class="ico"><use href="#i-search"/></svg></button>
<button type="button" class="icon-btn nav-toggle" id="nav-toggle" title="Menü einklappen" aria-label="Menü einklappen" aria-pressed="false"><svg class="ico"><use href="#i-panel"/></svg></button>
</div>
</div> </div>
<nav class="nav-main" aria-label="Hauptmenü"> <nav class="nav-main" aria-label="Hauptmenü">
<span class="nav-label">Live</span>
<a href="/" class="{{ 'on' if page == 'wand' else '' }}" title="Drucker"><svg class="ico"><use href="#i-printer"/></svg><span>Drucker</span></a> <a href="/" class="{{ 'on' if page == 'wand' else '' }}" title="Drucker"><svg class="ico"><use href="#i-printer"/></svg><span>Drucker</span></a>
<a href="/dateien" class="{{ 'on' if page == 'dateien' else '' }} nav-desk" title="Dateien"><svg class="ico"><use href="#i-file"/></svg><span>Dateien</span></a> <a href="/dateien" class="{{ 'on' if page == 'dateien' else '' }} nav-desk" title="Dateien"><svg class="ico"><use href="#i-file"/></svg><span>Dateien</span></a>
<a href="/kameras" class="{{ 'on' if page == 'kameras' else '' }}" title="Kameras"><svg class="ico"><use href="#i-camera"/></svg><span>Kameras</span></a> <a href="/kameras" class="{{ 'on' if page == 'kameras' else '' }}" title="Kameras"><svg class="ico"><use href="#i-camera"/></svg><span>Kameras</span></a>
<a href="/warteschlange" class="{{ 'on' if page == 'warteschlange' else '' }}" title="Warteschlange"><svg class="ico"><use href="#i-queue"/></svg><span>Warteschlange</span></a> <a href="/warteschlange" class="{{ 'on' if page == 'warteschlange' else '' }}" title="Warteschlange"><svg class="ico"><use href="#i-queue"/></svg><span>Warteschlange</span></a>
<a href="/auftraege" class="{{ 'on' if page == 'auftraege' else '' }} nav-desk" title="Aufträge"><svg class="ico"><use href="#i-folder"/></svg><span>Aufträge</span></a> <a href="/auftraege" class="{{ 'on' if page == 'auftraege' else '' }} nav-desk" title="Aufträge"><svg class="ico"><use href="#i-folder"/></svg><span>Aufträge</span></a>
<span class="nav-label nav-desk">Rückschau</span>
<a href="/protokoll" class="{{ 'on' if page == 'protokoll' or page == 'bericht' else '' }} nav-desk" title="Protokoll"><svg class="ico"><use href="#i-list"/></svg><span>Protokoll</span></a> <a href="/protokoll" class="{{ 'on' if page == 'protokoll' or page == 'bericht' else '' }} nav-desk" title="Protokoll"><svg class="ico"><use href="#i-list"/></svg><span>Protokoll</span></a>
<a href="/statistik" class="{{ 'on' if page == 'statistik' else '' }} nav-desk" title="Statistik"><svg class="ico"><use href="#i-grid"/></svg><span>Statistik</span></a> <a href="/statistik" class="{{ 'on' if page == 'statistik' else '' }} nav-desk" title="Statistik"><svg class="ico"><use href="#i-chart"/></svg><span>Statistik</span></a>
<a href="/aktivitaet" class="{{ 'on' if page == 'aktivitaet' else '' }} nav-desk" title="Aktivität"><svg class="ico"><use href="#i-activity"/></svg><span>Aktivität</span></a> <a href="/aktivitaet" class="{{ 'on' if page == 'aktivitaet' else '' }} nav-desk" title="Aktivität"><svg class="ico"><use href="#i-activity"/></svg><span>Aktivität</span></a>
<span class="nav-label nav-desk">Verwalten</span>
<a href="/material" class="{{ 'on' if page == 'material' else '' }} nav-desk" title="Material"><svg class="ico"><use href="#i-spool"/></svg><span>Material</span></a> <a href="/material" class="{{ 'on' if page == 'material' else '' }} nav-desk" title="Material"><svg class="ico"><use href="#i-spool"/></svg><span>Material</span></a>
<a href="/wartung" class="{{ 'on' if page == 'wartung' else '' }} nav-desk" title="Wartung"><svg class="ico"><use href="#i-nozzle"/></svg><span>Wartung</span></a> <a href="/wartung" class="{{ 'on' if page == 'wartung' else '' }} nav-desk" title="Wartung"><svg class="ico"><use href="#i-wrench"/></svg><span>Wartung</span></a>
</nav> </nav>
<div class="live-panel" id="live-panel">
<details class="queue-box" data-box="run" id="run-box" open hidden>
<summary class="queue-title"><svg class="ico caret"><use href="#i-down"/></svg><span class="grow">Läuft jetzt</span><span class="count hot" id="run-count">0</span></summary>
<ul class="queue-list" id="run-list"></ul>
</details>
<details class="queue-box" data-box="wait" id="wait-box" open>
<summary class="queue-title"><svg class="ico caret"><use href="#i-down"/></svg><span class="grow">Wartet</span><span class="count" id="wait-count">0</span></summary>
<ul class="queue-list" id="wait-list"></ul>
<div class="queue-empty" id="wait-empty"><svg class="ico"><use href="#i-inbox"/></svg><span>Leer</span></div>
</details>
<details class="queue-box" data-box="todo" id="todo-box" open hidden>
<summary class="queue-title"><svg class="ico caret"><use href="#i-down"/></svg><span class="grow">Aufgaben</span><span class="count todo" id="todo-count">0</span></summary>
<ul class="queue-list" id="todo-list"></ul>
</details>
<details class="queue-box" data-box="send" id="send-box" open hidden>
<summary class="queue-title"><svg class="ico caret"><use href="#i-down"/></svg><span class="grow">Senden</span><span class="count" id="send-count">0</span></summary>
<ul class="queue-list" id="send-list"></ul>
</details>
<details class="queue-box" data-box="alerts" id="alert-box" open hidden>
<summary class="queue-title"><svg class="ico caret"><use href="#i-down"/></svg><span class="grow">Alerts</span><span class="count bad" id="alert-count">0</span></summary>
<ul class="queue-list" id="alert-list"></ul>
<div class="queue-foot"><button type="button" class="ghost tiny-btn" id="events-clear">gelesen</button>&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>
<a href="/doku" class="{{ 'on' if page == 'doku' else '' }}" title="Doku"><svg class="ico"><use href="#i-book"/></svg><span>Doku</span></a>
</nav>
<div class="side-foot">
{% if current_user %}
<div class="side-user">
<span class="tiny" title="{{ current_user.role }}">{{ current_user.username }}</span>
<button type="button" class="ghost tiny-btn" id="logout-btn">Abmelden</button>
</div>
{% endif %}
<span class="side-copy">© by <a href="mailto:[email protected]">[email protected]</a></span>
<a href="/doku" title="Doku">v{{ version }}</a>
</div>
</aside> </aside>
<div class="app-main"> <div class="app-main">
<header class="topbar">
<button type="button" class="cmdk-btn" id="cmdk-open" title="Suche (Ctrl+K)" aria-label="Suche öffnen"><svg class="ico"><use href="#i-search"/></svg><span>Suche</span><kbd>Ctrl K</kbd></button>
<div class="live-bar" id="live-bar">
<div class="live-chips" id="live-chips"></div>
{% if page != 'jetzt' %}
<div class="live-pop" id="live-pop" hidden>
<div class="live-panel" id="live-panel">
{% include "_live_panel.html" %}
</div>
</div>
{% endif %}
</div>
<div class="topbar-tools">
<a class="icon-btn {{ 'on' if page == 'einstellungen' else '' }}" href="/einstellungen" title="Einstellungen" aria-label="Einstellungen"><svg class="ico"><use href="#i-settings"/></svg></a>
<a class="icon-btn {{ 'on' if page == 'doku' else '' }}" href="/doku" title="Doku" aria-label="Doku"><svg class="ico"><use href="#i-book"/></svg></a>
{% if current_user %}
<details class="user-menu" id="user-menu">
<summary class="side-avatar" aria-label="Benutzer-Menü">{{ current_user.username[:1]|upper }}</summary>
<div class="menu-pop" role="menu">
<div class="menu-user">
<strong>{{ current_user.username }}</strong>
<span class="tiny">{{ current_user.role }}</span>
</div>
<button type="button" class="ghost" id="logout-btn" role="menuitem">Abmelden</button>
<a href="/doku" role="menuitem">v{{ version }}</a>
<span class="tiny menu-copy">© by <a href="mailto:[email protected]">[email protected]</a></span>
</div>
</details>
{% endif %}
</div>
</header>
<main> <main>
{% block body %}{% endblock %} {% block body %}{% endblock %}
</main> </main>
@@ -185,8 +132,8 @@
<nav class="mobile-nav" aria-label="Schnellmenü"> <nav class="mobile-nav" aria-label="Schnellmenü">
<a href="/" class="{{ 'on' if page == 'wand' else '' }}"><svg class="ico"><use href="#i-printer"/></svg><span>Drucker</span></a> <a href="/" class="{{ 'on' if page == 'wand' else '' }}"><svg class="ico"><use href="#i-printer"/></svg><span>Drucker</span></a>
<a href="/dateien" class="{{ 'on' if page == 'dateien' else '' }}"><svg class="ico"><use href="#i-file"/></svg><span>Dateien</span></a> <a href="/dateien" class="{{ 'on' if page == 'dateien' else '' }}"><svg class="ico"><use href="#i-file"/></svg><span>Dateien</span></a>
<a href="/warteschlange" class="{{ 'on' if page == 'warteschlange' else '' }}"><svg class="ico"><use href="#i-queue"/></svg><span>Queue</span></a> <a href="/warteschlange" class="{{ 'on' if page == 'warteschlange' else '' }}"><svg class="ico"><use href="#i-queue"/></svg><span>Warteschlange</span></a>
<a href="/aktivitaet" class="{{ 'on' if page == 'aktivitaet' else '' }}"><svg class="ico"><use href="#i-activity"/></svg><span>Aktivität</span></a> <a href="/jetzt" class="{{ 'on' if page == 'jetzt' else '' }}"><svg class="ico"><use href="#i-sparkles"/></svg><span>Jetzt</span><span class="nav-badge" id="mobile-jetzt-badge" hidden>0</span></a>
<button type="button" class="mobile-more-btn" id="mobile-more" aria-expanded="false" aria-controls="mobile-more-sheet"><svg class="ico"><use href="#i-menu"/></svg><span>Mehr</span></button> <button type="button" class="mobile-more-btn" id="mobile-more" aria-expanded="false" aria-controls="mobile-more-sheet"><svg class="ico"><use href="#i-menu"/></svg><span>Mehr</span></button>
</nav> </nav>
<dialog id="mobile-more-sheet" class="mobile-more-sheet"> <dialog id="mobile-more-sheet" class="mobile-more-sheet">
@@ -195,20 +142,45 @@
<a href="/auftraege">Aufträge</a> <a href="/auftraege">Aufträge</a>
<a href="/protokoll">Protokoll</a> <a href="/protokoll">Protokoll</a>
<a href="/statistik">Statistik</a> <a href="/statistik">Statistik</a>
<a href="/aktivitaet">Aktivität</a>
<a href="/material">Material</a> <a href="/material">Material</a>
<a href="/wartung">Wartung</a> <a href="/wartung">Wartung</a>
<a href="/einstellungen">Einstellungen</a> <a href="/einstellungen">Einstellungen</a>
<a href="/doku">Doku</a> <a href="/doku">Doku</a>
</nav> </nav>
{% if current_user %}
<div class="mobile-more-user">
<span class="side-avatar" aria-hidden="true">{{ current_user.username[:1]|upper }}</span>
<span class="grow">
<strong>{{ current_user.username }}</strong>
<span class="tiny">{{ current_user.role }} · v{{ version }}</span>
</span>
<button type="button" class="ghost tiny-btn" id="logout-btn-m">Abmelden</button>
</div>
{% endif %}
<form method="dialog"><button class="ghost" value="cancel">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=29"></script> <script src="{{ url_for('static', filename='api.js') }}?v=32"></script>
<script src="{{ url_for('static', filename='ui.js') }}?v=3"></script> <script src="{{ url_for('static', filename='ui.js') }}?v=5"></script>
<script> <script>
document.getElementById("logout-btn")?.addEventListener("click", async () => { async function doLogout() {
try { await api("/auth/logout", { method: "POST", json: {} }); } catch (e) {} try { await api("/auth/logout", { method: "POST", json: {} }); } catch (e) {}
location.href = "/login"; location.href = "/login";
}); }
document.getElementById("logout-btn")?.addEventListener("click", doLogout);
document.getElementById("logout-btn-m")?.addEventListener("click", doLogout);
(function userMenuClose() {
const menu = document.getElementById("user-menu");
if (!menu) return;
document.addEventListener("click", (event) => {
if (!menu.open) return;
if (menu.contains(event.target)) return;
menu.open = false;
});
document.addEventListener("keydown", (event) => {
if (event.key === "Escape" && menu.open) menu.open = false;
});
})();
(function applyShellClasses() { (function applyShellClasses() {
const shelf = document.documentElement.classList.contains("shelf"); const shelf = document.documentElement.classList.contains("shelf");
const collapsed = document.documentElement.classList.contains("nav-collapsed"); const collapsed = document.documentElement.classList.contains("nav-collapsed");
@@ -247,6 +219,11 @@ function setDisplayMode(mode) {
document.getElementById("nav-toggle")?.addEventListener("click", () => { document.getElementById("nav-toggle")?.addEventListener("click", () => {
setNavCollapsed(!document.body.classList.contains("nav-collapsed")); setNavCollapsed(!document.body.classList.contains("nav-collapsed"));
}); });
function openCmdk() {
if (typeof openCommandPalette === "function") openCommandPalette();
}
document.getElementById("cmdk-open")?.addEventListener("click", openCmdk);
document.getElementById("cmdk-open-m")?.addEventListener("click", openCmdk);
(function restoreBoxes() { (function restoreBoxes() {
let closed = []; let closed = [];
@@ -267,33 +244,74 @@ function queueTarget(job) {
async function cancelQueueItem(job, button) { async function cancelQueueItem(job, button) {
const running = job.status === "running"; const running = job.status === "running";
const armed = running
? (job.is_ablauf ? "Ablauf stoppen?" : "Auftrag stoppen?")
: "Entfernen?";
const go = async () => { const go = async () => {
if (button) button.disabled = true; if (button) button.disabled = true;
const prev = { status: job.status, priority: job.priority };
try { try {
await api("/druck-queue/" + job.id, { method: "PATCH", json: { status: "cancelled" } }); await api("/druck-queue/" + job.id, { method: "PATCH", json: { status: "cancelled" } });
toast(running ? "Abgebrochen." : "Aus der Warteschlange genommen."); if (running) {
toast("Abgebrochen.");
} else if (typeof undoToast === "function") {
undoToast("Aus der Warteschlange genommen.", async () => {
await api("/druck-queue/" + job.id, {
method: "PATCH",
json: { status: "waiting", priority: prev.priority, undo: true },
});
toast("Wieder eingereiht.");
refreshJetzt();
if (typeof window.onQueueChanged === "function") window.onQueueChanged();
});
} else {
toast("Aus der Warteschlange genommen.");
}
} catch (error) { } catch (error) {
toast(error.message, "bad"); toast(error.message, "bad");
} }
refreshPrintQueue(); refreshJetzt();
if (typeof window.onQueueChanged === "function") window.onQueueChanged(); if (typeof window.onQueueChanged === "function") window.onQueueChanged();
if (button) button.disabled = false; if (button) button.disabled = false;
}; };
if (button) armButton(button, armed, go); if (running && button) armButton(button, job.is_ablauf ? "Ablauf stoppen?" : "Druck stoppen?", go);
else go(); else go();
} }
window.cancelQueueItem = cancelQueueItem; window.cancelQueueItem = cancelQueueItem;
function ablaufStepFrac(job) { function setListHtml(el, html) {
if (!job || !job.is_ablauf || !job.step_labels || !job.step_labels.length) return ""; if (!el) return;
const idx = Number(job.display_step_index != null ? job.display_step_index : job.step_index) || 0; if (el._html === html) return;
return (idx + 1) + "/" + job.step_labels.length; el._html = html;
el.innerHTML = html;
} }
function runLineHtml({ name, progress, remain, filename, tipExtra, cancelId }) { function shortPrinter(name) {
let text = String(name || "").replace(/^Kobra\s+(?:3\s+)?/i, "").trim() || String(name || "");
return text.length > 12 ? text.slice(0, 10) + "…" : text;
}
function openLivePop(section) {
const pop = document.getElementById("live-pop");
if (!pop) {
const box = section ? document.querySelector(`[data-box="${section}"]`) : null;
if (box) {
box.scrollIntoView({ block: "start", behavior: "smooth" });
return;
}
location.href = section ? "/jetzt#" + encodeURIComponent(section) : "/jetzt";
return;
}
pop.hidden = false;
if (section) {
const box = pop.querySelector(`[data-box="${section}"]`);
if (box) box.scrollIntoView({ block: "nearest" });
}
}
function closeLivePop() {
const pop = document.getElementById("live-pop");
if (pop) pop.hidden = true;
}
function runLineHtml({ name, progress, remain, filename, tipExtra, cancelId, host }) {
const pct = Math.max(0, Math.min(100, Math.round((Number(progress) || 0) * 100))); const pct = Math.max(0, Math.min(100, Math.round((Number(progress) || 0) * 100)));
const finish = remain > 0 ? finishClock(remain) : ""; const finish = remain > 0 ? finishClock(remain) : "";
const bits = [name]; const bits = [name];
@@ -305,8 +323,9 @@ function runLineHtml({ name, progress, remain, filename, tipExtra, cancelId }) {
remain > 0 ? "noch " + hours(remain) : "", remain > 0 ? "noch " + hours(remain) : "",
tipExtra ? "Schritt " + tipExtra : "", tipExtra ? "Schritt " + tipExtra : "",
].filter(Boolean).join(" · "); ].filter(Boolean).join(" · ");
const href = host ? "/#" + encodeURIComponent(host) : "/";
return `<li class="queue-item running run-line" style="--pct:${pct}"> return `<li class="queue-item running run-line" style="--pct:${pct}">
<a class="queue-link run-line-link" href="/warteschlange" title="${escapeHtml(tip || "Zur Warteschlange")}"> <a class="queue-link run-line-link" href="${escapeHtml(href)}" title="${escapeHtml(tip || name || "Drucker")}">
<span class="run-line-fill" aria-hidden="true"></span> <span class="run-line-fill" aria-hidden="true"></span>
<span class="run-line-text">${escapeHtml(bits.join(" · "))}</span> <span class="run-line-text">${escapeHtml(bits.join(" · "))}</span>
</a> </a>
@@ -314,233 +333,74 @@ function runLineHtml({ name, progress, remain, filename, tipExtra, cancelId }) {
</li>`; </li>`;
} }
function runItemHtml(job) { function actionItemHtml(action) {
return runLineHtml({ const kind = action.kind || "";
name: queueTarget(job), const cls = kind === "queue_error" || kind === "bedarf" || kind === "wartung" || kind === "auftrag" || kind === "filament_pause" ? "error" : "";
progress: job.progress, let tools = "";
remain: job.remain, if (kind === "clear" && action.host) {
filename: job.filename, tools = `<span class="queue-tools"><button type="button" class="icon-btn done-btn" data-cleared="${escapeHtml(action.host)}" title="Platte geräumt" aria-label="Platte geräumt">${icon("check")}</button></span>`;
tipExtra: ablaufStepFrac(job), } else if (kind === "queue_error" && action.id) {
cancelId: job.id, if (action.is_ablauf) {
}); tools = `<span class="queue-tools"><button type="button" class="icon-btn" data-resume-q="${action.id}" title="Fortsetzen">${icon("play")}</button>
} <button type="button" class="icon-btn danger" data-dismiss-q="${action.id}" title="Ausblenden">${icon("x")}</button></span>`;
} else {
function waitItemHtml(job, index, total) { tools = `<span class="queue-tools"><button type="button" class="icon-btn danger" data-dismiss-q="${action.id}" title="Ausblenden">${icon("x")}</button></span>`;
const isError = job.status === "error"; }
const label = job.is_ablauf ? "Ablauf · " + jobLabel(job.filename) : jobLabel(job.filename); } else if (kind === "waiting" && action.id) {
const rep = job.is_ablauf && (job.repeat_total || 1) > 1 ? job.repeat_total + "×" : ""; tools = `<span class="queue-tools"><button type="button" class="icon-btn danger" data-cancel-q="${action.id}" title="Entfernen">${icon("x")}</button></span>`;
return `<li class="queue-item ${isError ? "error" : ""}"> }
const href = action.href || "/warteschlange";
const name = action.printer || action.title || "";
const detail = action.detail || action.filename || "";
return `<li class="queue-item ${cls}">
<div class="queue-row"> <div class="queue-row">
<span class="grow"><span class="queue-name" title="${escapeHtml(job.filename)}">${escapeHtml(label)}</span></span> <a class="queue-link grow" href="${escapeHtml(href)}"><span class="queue-name">${escapeHtml(action.title || name)}</span></a>
<span class="queue-tools"> ${tools}
${!isError && index > 0 ? `<button type="button" class="icon-btn" data-prio-q="${job.id}" data-dir="-1" title="Höher">${icon("up")}</button>` : ""}
${!isError && index < total - 1 ? `<button type="button" class="icon-btn" data-prio-q="${job.id}" data-dir="1" title="Tiefer">${icon("down")}</button>` : ""}
${isError && job.is_ablauf ? `<button type="button" class="icon-btn" data-resume-q="${job.id}" title="Ab Schritt ${(Number(job.step_index) || 0) + 1} (${escapeHtml(job.step_label_current || "")}) weitermachen">${icon("play")}</button>` : ""}
<button type="button" class="icon-btn danger" ${isError ? `data-dismiss-q="${job.id}" title="Ausblenden"` : `data-cancel-q="${job.id}" title="Entfernen"`}>${icon("x")}</button>
</span>
</div> </div>
<span class="queue-sub">${isError ? "fehlgeschlagen · " : "→ "}${escapeHtml(queueTarget(job))}${rep ? " · " + rep : ""}</span> <span class="queue-msg">${escapeHtml([name !== action.title ? name : "", detail].filter(Boolean).join(" · "))}</span>
${isError ? `<span class="queue-msg">${escapeHtml(job.message || "")}</span>` : ""}
</li>`; </li>`;
} }
function directItemHtml(p) { function renderLiveChips({ actionItems, running, activeSends, alertCount, etaText }) {
return runLineHtml({ const box = document.getElementById("live-chips");
name: p.name || p.host,
progress: p.progress,
remain: p.remain,
filename: p.filename,
});
}
let queueCache = [];
const lastLiveFilename = Object.create(null);
function setListHtml(el, html) {
if (!el) return;
if (el._html === html) return;
el._html = html;
el.innerHTML = html;
}
function stickyLiveFilename(host, filename, state) {
const next = filename || "";
if (next) {
lastLiveFilename[host] = next;
return next;
}
const prev = lastLiveFilename[host] || "";
if (prev && ["printing", "paused", "heating", "complete"].includes(String(state || "").toLowerCase())) {
return prev;
}
if (!["printing", "paused", "heating"].includes(String(state || "").toLowerCase())) {
delete lastLiveFilename[host];
}
return prev && ["complete"].includes(String(state || "").toLowerCase()) ? prev : "";
}
async function refreshPrintQueue() {
let jobs;
let active = [];
try {
[jobs, active] = await Promise.all([api("/druck-queue"), api("/laeuft").catch(() => [])]);
} catch (error) { return; }
queueCache = jobs || [];
const liveByHost = Object.create(null);
(active || []).forEach((p) => { if (p.host) liveByHost[p.host] = p; });
const running = queueCache.filter((job) => job.status === "running").map((job) => {
const live = liveByHost[job.host];
if (!live) return job;
return {
...job,
progress: live.progress,
remain: live.remain,
filename: stickyLiveFilename(job.host, live.filename || job.filename, live.state) || job.filename,
};
});
const queuedHosts = new Set(running.map((job) => job.host));
const direct = (active || []).filter((p) => !queuedHosts.has(p.host)).map((p) => ({
...p,
filename: stickyLiveFilename(p.host, p.filename, p.state),
}));
const waiting = queueCache.filter((job) => job.status === "waiting");
const errors = queueCache
.filter((job) => job.status === "error")
.sort((a, b) => String(b.updated_at).localeCompare(String(a.updated_at)))
.slice(0, 2);
const runBox = document.getElementById("run-box");
runBox.hidden = !(running.length || direct.length);
document.getElementById("run-count").textContent = running.length + direct.length;
setListHtml(
document.getElementById("run-list"),
running.map(runItemHtml).join("") + direct.map(directItemHtml).join(""),
);
const waitList = document.getElementById("wait-list");
document.getElementById("wait-count").textContent = waiting.length;
document.getElementById("wait-count").className = "count" + (errors.length ? " bad" : waiting.length ? " hot" : "");
document.getElementById("wait-empty").hidden = !!(waiting.length || errors.length);
document.getElementById("wait-box").classList.toggle("is-empty", !(waiting.length || errors.length));
setListHtml(
waitList,
errors.map((job) => waitItemHtml(job, 0, 1)).join("")
+ waiting.map((job, i) => waitItemHtml(job, i, waiting.length)).join(""),
);
const panel = document.getElementById("live-panel");
panel.querySelectorAll("[data-cancel-q]").forEach((btn) => {
btn.onclick = () => {
const job = queueCache.find((item) => item.id === Number(btn.dataset.cancelQ));
if (job) cancelQueueItem(job, btn);
};
});
panel.querySelectorAll("[data-resume-q]").forEach((btn) => {
btn.onclick = () => {
const job = queueCache.find((item) => item.id === Number(btn.dataset.resumeQ));
if (!job) return;
const step = job.step_label_current || ("Schritt " + ((job.step_index || 0) + 1));
armButton(btn, "Weiter ab „" + step + "“?", async () => {
btn.disabled = true;
try {
await api("/druck-queue/" + job.id, { method: "PATCH", json: { status: "running", resume: true } });
toast("Ablauf geht weiter.");
} catch (error) { toast(error.message, "bad"); }
refreshPrintQueue();
if (typeof window.onQueueChanged === "function") window.onQueueChanged();
btn.disabled = false;
});
};
});
panel.querySelectorAll("[data-dismiss-q]").forEach((btn) => {
btn.onclick = () => armButton(btn, "Ausblenden?", async () => {
try { await api("/druck-queue/" + btn.dataset.dismissQ, { method: "PATCH", json: { status: "dismissed" } }); } catch (error) { toast(error.message, "bad"); }
refreshPrintQueue();
});
});
panel.querySelectorAll("[data-prio-q]").forEach((btn) => {
btn.onclick = async () => {
const id = Number(btn.dataset.prioQ);
const dir = Number(btn.dataset.dir);
const idx = waiting.findIndex((job) => job.id === id);
const other = waiting[idx + dir];
if (!other) return;
try {
await api("/druck-queue/" + id, { method: "PATCH", json: { priority: Number(other.priority) + dir } });
} catch (error) { toast(error.message, "bad"); }
refreshPrintQueue();
};
});
}
async function refreshSendQueue() {
let jobs;
try { jobs = await api("/sende-auftraege"); } catch (error) { return; }
const active = (jobs || []).filter((job) => job.status === "pending" || job.status === "running");
const recent = active.length ? active : (jobs || []).slice(0, 2).filter((job) => job.status === "error");
const box = document.getElementById("send-box");
box.hidden = !recent.length;
document.getElementById("send-count").textContent = active.length;
document.getElementById("send-list").innerHTML = recent.map((job) => {
const pct = Math.max(0, Math.min(100, Number(job.progress) || 0));
const cls = job.status === "error" ? "bad" : job.status === "done" ? "good" : "";
return `<li class="queue-item ${job.status === "error" ? "error" : ""}">
<span class="queue-name">${escapeHtml(job.printer || "")}</span>
<span class="queue-msg">${escapeHtml(job.message || "")}</span>
<span class="send-progress-track slim"><span class="send-progress-bar ${cls}" style="width:${pct}%"></span></span>
</li>`;
}).join("");
}
async function refreshEvents() {
let data;
try { data = await api("/ereignisse?unread=1&limit=5"); } catch (error) { return; }
const items = data.items || [];
const unread = Number(data.unread != null ? data.unread : items.length);
const box = document.getElementById("alert-box");
box.hidden = !items.length;
document.getElementById("alert-count").textContent = unread;
document.getElementById("alert-list").innerHTML = items.map((item) => `<li class="queue-item">
<div class="queue-row"><span class="grow"><span class="queue-name">${escapeHtml(item.printer || item.kind)}</span></span><span class="queue-sub">${escapeHtml(clockOf(item.created_at))}</span></div>
<span class="queue-msg">${escapeHtml(item.message)}</span>
</li>`).join("");
}
async function refreshBedarf() {
let data;
try { data = await api("/bedarf"); } catch (error) { return; }
const shortfall = data.shortfall || [];
const box = document.getElementById("bedarf-box");
if (!box) return; if (!box) return;
box.hidden = !shortfall.length; const bits = [];
document.getElementById("bedarf-count").textContent = shortfall.length; if (etaText) {
document.getElementById("bedarf-list").innerHTML = shortfall.slice(0, 5).map((item) => { bits.push(`<span class="live-eta">${escapeHtml(etaText)}</span>`);
const label = [item.material_type, item.color].filter(Boolean).join(" · ") || "Filament"; }
return `<li class="queue-item"> if (actionItems.length) {
<div class="queue-row"><span class="grow"><span class="queue-name">${escapeHtml(label)}</span></span> bits.push(`<button type="button" class="live-chip todo" data-live-open="jetzt">${icon("sparkles")}<span>Jetzt</span><span class="count todo">${actionItems.length}</span></button>`);
<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> running.forEach((p) => {
</li>`; const pct = Math.max(0, Math.min(100, Math.round((Number(p.progress) || 0) * 100)));
}).join(""); const finish = p.remain > 0 ? finishClock(p.remain) : "";
if (typeof window.onBedarfChanged === "function") window.onBedarfChanged(data); const pausedFil = p.state === "paused" && (p.filament_empty || p.pause_reason);
const label = [
shortPrinter(p.name || p.host),
pausedFil ? "Material!" : (pct ? pct + "%" : ""),
finish,
].filter(Boolean).join(" · ");
const tip = [p.name || "", p.pause_reason || ""].filter(Boolean).join(" — ");
bits.push(`<button type="button" class="live-chip run${pausedFil ? " warn" : ""}" data-live-open="run" style="--pct:${pct}" title="${escapeHtml(tip || p.name || "")}">
<span class="live-chip-fill" aria-hidden="true"></span>
<span class="live-chip-text">${escapeHtml(label)}</span>
</button>`);
});
if (activeSends > 0) {
bits.push(`<button type="button" class="live-chip" data-live-open="send">${icon("send")}<span>Senden</span><span class="count">${activeSends}</span></button>`);
}
if (alertCount > 0) {
bits.push(`<button type="button" class="live-chip bad" data-live-open="alerts">${icon("bell")}<span>Meldungen</span><span class="count bad">${alertCount}</span></button>`);
}
setListHtml(box, bits.join(""));
box.querySelectorAll("[data-live-open]").forEach((btn) => {
btn.onclick = () => openLivePop(btn.dataset.liveOpen);
});
} }
async function refreshTasks() { function bindLivePanelActions(panel) {
let items; if (!panel) return;
try { items = await api("/aufgaben"); } catch (error) { return; } panel.querySelectorAll("[data-cleared]").forEach((btn) => {
items = items || [];
document.getElementById("todo-box").hidden = !items.length;
document.getElementById("todo-count").textContent = items.length;
const list = document.getElementById("todo-list");
list.innerHTML = items.map((p) => `<li class="queue-item">
<div class="queue-row">
<a class="queue-link grow" href="/#${encodeURIComponent(p.host)}"><span class="queue-name">${escapeHtml(p.name || p.host)}</span></a>
<span class="queue-tools"><button type="button" class="icon-btn done-btn" data-cleared="${escapeHtml(p.host)}" title="Platte geräumt (zweimal klicken)" aria-label="Platte geräumt">${icon("check")}</button></span>
</div>
<span class="queue-msg">Platte räumen${p.state === "complete" ? " · Druck fertig" : ""}</span>
${p.filename ? `<span class="queue-sub" title="${escapeHtml(p.filename)}">${escapeHtml(jobLabel(p.filename))}</span>` : ""}
</li>`).join("");
list.querySelectorAll("[data-cleared]").forEach((btn) => {
btn.onclick = async () => { btn.onclick = async () => {
const host = btn.dataset.cleared; const host = btn.dataset.cleared;
const payload = await promptAbraeumen(btn.closest("li")?.querySelector(".queue-name")?.textContent || host); const payload = await promptAbraeumen(btn.closest("li")?.querySelector(".queue-name")?.textContent || host);
@@ -550,12 +410,146 @@ async function refreshTasks() {
await api("/drucker/" + encodeURIComponent(host) + "/abraeumen", { method: "POST", json: payload }); await api("/drucker/" + encodeURIComponent(host) + "/abraeumen", { method: "POST", json: payload });
toast("Platte frei."); toast("Platte frei.");
} catch (error) { toast(error.message, "bad"); } } catch (error) { toast(error.message, "bad"); }
refreshTasks(); refreshJetzt();
if (typeof window.onQueueChanged === "function") window.onQueueChanged(); if (typeof window.onQueueChanged === "function") window.onQueueChanged();
btn.disabled = false; btn.disabled = false;
}; };
}); });
panel.querySelectorAll("[data-cancel-q]").forEach((btn) => {
btn.onclick = () => cancelQueueItem({ id: Number(btn.dataset.cancelQ), status: "waiting" }, btn);
});
panel.querySelectorAll("[data-resume-q]").forEach((btn) => {
btn.onclick = () => armButton(btn, "Fortsetzen?", async () => {
try {
await api("/druck-queue/" + btn.dataset.resumeQ, { method: "PATCH", json: { status: "running", resume: true } });
toast("Ablauf geht weiter.");
} catch (error) { toast(error.message, "bad"); }
refreshJetzt();
if (typeof window.onQueueChanged === "function") window.onQueueChanged();
});
});
panel.querySelectorAll("[data-dismiss-q]").forEach((btn) => {
btn.onclick = () => armButton(btn, "Ausblenden?", async () => {
try { await api("/druck-queue/" + btn.dataset.dismissQ, { method: "PATCH", json: { status: "dismissed" } }); } catch (error) { toast(error.message, "bad"); }
refreshJetzt();
});
});
} }
async function refreshJetzt() {
let data;
try { data = await api("/jetzt"); } catch (error) { return; }
// Wartende ohne Materialgrund nicht in Jetzt — nur echte Handlungen + Material-Wartegründe
const actionItems = (data.actions || []).filter((a) => a.kind !== "waiting" || a.reason);
const running = data.running || [];
const eta = data.eta || {};
const multiEta = running.filter((p) => Number(p.remain) > 0).length >= 2;
const etaText = multiEta && eta.all_done_in_s > 0 ? "Alles fertig um " + finishClock(eta.all_done_in_s) : "";
const etaEl = document.getElementById("jetzt-eta");
if (etaEl) {
if (etaText) {
etaEl.hidden = false;
etaEl.textContent = etaText;
} else {
etaEl.hidden = true;
etaEl.textContent = "";
}
}
const jetztBox = document.getElementById("jetzt-box");
const jetztCount = document.getElementById("jetzt-count");
if (jetztCount) {
jetztCount.textContent = actionItems.length;
jetztCount.className = "count" + (actionItems.length ? " todo" : "");
}
if (jetztBox) {
jetztBox.classList.toggle("is-empty", !actionItems.length);
setListHtml(document.getElementById("jetzt-list"), actionItems.map(actionItemHtml).join(""));
}
const runBox = document.getElementById("run-box");
if (runBox) {
runBox.hidden = !running.length;
setListHtml(
document.getElementById("run-list"),
running.map((p) => runLineHtml({
name: p.name || p.host,
host: p.host,
progress: p.progress,
remain: p.remain,
filename: p.filename,
cancelId: p.queue_id,
})).join(""),
);
}
const sends = data.sends || [];
const activeSends = sends.filter((j) => j.status === "pending" || j.status === "running");
const sendBox = document.getElementById("send-box");
if (sendBox) {
sendBox.hidden = !activeSends.length && !sends.some((j) => j.status === "error");
const sendCount = document.getElementById("send-count");
if (sendCount) sendCount.textContent = activeSends.length;
setListHtml(
document.getElementById("send-list"),
sends.map((job) => {
const pct = Math.max(0, Math.min(100, Number(job.progress) || 0));
const cls = job.status === "error" ? "bad" : "";
return `<li class="queue-item ${job.status === "error" ? "error" : ""}">
<span class="queue-name">${escapeHtml(job.printer || "")}</span>
<span class="queue-msg">${escapeHtml(job.message || "")}</span>
<span class="send-progress-track slim"><span class="send-progress-bar ${cls}" style="width:${pct}%"></span></span>
</li>`;
}).join(""),
);
}
const alerts = data.alerts || [];
const alertCount = data.unread_alerts || alerts.length;
const alertBox = document.getElementById("alert-box");
if (alertBox) {
alertBox.hidden = !alerts.length;
const alertCountEl = document.getElementById("alert-count");
if (alertCountEl) alertCountEl.textContent = alertCount;
setListHtml(
document.getElementById("alert-list"),
alerts.map((item) => `<li class="queue-item">
<div class="queue-row"><span class="grow"><span class="queue-name">${escapeHtml(item.printer || item.kind)}</span></span><span class="queue-sub">${escapeHtml(clockOf(item.created_at))}</span></div>
<span class="queue-msg">${escapeHtml(item.message)}</span>
</li>`).join(""),
);
}
renderLiveChips({
actionItems,
running,
activeSends: activeSends.length,
alertCount,
etaText,
});
const mobileBadge = document.getElementById("mobile-jetzt-badge");
if (mobileBadge) {
const n = actionItems.length || running.length || activeSends.length || alertCount;
mobileBadge.hidden = !n;
mobileBadge.textContent = String(actionItems.length || n);
mobileBadge.classList.toggle("todo", !!actionItems.length);
}
if (typeof window.onBedarfChanged === "function") {
window.onBedarfChanged({ shortfall: Array(data.bedarf_shortfall || 0).fill({}) });
}
bindLivePanelActions(document.getElementById("live-panel"));
}
function refreshPrintQueue() { return refreshJetzt(); }
function refreshSendQueue() { return refreshJetzt(); }
function refreshEvents() { return refreshJetzt(); }
function refreshTasks() { return refreshJetzt(); }
function refreshBedarf() { return refreshJetzt(); }
(function mobileMoreSheet() { (function mobileMoreSheet() {
const sheet = document.getElementById("mobile-more-sheet"); const sheet = document.getElementById("mobile-more-sheet");
const btn = document.getElementById("mobile-more"); const btn = document.getElementById("mobile-more");
@@ -563,7 +557,6 @@ async function refreshTasks() {
btn.addEventListener("click", () => { btn.addEventListener("click", () => {
if (typeof sheet.showModal === "function") sheet.showModal(); if (typeof sheet.showModal === "function") sheet.showModal();
}); });
// Modal schließt und blockiert sonst die ganze App (Chrome).
sheet.addEventListener("click", (event) => { sheet.addEventListener("click", (event) => {
const link = event.target.closest("a[href]"); const link = event.target.closest("a[href]");
if (!link) return; if (!link) return;
@@ -581,36 +574,43 @@ if ("serviceWorker" in navigator) {
navigator.serviceWorker.register("/static/sw.js", { updateViaCache: "none" }).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) => {
event.preventDefault(); event.preventDefault();
try { await api("/ereignisse/gelesen", { method: "POST", json: {} }); } catch (error) { /* still */ } try { await api("/ereignisse/gelesen", { method: "POST", json: {} }); } catch (error) { /* still */ }
refreshEvents(); refreshJetzt();
if (typeof window.onEventsRead === "function") window.onEventsRead(); if (typeof window.onEventsRead === "function") window.onEventsRead();
}); });
(function livePopClose() {
const bar = document.getElementById("live-bar");
const pop = document.getElementById("live-pop");
if (!bar || !pop) return;
document.addEventListener("click", (event) => {
if (pop.hidden) return;
if (bar.contains(event.target)) return;
closeLivePop();
});
document.addEventListener("keydown", (event) => {
if (event.key === "Escape") closeLivePop();
});
})();
window.refreshPrintQueue = refreshPrintQueue; window.refreshPrintQueue = refreshPrintQueue;
window.refreshSendQueue = refreshSendQueue; window.refreshSendQueue = refreshSendQueue;
window.refreshBedarf = refreshBedarf; window.refreshBedarf = refreshBedarf;
refreshSendQueue();
refreshPrintQueue();
refreshEvents();
refreshTasks();
refreshBedarf();
window.refreshTasks = refreshTasks; window.refreshTasks = refreshTasks;
window.refreshJetzt = refreshJetzt;
refreshJetzt();
(function liveUpdates() { (function liveUpdates() {
let streamLive = false; let streamLive = false;
const sseKinds = { const sseKinds = {
queue: () => { queue: () => refreshJetzt(),
refreshPrintQueue(); senden: () => refreshJetzt(),
refreshBedarf(); ereignis: () => refreshJetzt(),
}, aufgaben: () => refreshJetzt(),
senden: () => refreshSendQueue(),
ereignis: () => refreshEvents(),
aufgaben: () => refreshTasks(),
drucker: () => { drucker: () => {
refreshPrintQueue(); refreshJetzt();
refreshTasks();
if (typeof window.onDruckerLive === "function") window.onDruckerLive(); if (typeof window.onDruckerLive === "function") window.onDruckerLive();
}, },
}; };
@@ -663,12 +663,7 @@ window.refreshTasks = refreshTasks;
}); });
} }
// Poller nur als Fallback, wenn SSE tot ist — sonst staut Chrome die Verbindungen. createPoller(whenStreamDown(refreshJetzt), 8000).start();
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> </script>
{% block script %}{% endblock %} {% block script %}{% endblock %}
+2 -2
View File
@@ -6,11 +6,11 @@
<meta name="csrf" content="{{ csrf }}"> <meta name="csrf" content="{{ csrf }}">
<title>Anmelden · Grotti</title> <title>Anmelden · Grotti</title>
<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="stylesheet" href="{{ url_for('static', filename='grotti.css') }}?v=40"> <link rel="stylesheet" href="{{ url_for('static', filename='grotti.css') }}?v=62">
</head> </head>
<body class="auth-body"> <body class="auth-body">
<main class="auth-card"> <main class="auth-card">
<h1 class="auth-brand"><img src="{{ url_for('static', filename='brand/logo.png') }}" alt="" width="40" height="40" decoding="async"> Grotti</h1> <h1 class="auth-brand"><img src="{{ url_for('static', filename='brand/logo.png') }}" alt="" width="44" height="44" decoding="async"><span>Grotti</span></h1>
<figure class="auth-quote" id="auth-quote" hidden> <figure class="auth-quote" id="auth-quote" hidden>
<blockquote id="auth-quote-text"></blockquote> <blockquote id="auth-quote-text"></blockquote>
<figcaption id="auth-quote-by"></figcaption> <figcaption id="auth-quote-by"></figcaption>
+12 -8
View File
@@ -34,23 +34,27 @@
</div> </div>
<div id="view-spools"> <div id="view-spools">
<div class="page-tools mat-tools"> <div class="toolbar page-tools mat-tools">
<div class="filter-bar" id="spool-filter" role="group" aria-label="Ort"> <div class="fchips" id="spool-filter" role="group" aria-label="Ort">
<button type="button" class="fchip on" data-filter="all">Alle</button> <button type="button" class="fchip on" data-filter="all">Alle</button>
<button type="button" class="fchip" data-filter="loaded">Am Drucker</button> <button type="button" class="fchip" data-filter="loaded">Am Drucker</button>
<button type="button" class="fchip" data-filter="shelf">Regal</button> <button type="button" class="fchip" data-filter="shelf">Regal</button>
<button type="button" class="fchip" data-filter="low">Wenig Rest</button> <button type="button" class="fchip" data-filter="low">Wenig Rest</button>
</div> </div>
<input id="spool-search" type="search" placeholder="Spule suchen…" autocomplete="off"> <div class="search-wrap">
<button type="button" id="spool-new"><svg class="ico"><use href="#i-spool"/></svg>Neue Spule</button> <svg class="ico"><use href="#i-search"/></svg>
<input id="spool-search" type="search" placeholder="Spule suchen…" autocomplete="off">
</div>
<div class="tools-end"><button type="button" id="spool-new" class="btn-primary"><svg class="ico"><use href="#i-plus"/></svg>Neue Spule</button></div>
</div> </div>
<p id="spool-list-note" class="tiny"></p> <p id="spool-list-note" class="tiny"></p>
<div class="split mat-split is-idle" id="spool-layout"> <div class="split mat-split is-idle" id="spool-layout">
<section class="card table-wrap mat-list"> <section class="card table-wrap mat-list">
<div class="lib-head"> <div class="card-head lib-head">
<svg class="ico head-ico"><use href="#i-spool"/></svg>
<h2>Spulen</h2> <h2>Spulen</h2>
<span class="count" id="spool-count">0</span> <span class="count-pill" id="spool-count">0</span>
</div> </div>
<table> <table>
<thead><tr><th>Spule</th><th>Charge</th><th>Sorte</th><th>Farbe</th><th>Ort</th><th class="num">Rest</th></tr></thead> <thead><tr><th>Spule</th><th>Charge</th><th>Sorte</th><th>Farbe</th><th>Ort</th><th class="num">Rest</th></tr></thead>
@@ -135,7 +139,7 @@
</details> </details>
<div class="actions"> <div class="actions">
<button type="submit">Merken</button> <button type="submit" class="btn-primary">Speichern</button>
<button type="button" class="ghost" id="spool-shelf" hidden>Ins Regal</button> <button type="button" class="ghost" id="spool-shelf" hidden>Ins Regal</button>
<button type="button" class="ghost" id="spool-label" hidden>Etikett</button> <button type="button" class="ghost" id="spool-label" hidden>Etikett</button>
<button type="button" class="warn" id="spool-delete" hidden><svg class="ico"><use href="#i-trash"/></svg>Löschen</button> <button type="button" class="warn" id="spool-delete" hidden><svg class="ico"><use href="#i-trash"/></svg>Löschen</button>
@@ -213,7 +217,7 @@
<input id="sorten-note" placeholder="optional"> <input id="sorten-note" placeholder="optional">
</div> </div>
<div class="actions"> <div class="actions">
<button type="submit">Merken</button> <button type="submit" class="btn-primary">Speichern</button>
<button type="button" class="ghost" id="sorten-use">Spule damit anlegen</button> <button type="button" class="ghost" id="sorten-use">Spule damit anlegen</button>
<button type="button" class="warn" id="sorten-delete" hidden><svg class="ico"><use href="#i-trash"/></svg>Löschen</button> <button type="button" class="warn" id="sorten-delete" hidden><svg class="ico"><use href="#i-trash"/></svg>Löschen</button>
</div> </div>
-195
View File
@@ -1,195 +0,0 @@
{% extends "layout.html" %}
{% block body %}
<div class="page-head">
<div>
<h1>Projekte</h1>
<p class="tiny">Gruppiere Dateien und Läufe.</p>
</div>
</div>
<div class="split">
<section class="card">
<h2>Liste</h2>
<div class="table-wrap">
<table>
<thead><tr><th>Name</th><th>Status</th><th class="num">Dateien</th></tr></thead>
<tbody id="project-rows"></tbody>
</table>
</div>
</section>
<section class="card">
<h2 id="project-title">Neu</h2>
<form id="project-form">
<input id="project-id" type="hidden">
<div class="field"><label for="project-name">Name</label><input id="project-name" required></div>
<div class="field"><label for="project-notes">Notiz</label><textarea id="project-notes"></textarea></div>
<div class="field"><label for="project-status">Status</label>
<select id="project-status">
<option value="idee">Idee</option>
<option value="bereit">Bereit</option>
<option value="druckt">Druckt</option>
<option value="fertig">Fertig</option>
</select></div>
<div class="actions">
<button type="submit">Merken</button>
<button type="button" class="ghost" id="project-new">Neu</button>
<button type="button" class="warn" id="project-delete">Löschen</button>
</div>
</form>
<p id="project-note"></p>
<h2 class="section-gap">Kalkulation</h2>
<label>Spule</label>
<select id="quote-spool"></select>
<div class="grid-2">
<div><label>Teilegewicht g</label><input id="quote-weight" type="number" min="0" step="1" value="0"></div>
<div><label>Stück</label><input id="quote-qty" type="number" min="1" step="1" value="1"></div>
<div><label>Ausschuss %</label><input id="quote-waste" type="number" min="0" step="1" value="0"></div>
<div><label>Auslastung %</label><input id="quote-util" type="number" min="1" max="100" step="1" value="50"></div>
<div><label>Preis €</label><input id="quote-price" type="number" min="0" step="0.01" value="0"></div>
<div>
<label>Preis gilt</label>
<select id="quote-mode">
<option value="perPart">pro Stück</option>
<option value="perKg">pro kg Teile</option>
</select>
</div>
</div>
<div class="actions"><button type="button" id="quote-go">Rechnen</button></div>
<div class="nums" id="quote-result"></div>
</section>
</div>
{% endblock %}
{% block script %}
<script>
const statusLabel = { idee: "Idee", bereit: "Bereit", druckt: "Druckt", fertig: "Fertig" };
let projects = [];
let spools = [];
function fill(project) {
document.getElementById("project-id").value = project ? project.id : "";
document.getElementById("project-name").value = project ? project.name : "";
document.getElementById("project-notes").value = project ? project.notes : "";
document.getElementById("project-status").value = project ? project.status : "idee";
document.getElementById("project-title").textContent = project ? project.name : "Neu";
document.getElementById("project-delete").hidden = !project;
const quote = project && project.quote && project.quote.inputs;
if (quote) {
document.getElementById("quote-weight").value = quote.weight_g;
document.getElementById("quote-qty").value = quote.qty;
document.getElementById("quote-waste").value = quote.waste_percent;
document.getElementById("quote-util").value = quote.utilization;
document.getElementById("quote-price").value = quote.price;
document.getElementById("quote-mode").value = quote.price_mode || "perPart";
}
if (project && project.result) paint(project.result);
else if (project && project.quote && project.quote.result) paint(project.quote.result);
else document.getElementById("quote-result").innerHTML = "";
}
function paint(result) {
document.getElementById("quote-result").innerHTML = `
<div class="num"><span>bleibt gesamt</span><b>${euro(result.margin)}</b></div>
<div class="num"><span>bleibt pro Stunde</span><b>${euro(result.per_hour)}</b></div>
<div class="num"><span>Filament</span><b>${euro(result.filament_cost)}</b></div>
<div class="num"><span>Strom</span><b>${euro(result.energy_cost)}</b></div>
<div class="num"><span>Zeit</span><b>${hours(result.hours * 3600) || "0 min"}</b></div>
<div class="num"><span>Filamentmenge</span><b>${grams(result.filament_g)}</b></div>`;
}
function render() {
const body = document.getElementById("project-rows");
body.innerHTML = "";
if (!projects.length) {
body.innerHTML = '<tr><td class="empty" colspan="3"><div class="empty-state">' + icon("folder") + '<p>Noch kein Projekt.</p></div></td></tr>';
}
projects.forEach((project) => {
const tr = document.createElement("tr");
tr.className = "pick";
tr.innerHTML = "<td></td><td></td><td></td>";
tr.children[0].textContent = project.name;
tr.children[1].textContent = statusLabel[project.status] || project.status;
tr.children[2].textContent = project.file_count || 0;
tr.addEventListener("click", async () => {
const full = await api("/projekte/" + project.id);
fill(full);
if (full.files && full.files[0] && full.files[0].weight_g && !full.quote.inputs) {
document.getElementById("quote-weight").value = full.files[0].weight_g;
}
});
body.appendChild(tr);
});
const select = document.getElementById("quote-spool");
select.innerHTML = '<option value="">ohne Spule</option>';
spools.forEach((spool) => {
const option = document.createElement("option");
option.value = spool.id;
option.textContent = spool.name;
select.appendChild(option);
});
}
async function load() {
[projects, spools] = await Promise.all([api("/projekte"), api("/spulen")]);
const settings = await api("/einstellungen");
document.getElementById("quote-waste").value = settings.energy.waste_percent;
document.getElementById("quote-util").value = settings.energy.utilization;
render();
fill(null);
}
document.getElementById("project-form").addEventListener("submit", async (event) => {
event.preventDefault();
const id = document.getElementById("project-id").value;
const payload = {
name: document.getElementById("project-name").value,
notes: document.getElementById("project-notes").value,
status: document.getElementById("project-status").value,
};
try {
const saved = id
? await api("/projekte/" + id, { method: "PATCH", json: payload })
: await api("/projekte", { method: "POST", json: payload });
showNote("project-note", null);
document.getElementById("project-note").textContent = "Gemerkt.";
await load();
fill(saved);
} catch (error) {
showNote("project-note", error);
}
});
document.getElementById("project-new").addEventListener("click", () => fill(null));
document.getElementById("project-delete").addEventListener("click", (event) => {
const id = document.getElementById("project-id").value;
if (!id) return;
armButton(event.currentTarget, "Dateien bleiben — löschen?", async () => {
try {
await api("/projekte/" + id, { method: "DELETE" });
toast("Projekt gelöscht.");
load();
} catch (error) {
showNote("project-note", error);
}
});
});
document.getElementById("quote-go").addEventListener("click", async () => {
try {
const payload = await api("/kalkulation", {
method: "POST",
json: {
project_id: document.getElementById("project-id").value || null,
spool_id: document.getElementById("quote-spool").value || null,
weight_g: Number(document.getElementById("quote-weight").value),
qty: Number(document.getElementById("quote-qty").value),
waste_percent: Number(document.getElementById("quote-waste").value),
utilization: Number(document.getElementById("quote-util").value),
price: Number(document.getElementById("quote-price").value),
price_mode: document.getElementById("quote-mode").value,
},
});
paint(payload.result);
} catch (error) {
showNote("project-note", error);
}
});
load().catch((error) => showNote("project-note", error));
</script>
{% endblock %}
+11 -3
View File
@@ -9,7 +9,7 @@
<button type="button" id="sync"><svg class="ico"><use href="#i-refresh"/></svg>Läufe holen</button> <button type="button" id="sync"><svg class="ico"><use href="#i-refresh"/></svg>Läufe holen</button>
</div> </div>
</div> </div>
<div class="page-tools"> <div class="toolbar page-tools">
<select id="run-filter-printer" title="Drucker"> <select id="run-filter-printer" title="Drucker">
<option value="">Alle Drucker</option> <option value="">Alle Drucker</option>
</select> </select>
@@ -86,16 +86,24 @@ function render() {
const fileCell = tr.children[1]; const fileCell = tr.children[1];
const link = document.createElement("a"); const link = document.createElement("a");
link.href = "/protokoll/" + run.id; link.href = "/protokoll/" + run.id;
link.className = "cell-file";
link.title = (run.filename || "").split("/").pop() || ("#" + run.id);
link.textContent = (run.filename || "").split("/").pop() || ("#" + run.id); link.textContent = (run.filename || "").split("/").pop() || ("#" + run.id);
fileCell.appendChild(link); fileCell.appendChild(link);
if (run.chargen_nr) { if (run.chargen_nr) {
const code = document.createElement("div"); const code = document.createElement("div");
code.className = "tiny"; code.className = "mono-badge";
code.textContent = run.chargen_nr; code.textContent = run.chargen_nr;
fileCell.appendChild(code); fileCell.appendChild(code);
} }
tr.children[2].textContent = states[run.state] || run.state; const stateKey = run.state === "in_progress" || run.state === "printing" ? "printing"
: run.state === "completed" ? "complete"
: run.state === "error" ? "error"
: run.state === "cancelled" || run.state === "interrupted" ? "cancelled"
: "idle";
tr.children[2].innerHTML = chipHtml(stateKey, states[run.state] || run.state);
tr.children[3].textContent = hours(run.duration_s); tr.children[3].textContent = hours(run.duration_s);
tr.children[3].className = "num";
const used = Number(run.weight_used_g) || Number(run.weight_g) || 0; const used = Number(run.weight_used_g) || Number(run.weight_g) || 0;
const planned = Number(run.weight_planned_g) || Number(run.weight_g) || 0; const planned = Number(run.weight_planned_g) || Number(run.weight_g) || 0;
tr.children[4].textContent = used ? grams(used) : ""; tr.children[4].textContent = used ? grams(used) : "";
+2 -2
View File
@@ -6,11 +6,11 @@
<meta name="csrf" content="{{ csrf }}"> <meta name="csrf" content="{{ csrf }}">
<title>Setup · Grotti</title> <title>Setup · Grotti</title>
<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="stylesheet" href="{{ url_for('static', filename='grotti.css') }}?v=40"> <link rel="stylesheet" href="{{ url_for('static', filename='grotti.css') }}?v=62">
</head> </head>
<body class="auth-body"> <body class="auth-body">
<main class="auth-card"> <main class="auth-card">
<h1 class="auth-brand"><img src="{{ url_for('static', filename='brand/logo.png') }}" alt="" width="40" height="40" decoding="async"> Grotti einrichten</h1> <h1 class="auth-brand"><img src="{{ url_for('static', filename='brand/logo.png') }}" alt="" width="44" height="44" decoding="async"><span>Grotti einrichten</span></h1>
<p class="tiny">Lege den ersten Administrator an. Danach ist Login Pflicht.</p> <p class="tiny">Lege den ersten Administrator an. Danach ist Login Pflicht.</p>
<form id="setup-form" class="stack"> <form id="setup-form" class="stack">
<div class="field"> <div class="field">
+21 -19
View File
@@ -9,30 +9,30 @@
<a class="ghost" id="csv-link" href="/api/v1/statistik.csv">CSV</a> <a class="ghost" id="csv-link" href="/api/v1/statistik.csv">CSV</a>
</div> </div>
</div> </div>
<div class="page-tools"> <div class="toolbar page-tools">
<div class="segmented period-presets" id="period" role="group" aria-label="Zeitraum"> <div class="segmented period-presets fchips" id="period" role="group" aria-label="Zeitraum">
<button type="button" data-period="all" class="on">Alles</button> <button type="button" data-period="all" class="fchip on">Alles</button>
<button type="button" data-period="day">Tag</button> <button type="button" data-period="day" class="fchip">Tag</button>
<button type="button" data-period="week">Woche</button> <button type="button" data-period="week" class="fchip">Woche</button>
<button type="button" data-period="month">Monat</button> <button type="button" data-period="month" class="fchip">Monat</button>
<button type="button" data-period="yesterday">Gestern</button> <button type="button" data-period="yesterday" class="fchip">Gestern</button>
<button type="button" data-period="last_week">Letzte Woche</button> <button type="button" data-period="last_week" class="fchip">Letzte Woche</button>
<button type="button" data-period="last_month">Letzter Monat</button> <button type="button" data-period="last_month" class="fchip">Letzter Monat</button>
</div> </div>
<input type="date" id="since" title="Von" class="date-field"> <input type="date" id="since" title="Von" class="date-field">
<input type="date" id="until" title="Bis" class="date-field"> <input type="date" id="until" title="Bis" class="date-field">
<select id="host"><option value="">Alle Drucker</option></select> <select id="host"><option value="">Alle Drucker</option></select>
<input type="text" id="material" placeholder="Sorte" size="10"> <input type="text" id="material" placeholder="Sorte" size="10">
<input type="text" id="brand" placeholder="Marke" size="10"> <input type="text" id="brand" placeholder="Marke" size="10">
<button type="button" id="reload">Aktualisieren</button> <div class="tools-end"><button type="button" id="reload" class="ghost"><svg class="ico"><use href="#i-refresh"/></svg>Aktualisieren</button></div>
</div> </div>
<p id="stat-note"></p> <p id="stat-note"></p>
<div id="stat" class="statistik"></div> <div id="stat" class="statistik"></div>
{% endblock %} {% endblock %}
{% block script %} {% block script %}
<script> <script>
function kpi(label, value, hint) { function kpi(label, value, hint, ico) {
return `<div class="kpi"><div class="kpi-val">${value}</div><div class="kpi-label">${escapeHtml(label)}</div>${hint ? '<div class="tiny">' + escapeHtml(hint) + "</div>" : ""}</div>`; return `<div class="kpi"><div class="kpi-label">${ico ? icon(ico) : ""}${escapeHtml(label)}</div><div class="kpi-value">${value}</div>${hint ? '<div class="kpi-sub">' + escapeHtml(hint) + "</div>" : ""}</div>`;
} }
function isoDate(date) { function isoDate(date) {
@@ -181,13 +181,15 @@ function render(data) {
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), "", "layers")}
${kpi("Druckzeit", hours(o.duration_s))} ${kpi("Druckzeit", hours(o.duration_s), "", "clock")}
${kpi("Verbrauch", grams(o.weight_g))} ${kpi("Verbrauch", grams(o.weight_g), "", "weight")}
${kpi("Kosten", euro(o.cost))} ${kpi("Kosten", euro(o.cost), "", "euro")}
${kpi("Erfolg", success + " %", (o.completed || 0) + " fertig · " + (o.failed || 0) + " abgebrochen")} ${kpi("Erfolg", success + " %", (o.completed || 0) + " fertig · " + (o.failed || 0) + " abgebrochen", "circle-check")}
${kpi("Ausschuss", scrap + " %", (o.scrap || 0) + " mit Urteil Ausschuss")} ${kpi("Ausschuss", scrap + " %", (o.scrap || 0) + " mit Urteil Ausschuss", "ban")}
${kpi("Schätztreue", ratio, data.estimate_count ? data.estimate_count + " Läufe mit Soll-Zeit" : "keine Soll-Zeit")} ${kpi("Stück-Yield", o.yield_percent != null ? Math.round(o.yield_percent) + " %" : "—", (o.pieces_ok || 0) + " gut · " + (o.pieces_bad || 0) + " schlecht", "percent")}
${kpi("€ / Teil", o.cost_per_part != null ? euro(o.cost_per_part) : "—", "Material + Strom ÷ gut", "tag")}
${kpi("Schätztreue", ratio, data.estimate_count ? data.estimate_count + " Läufe mit Soll-Zeit" : "keine Soll-Zeit", "gauge")}
</div> </div>
<div class="split"> <div class="split">
<div class="card"> <div class="card">
+83 -47
View File
@@ -15,7 +15,7 @@
<section class="desk no-cam" id="desk" hidden data-host=""> <section class="desk no-cam" id="desk" hidden data-host="">
<header class="desk-bar"> <header class="desk-bar">
<div class="desk-bar-top"> <div class="desk-bar-top">
<button type="button" class="ghost desk-back" id="desk-back">← Drucker</button> <button type="button" class="ghost desk-back" id="desk-back"><svg class="ico"><use href="#i-chevron-left"/></svg>Drucker</button>
<div class="desk-title"> <div class="desk-title">
<h2 data-role="name"></h2> <h2 data-role="name"></h2>
<span data-role="badge"></span> <span data-role="badge"></span>
@@ -27,7 +27,7 @@
</header> </header>
<div class="desk-stage"> <div class="desk-stage">
<details class="desk-fold desk-cam-fold" data-fold="cam" open> <details class="desk-fold desk-cam-fold" data-fold="cam" open>
<summary class="desk-fold-sum"><svg class="ico caret"><use href="#i-down"/></svg><span class="grow">Kamera</span><span class="tiny" data-role="cam-sum"></span></summary> <summary class="desk-fold-sum"><svg class="ico caret"><use href="#i-down"/></svg><svg class="ico title-ico"><use href="#i-camera"/></svg><span class="grow">Kamera</span><span class="tiny" data-role="cam-sum"></span></summary>
<div class="desk-cam" data-role="cam-stage"> <div class="desk-cam" data-role="cam-stage">
<img class="desk-cam-img" data-role="camera" alt="Kamera" hidden> <img class="desk-cam-img" data-role="camera" alt="Kamera" hidden>
<p class="desk-cam-empty" data-role="cam-empty"><svg class="ico"><use href="#i-camera"/></svg><span>Keine Kamera</span></p> <p class="desk-cam-empty" data-role="cam-empty"><svg class="ico"><use href="#i-camera"/></svg><span>Keine Kamera</span></p>
@@ -49,45 +49,51 @@
<div class="desk-facts" data-role="facts"></div> <div class="desk-facts" data-role="facts"></div>
<div data-role="flow"></div> <div data-role="flow"></div>
<div data-role="strips"></div> <div data-role="strips"></div>
<details class="desk-fold desk-steer-fold" data-fold="steer">
<summary class="desk-fold-sum"><svg class="ico caret"><use href="#i-down"/></svg><span class="grow">Steuerung</span></summary>
<div data-role="steer"></div>
</details>
</div> </div>
<div class="desk-foot"> <div class="desk-foot">
<div class="card-actions" data-role="controls"></div> <div class="card-actions" data-role="controls"></div>
<button type="button" class="warn" data-role="estop" data-steer-go="emergency_stop" hidden>E-Stop</button> <button type="button" class="btn-danger" data-role="estop" data-steer-go="emergency_stop" hidden>E-Stop</button>
</div> </div>
</aside> </aside>
</div> </div>
<div class="desk-tools"> <details class="desk-fold desk-technik" data-fold="technik">
<details class="desk-fold desk-tool" data-fold="verlauf" open> <summary class="desk-fold-sum"><svg class="ico caret"><use href="#i-down"/></svg><svg class="ico title-ico"><use href="#i-sliders"/></svg><span class="grow">Technik</span><span class="tiny">Steuerung, Verlauf, 3D, Bettnetz</span></summary>
<summary class="desk-fold-sum"><svg class="ico caret"><use href="#i-down"/></svg><span class="grow">Verlauf</span><span class="tiny" data-role="verlauf-caption"></span></summary> <div class="desk-technik-body">
<div class="desk-fold-body desk-verlauf" data-role="verlauf"> <div data-role="tech-facts" class="desk-facts desk-tech-facts"></div>
<p class="tiny">Lade Verlauf …</p> <details class="desk-fold desk-steer-fold" data-fold="steer" open>
</div> <summary class="desk-fold-sum"><svg class="ico caret"><use href="#i-down"/></svg><svg class="ico title-ico"><use href="#i-gauge"/></svg><span class="grow">Steuerung</span></summary>
</details> <div data-role="steer"></div>
<details class="desk-fold desk-tool" data-fold="path"> </details>
<summary class="desk-fold-sum"><svg class="ico caret"><use href="#i-down"/></svg><span class="grow">3D-Pfad</span><span class="tiny" data-role="path-caption"></span></summary> <div class="desk-tools">
<div class="desk-fold-body"> <details class="desk-fold desk-tool" data-fold="verlauf" open>
<p class="tiny">Ziehen dreht · Rad zoomt · Rechtsklick/Shift schwenkt · Doppelklick setzt zurück</p> <summary class="desk-fold-sum"><svg class="ico caret"><use href="#i-down"/></svg><svg class="ico title-ico"><use href="#i-activity"/></svg><span class="grow">Verlauf</span><span class="tiny" data-role="verlauf-caption"></span></summary>
<canvas class="gcode-canvas desk-canvas" data-role="path-canvas"></canvas> <div class="desk-fold-body desk-verlauf" data-role="verlauf">
<div class="exclude-panel" data-role="exclude-panel" hidden> <p class="tiny">Lade Verlauf …</p>
<div class="exclude-head">
<span class="tiny grow">Objekte ausschließen</span>
<span class="tiny" data-role="exclude-hint">Tippen wählt · zwei Klicks schließen aus</span>
</div> </div>
<div class="exclude-list" data-role="exclude-list"></div> </details>
</div> <details class="desk-fold desk-tool" data-fold="path">
<summary class="desk-fold-sum"><svg class="ico caret"><use href="#i-down"/></svg><svg class="ico title-ico"><use href="#i-cube"/></svg><span class="grow">3D-Pfad</span><span class="tiny" data-role="path-caption"></span></summary>
<div class="desk-fold-body">
<p class="tiny">Ziehen dreht · Rad zoomt · Rechtsklick/Shift schwenkt · Doppelklick setzt zurück</p>
<canvas class="gcode-canvas desk-canvas" data-role="path-canvas"></canvas>
<div class="exclude-panel" data-role="exclude-panel" hidden>
<div class="exclude-head">
<span class="tiny grow">Objekte ausschließen</span>
<span class="tiny" data-role="exclude-hint">Tippen wählt · zwei Klicks schließen aus</span>
</div>
<div class="exclude-list" data-role="exclude-list"></div>
</div>
</div>
</details>
<details class="desk-fold desk-tool desk-tool-mesh" data-fold="mesh">
<summary class="desk-fold-sum"><svg class="ico caret"><use href="#i-down"/></svg><svg class="ico title-ico"><use href="#i-grid"/></svg><span class="grow">Bettnetz</span></summary>
<div class="desk-fold-body" data-role="mesh">
<p class="desk-empty"><svg class="ico"><use href="#i-grid"/></svg><span>Noch kein Bettnetz</span></p>
</div>
</details>
</div> </div>
</details> </div>
<details class="desk-fold desk-tool desk-tool-mesh" data-fold="mesh"> </details>
<summary class="desk-fold-sum"><svg class="ico caret"><use href="#i-down"/></svg><span class="grow">Bettnetz</span></summary>
<div class="desk-fold-body" data-role="mesh">
<p class="desk-empty"><svg class="ico"><use href="#i-grid"/></svg><span>Noch kein Bettnetz</span></p>
</div>
</details>
</div>
</section> </section>
{% endblock %} {% endblock %}
{% block script %} {% block script %}
@@ -143,7 +149,7 @@ function syncDeskCamCollapsed() {
function restoreDeskFolds() { function restoreDeskFolds() {
let closed; let closed;
try { closed = JSON.parse(localStorage.getItem(DESK_FOLD_KEY) || "null"); } catch (error) { closed = null; } try { closed = JSON.parse(localStorage.getItem(DESK_FOLD_KEY) || "null"); } catch (error) { closed = null; }
if (!Array.isArray(closed)) closed = ["steer", "path", "mesh"]; if (!Array.isArray(closed)) closed = ["technik", "path", "mesh"];
deskEl.querySelectorAll("details[data-fold]").forEach((box) => { deskEl.querySelectorAll("details[data-fold]").forEach((box) => {
box.open = !closed.includes(box.dataset.fold); box.open = !closed.includes(box.dataset.fold);
box.addEventListener("toggle", () => { box.addEventListener("toggle", () => {
@@ -176,7 +182,11 @@ function setDeskCameraMode(live, note) {
} }
syncDeskCamCollapsed(); syncDeskCamCollapsed();
const sum = deskEl.querySelector('[data-role="cam-sum"]'); const sum = deskEl.querySelector('[data-role="cam-sum"]');
if (sum) sum.textContent = deskCamLive ? "live" : (note || "keine"); if (sum) {
if (!deskCamLive) sum.textContent = note || "keine";
else if (note === "stream") sum.textContent = "stream";
else sum.textContent = "live";
}
} }
function heatColor(value, low, high) { function heatColor(value, low, high) {
@@ -501,7 +511,9 @@ function heroHtml(printer, state) {
} }
if (state === "complete") { if (state === "complete") {
const took = hours(printer.elapsed); const took = hours(printer.elapsed);
return `<div class="hero idle"><span class="hero-main">fertig</span>${took ? `<span class="hero-side">nach ${escapeHtml(took)}</span>` : ""}</div>`; const last = jobLabel(printer.filename || "");
const side = [last ? "zuletzt " + last : "", took].filter(Boolean).join(", ");
return `<div class="hero idle"><span class="hero-main">bereit</span>${side ? `<span class="hero-side">${escapeHtml(side)}</span>` : ""}</div>`;
} }
if (state === "cancelled") { if (state === "cancelled") {
return `<div class="hero idle"><span class="hero-main">abgebrochen</span></div>`; return `<div class="hero idle"><span class="hero-main">abgebrochen</span></div>`;
@@ -539,10 +551,6 @@ function patchBar(box, printer, state) {
} }
function factsHtml(printer) { function factsHtml(printer) {
const part = fanPct(printer, "part") || fanPct(printer);
const chamberFan = fanPct(printer, "chamber");
const auxFan = fanPct(printer, "aux");
const speed = speedPct(printer);
const loaded = wallSpools.filter((s) => s.printer_host === printer.host); const loaded = wallSpools.filter((s) => s.printer_host === printer.host);
const spoolLine = loaded.length const spoolLine = loaded.length
? "<span>Spule <b>" + escapeHtml(loaded.map((s) => (s.ace_slot ? s.ace_slot + ": " : "") + s.name + " (" + grams(s.remaining_g) + ")").join(", ")) + "</b></span>" ? "<span>Spule <b>" + escapeHtml(loaded.map((s) => (s.ace_slot ? s.ace_slot + ": " : "") + s.name + " (" + grams(s.remaining_g) + ")").join(", ")) + "</b></span>"
@@ -552,13 +560,22 @@ function factsHtml(printer) {
${spoolLine} ${spoolLine}
${printer.material ? "<span>Material <b>" + escapeHtml(String(printer.material).split(";")[0]) + "</b></span>" : ""} ${printer.material ? "<span>Material <b>" + escapeHtml(String(printer.material).split(";")[0]) + "</b></span>" : ""}
${printer.weight ? "<span>Gewicht <b>" + grams(printer.weight) + "</b></span>" : ""} ${printer.weight ? "<span>Gewicht <b>" + grams(printer.weight) + "</b></span>" : ""}
${printer.message && printer.ok === false ? `<p class="msg">${escapeHtml(printer.message)}</p>` : ""}
`;
}
function techFactsHtml(printer) {
const part = fanPct(printer, "part") || fanPct(printer);
const chamberFan = fanPct(printer, "chamber");
const auxFan = fanPct(printer, "aux");
const speed = speedPct(printer);
return `
${part ? "<span class=\"fact-ico\">" + icon("fan") + " Teil <b>" + part + "</b></span>" : ""} ${part ? "<span class=\"fact-ico\">" + icon("fan") + " Teil <b>" + part + "</b></span>" : ""}
${chamberFan ? "<span class=\"fact-ico\">" + icon("fan-chamber") + " Bauraum <b>" + chamberFan + "</b></span>" : ""} ${chamberFan ? "<span class=\"fact-ico\">" + icon("fan-chamber") + " Bauraum <b>" + chamberFan + "</b></span>" : ""}
${auxFan ? "<span class=\"fact-ico\">" + icon("fan-aux") + " Aux <b>" + auxFan + "</b></span>" : ""} ${auxFan ? "<span class=\"fact-ico\">" + icon("fan-aux") + " Aux <b>" + auxFan + "</b></span>" : ""}
${speed ? "<span>Speed <b>" + speed + "</b></span>" : ""} ${speed ? "<span>Speed <b>" + speed + "</b></span>" : ""}
${anycubicTaskLine(printer)} ${anycubicTaskLine(printer)}
<span>Host <b>${escapeHtml(printer.host)}</b></span> <span>Host <b>${escapeHtml(printer.host)}</b></span>
${printer.message && printer.ok === false ? `<p class="msg">${escapeHtml(printer.message)}</p>` : ""}
`; `;
} }
@@ -774,19 +791,19 @@ function refreshDeskCamera(printer) {
// Layout (no-cam) nicht bei jedem Frame-Fehler umwerfen — sonst springt die // Layout (no-cam) nicht bei jedem Frame-Fehler umwerfen — sonst springt die
// ganze Bühne inkl. 3D-Viewer alle paar Sekunden. // ganze Bühne inkl. 3D-Viewer alle paar Sekunden.
img.alt = "Kamera " + (printer.name || printer.host); img.alt = "Kamera " + (printer.name || printer.host);
const next = withToken(printer.camera) + "&t=" + Math.floor(Date.now() / 3000); const streaming = typeof cameraMode === "function" && cameraMode() === "stream";
const next = typeof cameraUrl === "function" ? cameraUrl(printer, streaming ? 0 : 3000) : (withToken(printer.camera) + "&t=" + Math.floor(Date.now() / 3000));
if (img.dataset.srcKey === next) return; if (img.dataset.srcKey === next) return;
img.dataset.srcKey = next; img.dataset.srcKey = next;
const probe = new Image(); const onOk = () => {
probe.onload = () => {
if (img.dataset.srcKey !== next) return; if (img.dataset.srcKey !== next) return;
deskCamFails = 0; deskCamFails = 0;
img.src = next; img.src = next;
img.hidden = false; img.hidden = false;
empty.hidden = true; empty.hidden = true;
setDeskCameraMode(true, "live"); setDeskCameraMode(true, streaming ? "stream" : "live");
}; };
probe.onerror = () => { const onFail = () => {
if (img.dataset.srcKey !== next) return; if (img.dataset.srcKey !== next) return;
deskCamFails += 1; deskCamFails += 1;
if (deskCamFails >= 2) { if (deskCamFails >= 2) {
@@ -803,8 +820,17 @@ function refreshDeskCamera(printer) {
empty.innerHTML = icon("camera") + "<span>Kamera nicht erreichbar</span>"; empty.innerHTML = icon("camera") + "<span>Kamera nicht erreichbar</span>";
} }
const sum = deskEl.querySelector('[data-role="cam-sum"]'); const sum = deskEl.querySelector('[data-role="cam-sum"]');
if (sum && deskCamLive) sum.textContent = "live · kurz weg"; if (sum && deskCamLive) sum.textContent = streaming ? "stream · kurz weg" : "live · kurz weg";
}; };
if (streaming) {
img.onload = onOk;
img.onerror = onFail;
img.src = next;
return;
}
const probe = new Image();
probe.onload = onOk;
probe.onerror = onFail;
probe.src = next; probe.src = next;
} }
@@ -816,9 +842,18 @@ function startCameraTimer() {
if (printer) refreshDeskCamera(printer); if (printer) refreshDeskCamera(printer);
}; };
tick(); tick();
// Stream braucht keinen Poll — MJPEG läuft im <img>.
if (typeof cameraMode === "function" && cameraMode() === "stream") return;
cameraTimer = setInterval(tick, 3000); cameraTimer = setInterval(tick, 3000);
} }
window.addEventListener("grotti-camera-mode", () => {
if (!deskHost) return;
const img = deskEl.querySelector('[data-role="camera"]');
if (img) img.dataset.srcKey = "";
startCameraTimer();
});
function stopCameraTimer() { function stopCameraTimer() {
if (cameraTimer) { if (cameraTimer) {
clearInterval(cameraTimer); clearInterval(cameraTimer);
@@ -890,6 +925,7 @@ function patchDesk(printer) {
} }
setHtml(role("swatches"), swatchesHtml(printer)); setHtml(role("swatches"), swatchesHtml(printer));
setHtml(role("facts"), factsHtml(printer)); setHtml(role("facts"), factsHtml(printer));
setHtml(role("tech-facts"), techFactsHtml(printer));
setHtml(role("flow"), flowHtml(printer)); setHtml(role("flow"), flowHtml(printer));
setHtml(role("strips"), stripsHtml(printer, state)); setHtml(role("strips"), stripsHtml(printer, state));
setHtml(role("controls"), actionsHtml(state, printer)); setHtml(role("controls"), actionsHtml(state, printer));
+23 -15
View File
@@ -3,21 +3,23 @@
<div class="page-head"> <div class="page-head">
<div> <div>
<h1>Warteschlange</h1> <h1>Warteschlange</h1>
<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 Drucke. 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> <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>
<div class="filter-bar" id="q-filter" role="group" aria-label="Status"> <div class="toolbar" id="q-filter" role="group" aria-label="Status">
<button type="button" class="fchip on" data-status="">Alle aktiv</button> <div class="fchips">
<button type="button" class="fchip" data-status="running">Läuft</button> <button type="button" class="fchip on" data-status="">Alle aktiv</button>
<button type="button" class="fchip" data-status="waiting">Wartet</button> <button type="button" class="fchip" data-status="running"><svg class="ico"><use href="#i-play"/></svg>Läuft</button>
<button type="button" class="fchip" data-status="error">Fehler</button> <button type="button" class="fchip" data-status="waiting"><svg class="ico"><use href="#i-clock"/></svg>Wartet</button>
<button type="button" class="fchip" data-status="history">Historie</button> <button type="button" class="fchip" data-status="error"><svg class="ico"><use href="#i-alert"/></svg>Fehler</button>
<button type="button" class="fchip" data-status="history"><svg class="ico"><use href="#i-history"/></svg>Historie</button>
</div>
<div class="tools-end"><span id="q-note" class="tiny"></span></div>
</div> </div>
<p id="q-note" class="tiny"></p>
<div class="card table-wrap"> <div class="card table-wrap">
<table> <table>
<thead> <thead>
@@ -87,8 +89,13 @@ function render() {
if (!shown.length) { if (!shown.length) {
const hint = jobs.length const hint = jobs.length
? "Nichts in dieser Ansicht." ? "Nichts in dieser Ansicht."
: (liveDirect.length ? "Keine Queue-Aufträge — laufende Direktstarts unter „Läuft“." : "Noch kein Eintrag."); : (liveDirect.length
body.innerHTML = '<tr><td class="empty" colspan="6"><div class="empty-state">' + icon("queue") + '<p>' + hint + '</p></div></td></tr>'; ? "Keine Einträge in der Warteschlange — laufende Direktstarts unter „Läuft“."
: "Noch kein Eintrag.");
const cta = (!jobs.length && !liveDirect.length)
? '<p><a class="btn-primary" href="/dateien">' + icon("send") + ' Datei senden</a></p>'
: "";
body.innerHTML = '<tr><td class="empty" colspan="6"><div class="empty-state">' + icon("queue") + '<p>' + hint + '</p>' + cta + '</div></td></tr>';
document.getElementById("q-note").textContent = ""; document.getElementById("q-note").textContent = "";
return; return;
} }
@@ -98,11 +105,11 @@ function render() {
if (job.direct) { if (job.direct) {
return `<tr data-direct="${escapeHtml(job.host)}"> return `<tr data-direct="${escapeHtml(job.host)}">
<td>${chipHtml("printing", "läuft")}</td> <td>${chipHtml("printing", "läuft")}</td>
<td><span title="${escapeHtml(job.filename || "")}">${escapeHtml(jobLabel(job.filename))}</span></td> <td><span class="cell-file" title="${escapeHtml(job.filename || "")}">${escapeHtml(jobLabel(job.filename))}</span></td>
<td><a href="/#${encodeURIComponent(job.host)}">${escapeHtml(targetOf(job))}</a></td> <td><a href="/#${encodeURIComponent(job.host)}">${escapeHtml(targetOf(job))}</a></td>
<td>—</td> <td>—</td>
<td class="tiny">${escapeHtml(job.message || "")}</td> <td class="tiny">${escapeHtml(job.message || "")}</td>
<td class="queue-tools"><a class="icon-btn" href="/#${encodeURIComponent(job.host)}" title="Zur Wand">${icon("printer")}</a></td> <td class="actions-cell"><a class="icon-btn" href="/#${encodeURIComponent(job.host)}" title="Zur Wand" aria-label="Zur Wand">${icon("printer")}</a></td>
</tr>`; </tr>`;
} }
const waitIdx = waiting.findIndex((j) => j.id === job.id); const waitIdx = waiting.findIndex((j) => j.id === job.id);
@@ -123,13 +130,14 @@ function render() {
} else if (st === "waiting" || st === "running") { } else if (st === "waiting" || st === "running") {
tools.push(`<button type="button" class="icon-btn danger" data-cancel="${job.id}" title="Abbrechen">${icon("x")}</button>`); tools.push(`<button type="button" class="icon-btn danger" data-cancel="${job.id}" title="Abbrechen">${icon("x")}</button>`);
} }
const chipState = st === "running" ? "printing" : st === "waiting" ? "waiting" : st === "error" ? "error" : st === "done" ? "done" : st === "cancelled" || st === "dismissed" ? "dismissed" : "complete";
return `<tr data-id="${job.id}"> return `<tr data-id="${job.id}">
<td>${chipHtml(st === "running" ? "printing" : st === "waiting" ? "waiting" : st === "error" ? "error" : "complete", STATUS_LABEL[st] || st)}</td> <td>${chipHtml(chipState, STATUS_LABEL[st] || st)}</td>
<td><span title="${escapeHtml(job.filename || "")}">${escapeHtml(jobLabel(job.filename))}</span>${rep ? " · " + escapeHtml(rep) : ""}</td> <td><span class="cell-file" title="${escapeHtml(job.filename || "")}">${escapeHtml(jobLabel(job.filename))}</span>${rep ? " · " + escapeHtml(rep) : ""}</td>
<td>${job.host ? `<a href="/#${encodeURIComponent(job.host)}">${escapeHtml(targetOf(job))}</a>` : escapeHtml(targetOf(job))}</td> <td>${job.host ? `<a href="/#${encodeURIComponent(job.host)}">${escapeHtml(targetOf(job))}</a>` : escapeHtml(targetOf(job))}</td>
<td>${steps || "—"}</td> <td>${steps || "—"}</td>
<td class="tiny">${escapeHtml(job.message || "")}</td> <td class="tiny">${escapeHtml(job.message || "")}</td>
<td class="queue-tools">${tools.join("")}</td> <td class="actions-cell">${tools.join("")}</td>
</tr>`; </tr>`;
}).join(""); }).join("");
document.getElementById("q-note").textContent = shown.length === 1 ? "1 Eintrag" : shown.length + " Einträge"; document.getElementById("q-note").textContent = shown.length === 1 ? "1 Eintrag" : shown.length + " Einträge";